Files
Moltbot/src/gateway/server-chat.agent-events.test.ts
2026-02-21 20:08:55 +01:00

529 lines
17 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");
}
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" },
});
handler({
runId: "run-2",
seq: 2,
stream: "lifecycle",
ts: Date.now(),
data: { phase: "end" },
});
const chatCalls = chatBroadcastCalls(broadcast);
expect(chatCalls).toHaveLength(1);
const payload = chatCalls[0]?.[1] as { state?: string; message?: unknown };
expect(payload.state).toBe("final");
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<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",
});
handler({
runId: "run-fallback",
seq: 1,
stream: "lifecycle",
ts: Date.now(),
data: {
phase: "fallback",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
},
});
expect(broadcastToConnIds).not.toHaveBeenCalled();
const broadcastAgentCalls = broadcast.mock.calls.filter(([event]) => event === "agent");
expect(broadcastAgentCalls).toHaveLength(1);
const payload = broadcastAgentCalls[0]?.[1] as {
sessionKey?: string;
stream?: string;
data?: Record<string, unknown>;
};
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",
});
handler({
runId: "run-fallback-internal",
seq: 1,
stream: "lifecycle",
ts: Date.now(),
data: {
phase: "fallback",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
},
});
const broadcastAgentCalls = broadcast.mock.calls.filter(([event]) => event === "agent");
expect(broadcastAgentCalls).toHaveLength(1);
const payload = broadcastAgentCalls[0]?.[1] as {
runId?: string;
sessionKey?: string;
stream?: string;
data?: Record<string, unknown>;
};
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,
});
handler({
runId: "run-fallback-session-key",
seq: 1,
stream: "lifecycle",
ts: Date.now(),
sessionKey: "session-from-event",
data: {
phase: "fallback",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
},
});
const broadcastAgentCalls = broadcast.mock.calls.filter(([event]) => event === "agent");
expect(broadcastAgentCalls).toHaveLength(1);
const payload = broadcastAgentCalls[0]?.[1] as { sessionKey?: string };
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);
handler({
runId: "run-heartbeat",
seq: 2,
stream: "lifecycle",
ts: Date.now(),
data: { phase: "end" },
});
const chatCalls = chatBroadcastCalls(broadcast);
expect(chatCalls).toHaveLength(1);
const finalPayload = chatCalls[0]?.[1] as { state?: string; message?: unknown };
expect(finalPayload.state).toBe("final");
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.",
},
});
handler({
runId: "run-heartbeat-alert",
seq: 2,
stream: "lifecycle",
ts: Date.now(),
data: { phase: "end" },
});
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("final");
expect(payload.message?.content?.[0]?.text).toBe(
"Disk usage crossed 95 percent on /data and needs cleanup now.",
);
});
});