* fix(gateway): flush throttled delta before emitChatFinal The 150ms throttle in emitChatDelta can suppress the last text chunk before emitChatFinal fires, causing streaming clients (e.g. ACP) to receive truncated responses. The final event carries the complete text, but clients that build responses incrementally from deltas miss the tail end. Flush one last unthrottled delta with the complete buffered text immediately before sending the final event. This ensures all streaming consumers have the full response without needing to reconcile deltas against the final payload. * fix(gateway): avoid duplicate delta flush when buffer unchanged Track the text length at the time of the last broadcast. The flush in emitChatFinal now only sends a delta if the buffer has grown since the last broadcast, preventing duplicate sends when the final delta passed the 150ms throttle and was already broadcast. * fix(gateway): honor heartbeat suppression in final delta flush * test(gateway): add final delta flush and dedupe coverage * fix(gateway): skip final flush for silent lead fragments * docs(changelog): note gateway final-delta flush fix credits --------- Co-authored-by: Jonathan Taylor <visionik@pobox.com> Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
642 lines
20 KiB
TypeScript
642 lines
20 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { loadConfig } from "../config/config.js";
|
|
import { registerAgentRunContext, resetAgentRunContextForTest } from "../infra/agent-events.js";
|
|
import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
|
|
import {
|
|
createAgentEventHandler,
|
|
createChatRunState,
|
|
createToolEventRecipientRegistry,
|
|
} from "./server-chat.js";
|
|
|
|
vi.mock("../config/config.js", () => ({
|
|
loadConfig: vi.fn(() => ({})),
|
|
}));
|
|
|
|
vi.mock("../infra/heartbeat-visibility.js", () => ({
|
|
resolveHeartbeatVisibility: vi.fn(() => ({
|
|
showOk: false,
|
|
showAlerts: true,
|
|
useIndicator: true,
|
|
})),
|
|
}));
|
|
|
|
describe("agent event handler", () => {
|
|
beforeEach(() => {
|
|
vi.mocked(loadConfig).mockReturnValue({});
|
|
vi.mocked(resolveHeartbeatVisibility).mockReturnValue({
|
|
showOk: false,
|
|
showAlerts: true,
|
|
useIndicator: true,
|
|
});
|
|
resetAgentRunContextForTest();
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetAgentRunContextForTest();
|
|
});
|
|
|
|
function createHarness(params?: {
|
|
now?: number;
|
|
resolveSessionKeyForRun?: (runId: string) => string | undefined;
|
|
}) {
|
|
const nowSpy =
|
|
params?.now === undefined ? undefined : vi.spyOn(Date, "now").mockReturnValue(params.now);
|
|
const broadcast = vi.fn();
|
|
const broadcastToConnIds = vi.fn();
|
|
const nodeSendToSession = vi.fn();
|
|
const agentRunSeq = new Map<string, number>();
|
|
const chatRunState = createChatRunState();
|
|
const toolEventRecipients = createToolEventRecipientRegistry();
|
|
|
|
const handler = createAgentEventHandler({
|
|
broadcast,
|
|
broadcastToConnIds,
|
|
nodeSendToSession,
|
|
agentRunSeq,
|
|
chatRunState,
|
|
resolveSessionKeyForRun: params?.resolveSessionKeyForRun ?? (() => undefined),
|
|
clearAgentRunContext: vi.fn(),
|
|
toolEventRecipients,
|
|
});
|
|
|
|
return {
|
|
nowSpy,
|
|
broadcast,
|
|
broadcastToConnIds,
|
|
nodeSendToSession,
|
|
agentRunSeq,
|
|
chatRunState,
|
|
toolEventRecipients,
|
|
handler,
|
|
};
|
|
}
|
|
|
|
function emitRun1AssistantText(
|
|
harness: ReturnType<typeof createHarness>,
|
|
text: string,
|
|
): ReturnType<typeof createHarness> {
|
|
harness.chatRunState.registry.add("run-1", {
|
|
sessionKey: "session-1",
|
|
clientRunId: "client-1",
|
|
});
|
|
harness.handler({
|
|
runId: "run-1",
|
|
seq: 1,
|
|
stream: "assistant",
|
|
ts: Date.now(),
|
|
data: { text },
|
|
});
|
|
return harness;
|
|
}
|
|
|
|
function chatBroadcastCalls(broadcast: ReturnType<typeof vi.fn>) {
|
|
return broadcast.mock.calls.filter(([event]) => event === "chat");
|
|
}
|
|
|
|
function sessionChatCalls(nodeSendToSession: ReturnType<typeof vi.fn>) {
|
|
return nodeSendToSession.mock.calls.filter(([, event]) => event === "chat");
|
|
}
|
|
|
|
const FALLBACK_LIFECYCLE_DATA = {
|
|
phase: "fallback",
|
|
selectedProvider: "fireworks",
|
|
selectedModel: "fireworks/minimax-m2p5",
|
|
activeProvider: "deepinfra",
|
|
activeModel: "moonshotai/Kimi-K2.5",
|
|
} as const;
|
|
|
|
function emitLifecycleEnd(
|
|
handler: ReturnType<typeof createHarness>["handler"],
|
|
runId: string,
|
|
seq = 2,
|
|
) {
|
|
handler({
|
|
runId,
|
|
seq,
|
|
stream: "lifecycle",
|
|
ts: Date.now(),
|
|
data: { phase: "end" },
|
|
});
|
|
}
|
|
|
|
function emitFallbackLifecycle(params: {
|
|
handler: ReturnType<typeof createHarness>["handler"];
|
|
runId: string;
|
|
seq?: number;
|
|
sessionKey?: string;
|
|
}) {
|
|
params.handler({
|
|
runId: params.runId,
|
|
seq: params.seq ?? 1,
|
|
stream: "lifecycle",
|
|
ts: Date.now(),
|
|
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
|
data: { ...FALLBACK_LIFECYCLE_DATA },
|
|
});
|
|
}
|
|
|
|
function expectSingleAgentBroadcastPayload(broadcast: ReturnType<typeof vi.fn>) {
|
|
const broadcastAgentCalls = broadcast.mock.calls.filter(([event]) => event === "agent");
|
|
expect(broadcastAgentCalls).toHaveLength(1);
|
|
return broadcastAgentCalls[0]?.[1] as {
|
|
runId?: string;
|
|
sessionKey?: string;
|
|
stream?: string;
|
|
data?: Record<string, unknown>;
|
|
};
|
|
}
|
|
|
|
function expectSingleFinalChatPayload(broadcast: ReturnType<typeof vi.fn>) {
|
|
const chatCalls = chatBroadcastCalls(broadcast);
|
|
expect(chatCalls).toHaveLength(1);
|
|
const payload = chatCalls[0]?.[1] as {
|
|
state?: string;
|
|
message?: unknown;
|
|
};
|
|
expect(payload.state).toBe("final");
|
|
return payload;
|
|
}
|
|
|
|
it("emits chat delta for assistant text-only events", () => {
|
|
const { broadcast, nodeSendToSession, nowSpy } = emitRun1AssistantText(
|
|
createHarness({ now: 1_000 }),
|
|
"Hello world",
|
|
);
|
|
const chatCalls = chatBroadcastCalls(broadcast);
|
|
expect(chatCalls).toHaveLength(1);
|
|
const payload = chatCalls[0]?.[1] as {
|
|
state?: string;
|
|
message?: { content?: Array<{ text?: string }> };
|
|
};
|
|
expect(payload.state).toBe("delta");
|
|
expect(payload.message?.content?.[0]?.text).toBe("Hello world");
|
|
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
|
|
nowSpy?.mockRestore();
|
|
});
|
|
|
|
it("strips inline directives from assistant chat events", () => {
|
|
const { broadcast, nodeSendToSession, nowSpy } = emitRun1AssistantText(
|
|
createHarness({ now: 1_000 }),
|
|
"Hello [[reply_to_current]] world [[audio_as_voice]]",
|
|
);
|
|
const chatCalls = chatBroadcastCalls(broadcast);
|
|
expect(chatCalls).toHaveLength(1);
|
|
const payload = chatCalls[0]?.[1] as {
|
|
message?: { content?: Array<{ text?: string }> };
|
|
};
|
|
expect(payload.message?.content?.[0]?.text).toBe("Hello world ");
|
|
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
|
|
nowSpy?.mockRestore();
|
|
});
|
|
|
|
it("does not emit chat delta for NO_REPLY streaming text", () => {
|
|
const { broadcast, nodeSendToSession, nowSpy } = emitRun1AssistantText(
|
|
createHarness({ now: 1_000 }),
|
|
" NO_REPLY ",
|
|
);
|
|
expect(chatBroadcastCalls(broadcast)).toHaveLength(0);
|
|
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(0);
|
|
nowSpy?.mockRestore();
|
|
});
|
|
|
|
it("does not include NO_REPLY text in chat final message", () => {
|
|
const { broadcast, nodeSendToSession, chatRunState, handler, nowSpy } = createHarness({
|
|
now: 2_000,
|
|
});
|
|
chatRunState.registry.add("run-2", { sessionKey: "session-2", clientRunId: "client-2" });
|
|
|
|
handler({
|
|
runId: "run-2",
|
|
seq: 1,
|
|
stream: "assistant",
|
|
ts: Date.now(),
|
|
data: { text: "NO_REPLY" },
|
|
});
|
|
emitLifecycleEnd(handler, "run-2");
|
|
|
|
const payload = expectSingleFinalChatPayload(broadcast) as { message?: unknown };
|
|
expect(payload.message).toBeUndefined();
|
|
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
|
|
nowSpy?.mockRestore();
|
|
});
|
|
|
|
it("suppresses NO_REPLY lead fragments and does not leak NO in final chat message", () => {
|
|
const { broadcast, nodeSendToSession, chatRunState, handler, nowSpy } = createHarness({
|
|
now: 2_100,
|
|
});
|
|
chatRunState.registry.add("run-3", { sessionKey: "session-3", clientRunId: "client-3" });
|
|
|
|
for (const text of ["NO", "NO_", "NO_RE", "NO_REPLY"]) {
|
|
handler({
|
|
runId: "run-3",
|
|
seq: 1,
|
|
stream: "assistant",
|
|
ts: Date.now(),
|
|
data: { text },
|
|
});
|
|
}
|
|
emitLifecycleEnd(handler, "run-3");
|
|
|
|
const payload = expectSingleFinalChatPayload(broadcast) as { message?: unknown };
|
|
expect(payload.message).toBeUndefined();
|
|
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
|
|
nowSpy?.mockRestore();
|
|
});
|
|
|
|
it("keeps final short replies like 'No' even when lead-fragment deltas are suppressed", () => {
|
|
const { broadcast, nodeSendToSession, chatRunState, handler, nowSpy } = createHarness({
|
|
now: 2_200,
|
|
});
|
|
chatRunState.registry.add("run-4", { sessionKey: "session-4", clientRunId: "client-4" });
|
|
|
|
handler({
|
|
runId: "run-4",
|
|
seq: 1,
|
|
stream: "assistant",
|
|
ts: Date.now(),
|
|
data: { text: "No" },
|
|
});
|
|
emitLifecycleEnd(handler, "run-4");
|
|
|
|
const payload = expectSingleFinalChatPayload(broadcast) as {
|
|
message?: { content?: Array<{ text?: string }> };
|
|
};
|
|
expect(payload.message?.content?.[0]?.text).toBe("No");
|
|
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
|
|
nowSpy?.mockRestore();
|
|
});
|
|
|
|
it("flushes buffered text as delta before final when throttle suppresses the latest chunk", () => {
|
|
let now = 10_000;
|
|
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
|
|
const { broadcast, nodeSendToSession, chatRunState, handler } = createHarness();
|
|
chatRunState.registry.add("run-flush", {
|
|
sessionKey: "session-flush",
|
|
clientRunId: "client-flush",
|
|
});
|
|
|
|
handler({
|
|
runId: "run-flush",
|
|
seq: 1,
|
|
stream: "assistant",
|
|
ts: Date.now(),
|
|
data: { text: "Hello" },
|
|
});
|
|
|
|
now = 10_100;
|
|
handler({
|
|
runId: "run-flush",
|
|
seq: 1,
|
|
stream: "assistant",
|
|
ts: Date.now(),
|
|
data: { text: "Hello world" },
|
|
});
|
|
|
|
emitLifecycleEnd(handler, "run-flush");
|
|
|
|
const chatCalls = chatBroadcastCalls(broadcast);
|
|
expect(chatCalls).toHaveLength(3);
|
|
const firstPayload = chatCalls[0]?.[1] as { state?: string };
|
|
const secondPayload = chatCalls[1]?.[1] as {
|
|
state?: string;
|
|
message?: { content?: Array<{ text?: string }> };
|
|
};
|
|
const thirdPayload = chatCalls[2]?.[1] as { state?: string };
|
|
expect(firstPayload.state).toBe("delta");
|
|
expect(secondPayload.state).toBe("delta");
|
|
expect(secondPayload.message?.content?.[0]?.text).toBe("Hello world");
|
|
expect(thirdPayload.state).toBe("final");
|
|
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(3);
|
|
nowSpy.mockRestore();
|
|
});
|
|
|
|
it("does not flush an extra delta when the latest text already broadcast", () => {
|
|
let now = 11_000;
|
|
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
|
|
const { broadcast, nodeSendToSession, chatRunState, handler } = createHarness();
|
|
chatRunState.registry.add("run-no-dup-flush", {
|
|
sessionKey: "session-no-dup-flush",
|
|
clientRunId: "client-no-dup-flush",
|
|
});
|
|
|
|
handler({
|
|
runId: "run-no-dup-flush",
|
|
seq: 1,
|
|
stream: "assistant",
|
|
ts: Date.now(),
|
|
data: { text: "Hello" },
|
|
});
|
|
|
|
now = 11_200;
|
|
handler({
|
|
runId: "run-no-dup-flush",
|
|
seq: 1,
|
|
stream: "assistant",
|
|
ts: Date.now(),
|
|
data: { text: "Hello world" },
|
|
});
|
|
|
|
emitLifecycleEnd(handler, "run-no-dup-flush");
|
|
|
|
const chatCalls = chatBroadcastCalls(broadcast);
|
|
expect(chatCalls).toHaveLength(3);
|
|
expect(chatCalls.map(([, payload]) => (payload as { state?: string }).state)).toEqual([
|
|
"delta",
|
|
"delta",
|
|
"final",
|
|
]);
|
|
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(3);
|
|
nowSpy.mockRestore();
|
|
});
|
|
|
|
it("cleans up agent run sequence tracking when lifecycle completes", () => {
|
|
const { agentRunSeq, chatRunState, handler, nowSpy } = createHarness({ now: 2_500 });
|
|
chatRunState.registry.add("run-cleanup", {
|
|
sessionKey: "session-cleanup",
|
|
clientRunId: "client-cleanup",
|
|
});
|
|
|
|
handler({
|
|
runId: "run-cleanup",
|
|
seq: 1,
|
|
stream: "assistant",
|
|
ts: Date.now(),
|
|
data: { text: "done" },
|
|
});
|
|
expect(agentRunSeq.get("run-cleanup")).toBe(1);
|
|
|
|
handler({
|
|
runId: "run-cleanup",
|
|
seq: 2,
|
|
stream: "lifecycle",
|
|
ts: Date.now(),
|
|
data: { phase: "end" },
|
|
});
|
|
|
|
expect(agentRunSeq.has("run-cleanup")).toBe(false);
|
|
expect(agentRunSeq.has("client-cleanup")).toBe(false);
|
|
nowSpy?.mockRestore();
|
|
});
|
|
|
|
it("routes tool events only to registered recipients when verbose is enabled", () => {
|
|
const { broadcast, broadcastToConnIds, toolEventRecipients, handler } = createHarness({
|
|
resolveSessionKeyForRun: () => "session-1",
|
|
});
|
|
|
|
registerAgentRunContext("run-tool", { sessionKey: "session-1", verboseLevel: "on" });
|
|
toolEventRecipients.add("run-tool", "conn-1");
|
|
|
|
handler({
|
|
runId: "run-tool",
|
|
seq: 1,
|
|
stream: "tool",
|
|
ts: Date.now(),
|
|
data: { phase: "start", name: "read", toolCallId: "t1" },
|
|
});
|
|
|
|
expect(broadcast).not.toHaveBeenCalled();
|
|
expect(broadcastToConnIds).toHaveBeenCalledTimes(1);
|
|
resetAgentRunContextForTest();
|
|
});
|
|
|
|
it("broadcasts tool events to WS recipients even when verbose is off, but skips node send", () => {
|
|
const { broadcastToConnIds, nodeSendToSession, toolEventRecipients, handler } = createHarness({
|
|
resolveSessionKeyForRun: () => "session-1",
|
|
});
|
|
|
|
registerAgentRunContext("run-tool-off", { sessionKey: "session-1", verboseLevel: "off" });
|
|
toolEventRecipients.add("run-tool-off", "conn-1");
|
|
|
|
handler({
|
|
runId: "run-tool-off",
|
|
seq: 1,
|
|
stream: "tool",
|
|
ts: Date.now(),
|
|
data: { phase: "start", name: "read", toolCallId: "t2" },
|
|
});
|
|
|
|
// Tool events always broadcast to registered WS recipients
|
|
expect(broadcastToConnIds).toHaveBeenCalledTimes(1);
|
|
// But node/channel subscribers should NOT receive when verbose is off
|
|
const nodeToolCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "agent");
|
|
expect(nodeToolCalls).toHaveLength(0);
|
|
resetAgentRunContextForTest();
|
|
});
|
|
|
|
it("strips tool output when verbose is on", () => {
|
|
const { broadcastToConnIds, toolEventRecipients, handler } = createHarness({
|
|
resolveSessionKeyForRun: () => "session-1",
|
|
});
|
|
|
|
registerAgentRunContext("run-tool-on", { sessionKey: "session-1", verboseLevel: "on" });
|
|
toolEventRecipients.add("run-tool-on", "conn-1");
|
|
|
|
handler({
|
|
runId: "run-tool-on",
|
|
seq: 1,
|
|
stream: "tool",
|
|
ts: Date.now(),
|
|
data: {
|
|
phase: "result",
|
|
name: "exec",
|
|
toolCallId: "t3",
|
|
result: { content: [{ type: "text", text: "secret" }] },
|
|
partialResult: { content: [{ type: "text", text: "partial" }] },
|
|
},
|
|
});
|
|
|
|
expect(broadcastToConnIds).toHaveBeenCalledTimes(1);
|
|
const payload = broadcastToConnIds.mock.calls[0]?.[1] as { data?: Record<string, unknown> };
|
|
expect(payload.data?.result).toBeUndefined();
|
|
expect(payload.data?.partialResult).toBeUndefined();
|
|
resetAgentRunContextForTest();
|
|
});
|
|
|
|
it("keeps tool output when verbose is full", () => {
|
|
const { broadcastToConnIds, toolEventRecipients, handler } = createHarness({
|
|
resolveSessionKeyForRun: () => "session-1",
|
|
});
|
|
|
|
registerAgentRunContext("run-tool-full", { sessionKey: "session-1", verboseLevel: "full" });
|
|
toolEventRecipients.add("run-tool-full", "conn-1");
|
|
|
|
const result = { content: [{ type: "text", text: "secret" }] };
|
|
handler({
|
|
runId: "run-tool-full",
|
|
seq: 1,
|
|
stream: "tool",
|
|
ts: Date.now(),
|
|
data: {
|
|
phase: "result",
|
|
name: "exec",
|
|
toolCallId: "t4",
|
|
result,
|
|
},
|
|
});
|
|
|
|
expect(broadcastToConnIds).toHaveBeenCalledTimes(1);
|
|
const payload = broadcastToConnIds.mock.calls[0]?.[1] as { data?: Record<string, unknown> };
|
|
expect(payload.data?.result).toEqual(result);
|
|
resetAgentRunContextForTest();
|
|
});
|
|
|
|
it("broadcasts fallback events to agent subscribers and node session", () => {
|
|
const { broadcast, broadcastToConnIds, nodeSendToSession, handler } = createHarness({
|
|
resolveSessionKeyForRun: () => "session-fallback",
|
|
});
|
|
|
|
emitFallbackLifecycle({ handler, runId: "run-fallback" });
|
|
|
|
expect(broadcastToConnIds).not.toHaveBeenCalled();
|
|
const payload = expectSingleAgentBroadcastPayload(broadcast);
|
|
expect(payload.stream).toBe("lifecycle");
|
|
expect(payload.data?.phase).toBe("fallback");
|
|
expect(payload.sessionKey).toBe("session-fallback");
|
|
expect(payload.data?.activeProvider).toBe("deepinfra");
|
|
|
|
const nodeCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "agent");
|
|
expect(nodeCalls).toHaveLength(1);
|
|
});
|
|
|
|
it("remaps chat-linked lifecycle runId to client runId", () => {
|
|
const { broadcast, nodeSendToSession, chatRunState, handler } = createHarness({
|
|
resolveSessionKeyForRun: () => "session-fallback",
|
|
});
|
|
chatRunState.registry.add("run-fallback-internal", {
|
|
sessionKey: "session-fallback",
|
|
clientRunId: "run-fallback-client",
|
|
});
|
|
|
|
emitFallbackLifecycle({ handler, runId: "run-fallback-internal" });
|
|
|
|
const payload = expectSingleAgentBroadcastPayload(broadcast);
|
|
expect(payload.runId).toBe("run-fallback-client");
|
|
expect(payload.stream).toBe("lifecycle");
|
|
expect(payload.data?.phase).toBe("fallback");
|
|
|
|
const nodeCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "agent");
|
|
expect(nodeCalls).toHaveLength(1);
|
|
const nodePayload = nodeCalls[0]?.[2] as { runId?: string };
|
|
expect(nodePayload.runId).toBe("run-fallback-client");
|
|
});
|
|
|
|
it("uses agent event sessionKey when run-context lookup cannot resolve", () => {
|
|
const { broadcast, handler } = createHarness({
|
|
resolveSessionKeyForRun: () => undefined,
|
|
});
|
|
|
|
emitFallbackLifecycle({
|
|
handler,
|
|
runId: "run-fallback-session-key",
|
|
sessionKey: "session-from-event",
|
|
});
|
|
|
|
const payload = expectSingleAgentBroadcastPayload(broadcast);
|
|
expect(payload.sessionKey).toBe("session-from-event");
|
|
});
|
|
|
|
it("remaps chat-linked tool runId for non-full verbose payloads", () => {
|
|
const { broadcastToConnIds, chatRunState, toolEventRecipients, handler } = createHarness({
|
|
resolveSessionKeyForRun: () => "session-tool-remap",
|
|
});
|
|
|
|
chatRunState.registry.add("run-tool-internal", {
|
|
sessionKey: "session-tool-remap",
|
|
clientRunId: "run-tool-client",
|
|
});
|
|
registerAgentRunContext("run-tool-internal", {
|
|
sessionKey: "session-tool-remap",
|
|
verboseLevel: "on",
|
|
});
|
|
toolEventRecipients.add("run-tool-internal", "conn-1");
|
|
|
|
handler({
|
|
runId: "run-tool-internal",
|
|
seq: 1,
|
|
stream: "tool",
|
|
ts: Date.now(),
|
|
data: {
|
|
phase: "result",
|
|
name: "exec",
|
|
toolCallId: "tool-remap-1",
|
|
result: { content: [{ type: "text", text: "secret" }] },
|
|
},
|
|
});
|
|
|
|
expect(broadcastToConnIds).toHaveBeenCalledTimes(1);
|
|
const payload = broadcastToConnIds.mock.calls[0]?.[1] as { runId?: string };
|
|
expect(payload.runId).toBe("run-tool-client");
|
|
resetAgentRunContextForTest();
|
|
});
|
|
|
|
it("suppresses heartbeat ack-like chat output when showOk is false", () => {
|
|
const { broadcast, nodeSendToSession, chatRunState, handler } = createHarness({
|
|
now: 2_000,
|
|
});
|
|
chatRunState.registry.add("run-heartbeat", {
|
|
sessionKey: "session-heartbeat",
|
|
clientRunId: "client-heartbeat",
|
|
});
|
|
registerAgentRunContext("run-heartbeat", {
|
|
sessionKey: "session-heartbeat",
|
|
isHeartbeat: true,
|
|
verboseLevel: "off",
|
|
});
|
|
|
|
handler({
|
|
runId: "run-heartbeat",
|
|
seq: 1,
|
|
stream: "assistant",
|
|
ts: Date.now(),
|
|
data: {
|
|
text: "HEARTBEAT_OK Read HEARTBEAT.md if it exists (workspace context). Follow it strictly.",
|
|
},
|
|
});
|
|
|
|
expect(chatBroadcastCalls(broadcast)).toHaveLength(0);
|
|
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(0);
|
|
|
|
emitLifecycleEnd(handler, "run-heartbeat");
|
|
|
|
const finalPayload = expectSingleFinalChatPayload(broadcast) as { message?: unknown };
|
|
expect(finalPayload.message).toBeUndefined();
|
|
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
|
|
});
|
|
|
|
it("keeps heartbeat alert text in final chat output when remainder exceeds ackMaxChars", () => {
|
|
vi.mocked(loadConfig).mockReturnValue({
|
|
agents: { defaults: { heartbeat: { ackMaxChars: 10 } } },
|
|
});
|
|
|
|
const { broadcast, chatRunState, handler } = createHarness({ now: 3_000 });
|
|
chatRunState.registry.add("run-heartbeat-alert", {
|
|
sessionKey: "session-heartbeat-alert",
|
|
clientRunId: "client-heartbeat-alert",
|
|
});
|
|
registerAgentRunContext("run-heartbeat-alert", {
|
|
sessionKey: "session-heartbeat-alert",
|
|
isHeartbeat: true,
|
|
verboseLevel: "off",
|
|
});
|
|
|
|
handler({
|
|
runId: "run-heartbeat-alert",
|
|
seq: 1,
|
|
stream: "assistant",
|
|
ts: Date.now(),
|
|
data: {
|
|
text: "HEARTBEAT_OK Disk usage crossed 95 percent on /data and needs cleanup now.",
|
|
},
|
|
});
|
|
|
|
emitLifecycleEnd(handler, "run-heartbeat-alert");
|
|
|
|
const payload = expectSingleFinalChatPayload(broadcast) as {
|
|
message?: { content?: Array<{ text?: string }> };
|
|
};
|
|
expect(payload.message?.content?.[0]?.text).toBe(
|
|
"Disk usage crossed 95 percent on /data and needs cleanup now.",
|
|
);
|
|
});
|
|
});
|