From 1a172ba873ef9a9439323e1c3c115377dbab152f Mon Sep 17 00:00:00 2001 From: Pocket Clawd Date: Mon, 26 Jan 2026 14:44:11 -0800 Subject: [PATCH] test(webchat): cover heartbeat visibility suppression --- .../server-chat.heartbeat-visibility.test.ts | 168 +++++++++++++++--- src/gateway/server-chat.ts | 38 ++-- 2 files changed, 159 insertions(+), 47 deletions(-) diff --git a/src/gateway/server-chat.heartbeat-visibility.test.ts b/src/gateway/server-chat.heartbeat-visibility.test.ts index a9a0d8597..c528e85bb 100644 --- a/src/gateway/server-chat.heartbeat-visibility.test.ts +++ b/src/gateway/server-chat.heartbeat-visibility.test.ts @@ -1,38 +1,42 @@ import { describe, expect, it, vi } from "vitest"; -vi.mock("../config/config.js", () => ({ - loadConfig: vi.fn(() => ({ - channels: { - defaults: { - heartbeat: { - showOk: false, - }, - }, - }, - })), -})); +describe("agent event handler (webchat heartbeat visibility)", () => { + it("suppresses HEARTBEAT_OK-only broadcasts to webchat when showOk is false (context on clientRunId)", 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, + }, + }, + }, + })), + }; + }); -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 { createAgentEventHandler, createChatRunState } = await import("./server-chat.js"); const broadcast = vi.fn(); const nodeSendToSession = vi.fn(); const agentRunSeq = new Map(); 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" }); - registerAgentRunContext("run-1", { isHeartbeat: true }); + + // server-chat uses clientRunId for the broadcast payload. + registerAgentRunContext("client-1", { isHeartbeat: true }); const handler = createAgentEventHandler({ broadcast, @@ -57,4 +61,118 @@ describe("server-chat heartbeat visibility", () => { const sessionChatCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "chat"); 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(); + 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(); + 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); + }); }); diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index f3014571a..3f57df1eb 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -39,9 +39,19 @@ function resolveWebchatHeartbeatPolicy(): WebchatHeartbeatPolicy { 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); - 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(); if (policy.showOk) return false; @@ -164,14 +174,6 @@ export function createAgentEventHandler({ resolveSessionKeyForRun, clearAgentRunContext, }: AgentEventHandlerOptions) { - const webchatHeartbeatShowOk = resolveWebchatHeartbeatShowOk(); - - const shouldSuppressHeartbeatBroadcast = (agentRunId: string): boolean => { - const runContext = getAgentRunContext(agentRunId); - if (!runContext?.isHeartbeat) return false; - return !webchatHeartbeatShowOk; - }; - const emitChatDelta = ( sessionKey: string, clientRunId: string, @@ -195,12 +197,8 @@ export function createAgentEventHandler({ timestamp: now, }, }; -<<<<<<< HEAD - // Suppress webchat broadcast for heartbeat runs when showOk is false - if (!shouldSuppressHeartbeatBroadcast(agentRunId)) { -======= - if (!shouldSuppressHeartbeatBroadcast(clientRunId, text)) { ->>>>>>> 3f72ad7bd (fix(webchat): suppress heartbeat ok broadcasts; stabilize audit/status tests) + + if (!shouldSuppressHeartbeatBroadcast(agentRunId, clientRunId, text)) { broadcast("chat", payload, { dropIfSlow: true }); } nodeSendToSession(sessionKey, "chat", payload); @@ -231,12 +229,8 @@ export function createAgentEventHandler({ } : undefined, }; -<<<<<<< HEAD - // Suppress webchat broadcast for heartbeat runs when showOk is false - if (!shouldSuppressHeartbeatBroadcast(agentRunId)) { -======= - if (!shouldSuppressHeartbeatBroadcast(clientRunId, text)) { ->>>>>>> 3f72ad7bd (fix(webchat): suppress heartbeat ok broadcasts; stabilize audit/status tests) + + if (!shouldSuppressHeartbeatBroadcast(agentRunId, clientRunId, text)) { broadcast("chat", payload); } nodeSendToSession(sessionKey, "chat", payload);