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(); 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, text: string, ): ReturnType { 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) { return broadcast.mock.calls.filter(([event]) => event === "chat"); } function sessionChatCalls(nodeSendToSession: ReturnType) { 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["handler"], runId: string, seq = 2, ) { handler({ runId, seq, stream: "lifecycle", ts: Date.now(), data: { phase: "end" }, }); } function emitFallbackLifecycle(params: { handler: ReturnType["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) { 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; }; } function expectSingleFinalChatPayload(broadcast: ReturnType) { 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("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 }; 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 }; 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.", ); }); });