test(webchat): cover heartbeat visibility suppression

This commit is contained in:
Pocket Clawd 2026-01-26 14:44:11 -08:00
parent 1284c3d868
commit 1a172ba873
2 changed files with 159 additions and 47 deletions

View File

@ -1,38 +1,42 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
vi.mock("../config/config.js", () => ({ describe("agent event handler (webchat heartbeat visibility)", () => {
loadConfig: vi.fn(() => ({ it("suppresses HEARTBEAT_OK-only broadcasts to webchat when showOk is false (context on clientRunId)", async () => {
channels: { vi.resetModules();
defaults: { vi.doMock("../config/config.js", () => {
heartbeat: { return {
showOk: false, loadConfig: vi.fn(() => ({
}, agents: {
}, defaults: {
}, heartbeat: {
})), ackMaxChars: 30,
})); },
},
},
channels: {
defaults: {
heartbeat: {
showOk: false,
showAlerts: true,
useIndicator: true,
},
},
},
})),
};
});
vi.mock("../infra/heartbeat-visibility.js", () => ({
resolveHeartbeatVisibility: vi.fn(() => ({
showOk: false,
showAlerts: true,
useIndicator: true,
})),
}));
describe("server-chat heartbeat visibility", () => {
it("suppresses webchat chat broadcast for heartbeat runs (even when clientRunId differs)", async () => {
const { createAgentEventHandler, createChatRunState } = await import("./server-chat.js");
const { registerAgentRunContext } = await import("../infra/agent-events.js"); const { registerAgentRunContext } = await import("../infra/agent-events.js");
const { createAgentEventHandler, createChatRunState } = await import("./server-chat.js");
const broadcast = vi.fn(); const broadcast = vi.fn();
const nodeSendToSession = vi.fn(); const nodeSendToSession = vi.fn();
const agentRunSeq = new Map<string, number>(); const agentRunSeq = new Map<string, number>();
const chatRunState = createChatRunState(); const chatRunState = createChatRunState();
// runId is the agent run id; clientRunId is what webchat uses in payloads.
chatRunState.registry.add("run-1", { sessionKey: "session-1", clientRunId: "client-1" }); chatRunState.registry.add("run-1", { sessionKey: "session-1", clientRunId: "client-1" });
registerAgentRunContext("run-1", { isHeartbeat: true });
// server-chat uses clientRunId for the broadcast payload.
registerAgentRunContext("client-1", { isHeartbeat: true });
const handler = createAgentEventHandler({ const handler = createAgentEventHandler({
broadcast, broadcast,
@ -57,4 +61,118 @@ describe("server-chat heartbeat visibility", () => {
const sessionChatCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "chat"); const sessionChatCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "chat");
expect(sessionChatCalls).toHaveLength(1); expect(sessionChatCalls).toHaveLength(1);
}); });
it("suppresses when heartbeat context is only registered under agentRunId (clientRunId differs)", async () => {
vi.resetModules();
vi.doMock("../config/config.js", () => {
return {
loadConfig: vi.fn(() => ({
agents: {
defaults: {
heartbeat: {
ackMaxChars: 30,
},
},
},
channels: {
defaults: {
heartbeat: {
showOk: false,
},
},
},
})),
};
});
const { registerAgentRunContext } = await import("../infra/agent-events.js");
const { createAgentEventHandler, createChatRunState } = await import("./server-chat.js");
const broadcast = vi.fn();
const nodeSendToSession = vi.fn();
const agentRunSeq = new Map<string, number>();
const chatRunState = createChatRunState();
chatRunState.registry.add("run-1", { sessionKey: "session-1", clientRunId: "client-1" });
// This matches how agent-runner registers context (runId), while webchat may use a separate clientRunId.
registerAgentRunContext("run-1", { isHeartbeat: true });
const handler = createAgentEventHandler({
broadcast,
nodeSendToSession,
agentRunSeq,
chatRunState,
resolveSessionKeyForRun: () => undefined,
clearAgentRunContext: vi.fn(),
});
handler({
runId: "run-1",
seq: 1,
stream: "assistant",
ts: Date.now(),
data: { text: "HEARTBEAT_OK" },
});
const chatCalls = broadcast.mock.calls.filter(([event]) => event === "chat");
expect(chatCalls).toHaveLength(0);
});
it("still broadcasts non-HEARTBEAT_OK heartbeat alerts to webchat when showOk is false", async () => {
vi.resetModules();
vi.doMock("../config/config.js", () => {
return {
loadConfig: vi.fn(() => ({
agents: {
defaults: {
heartbeat: {
ackMaxChars: 30,
},
},
},
channels: {
defaults: {
heartbeat: {
showOk: false,
showAlerts: true,
useIndicator: true,
},
},
},
})),
};
});
const { registerAgentRunContext } = await import("../infra/agent-events.js");
const { createAgentEventHandler, createChatRunState } = await import("./server-chat.js");
const broadcast = vi.fn();
const nodeSendToSession = vi.fn();
const agentRunSeq = new Map<string, number>();
const chatRunState = createChatRunState();
chatRunState.registry.add("run-1", { sessionKey: "session-1", clientRunId: "client-1" });
registerAgentRunContext("client-1", { isHeartbeat: true });
const handler = createAgentEventHandler({
broadcast,
nodeSendToSession,
agentRunSeq,
chatRunState,
resolveSessionKeyForRun: () => undefined,
clearAgentRunContext: vi.fn(),
});
handler({
runId: "run-1",
seq: 1,
stream: "assistant",
ts: Date.now(),
data: { text: "ALERT: something happened" },
});
const chatCalls = broadcast.mock.calls.filter(([event]) => event === "chat");
expect(chatCalls).toHaveLength(1);
});
}); });

View File

@ -39,9 +39,19 @@ function resolveWebchatHeartbeatPolicy(): WebchatHeartbeatPolicy {
return policy; return policy;
} }
function shouldSuppressHeartbeatBroadcast(runId: string, text: string | undefined): boolean { function resolveHeartbeatContextIsHeartbeat(runId: string, clientRunId: string): boolean {
const clientContext = getAgentRunContext(clientRunId);
if (clientContext?.isHeartbeat !== undefined) return clientContext.isHeartbeat;
const runContext = getAgentRunContext(runId); const runContext = getAgentRunContext(runId);
if (!runContext?.isHeartbeat) return false; return Boolean(runContext?.isHeartbeat);
}
function shouldSuppressHeartbeatBroadcast(
runId: string,
clientRunId: string,
text: string | undefined,
): boolean {
if (!resolveHeartbeatContextIsHeartbeat(runId, clientRunId)) return false;
const policy = resolveWebchatHeartbeatPolicy(); const policy = resolveWebchatHeartbeatPolicy();
if (policy.showOk) return false; if (policy.showOk) return false;
@ -164,14 +174,6 @@ export function createAgentEventHandler({
resolveSessionKeyForRun, resolveSessionKeyForRun,
clearAgentRunContext, clearAgentRunContext,
}: AgentEventHandlerOptions) { }: AgentEventHandlerOptions) {
const webchatHeartbeatShowOk = resolveWebchatHeartbeatShowOk();
const shouldSuppressHeartbeatBroadcast = (agentRunId: string): boolean => {
const runContext = getAgentRunContext(agentRunId);
if (!runContext?.isHeartbeat) return false;
return !webchatHeartbeatShowOk;
};
const emitChatDelta = ( const emitChatDelta = (
sessionKey: string, sessionKey: string,
clientRunId: string, clientRunId: string,
@ -195,12 +197,8 @@ export function createAgentEventHandler({
timestamp: now, timestamp: now,
}, },
}; };
<<<<<<< HEAD
// Suppress webchat broadcast for heartbeat runs when showOk is false if (!shouldSuppressHeartbeatBroadcast(agentRunId, clientRunId, text)) {
if (!shouldSuppressHeartbeatBroadcast(agentRunId)) {
=======
if (!shouldSuppressHeartbeatBroadcast(clientRunId, text)) {
>>>>>>> 3f72ad7bd (fix(webchat): suppress heartbeat ok broadcasts; stabilize audit/status tests)
broadcast("chat", payload, { dropIfSlow: true }); broadcast("chat", payload, { dropIfSlow: true });
} }
nodeSendToSession(sessionKey, "chat", payload); nodeSendToSession(sessionKey, "chat", payload);
@ -231,12 +229,8 @@ export function createAgentEventHandler({
} }
: undefined, : undefined,
}; };
<<<<<<< HEAD
// Suppress webchat broadcast for heartbeat runs when showOk is false if (!shouldSuppressHeartbeatBroadcast(agentRunId, clientRunId, text)) {
if (!shouldSuppressHeartbeatBroadcast(agentRunId)) {
=======
if (!shouldSuppressHeartbeatBroadcast(clientRunId, text)) {
>>>>>>> 3f72ad7bd (fix(webchat): suppress heartbeat ok broadcasts; stabilize audit/status tests)
broadcast("chat", payload); broadcast("chat", payload);
} }
nodeSendToSession(sessionKey, "chat", payload); nodeSendToSession(sessionKey, "chat", payload);