From cec996c812f11b482ed866024e7406e0982d2107 Mon Sep 17 00:00:00 2001 From: Pocket Clawd Date: Mon, 26 Jan 2026 13:37:40 -0800 Subject: [PATCH] fix(webchat): suppress heartbeat chat broadcast using agent runId - Avoid per-event config loads by caching webchat heartbeat visibility\n- Use agent runId (not client runId) to read isHeartbeat context\n- Add regression test for clientRunId mismatch --- .../server-chat.heartbeat-visibility.test.ts | 60 +++++++++++++++++++ src/gateway/server-chat.ts | 43 +++++++------ 2 files changed, 86 insertions(+), 17 deletions(-) create mode 100644 src/gateway/server-chat.heartbeat-visibility.test.ts diff --git a/src/gateway/server-chat.heartbeat-visibility.test.ts b/src/gateway/server-chat.heartbeat-visibility.test.ts new file mode 100644 index 000000000..a9a0d8597 --- /dev/null +++ b/src/gateway/server-chat.heartbeat-visibility.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../config/config.js", () => ({ + loadConfig: vi.fn(() => ({ + channels: { + defaults: { + heartbeat: { + showOk: false, + }, + }, + }, + })), +})); + +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 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 }); + + 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); + + const sessionChatCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "chat"); + expect(sessionChatCalls).toHaveLength(1); + }); +}); diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 8c67767a6..b3daec586 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -5,21 +5,13 @@ import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js"; import { loadSessionEntry } from "./session-utils.js"; import { formatForLog } from "./ws-log.js"; -/** - * Check if webchat broadcasts should be suppressed for heartbeat runs. - * Returns true if the run is a heartbeat and showOk is false. - */ -function shouldSuppressHeartbeatBroadcast(runId: string): boolean { - const runContext = getAgentRunContext(runId); - if (!runContext?.isHeartbeat) return false; - +function resolveWebchatHeartbeatShowOk(): boolean { try { const cfg = loadConfig(); - const visibility = resolveHeartbeatVisibility({ cfg, channel: "webchat" }); - return !visibility.showOk; + return resolveHeartbeatVisibility({ cfg, channel: "webchat" }).showOk; } catch { - // Default to suppressing if we can't load config - return true; + // Default: hide HEARTBEAT_OK noise from webchat + return false; } } @@ -133,7 +125,21 @@ export function createAgentEventHandler({ resolveSessionKeyForRun, clearAgentRunContext, }: AgentEventHandlerOptions) { - const emitChatDelta = (sessionKey: string, clientRunId: string, seq: number, text: string) => { + 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, + seq: number, + text: string, + agentRunId: string, + ) => { chatRunState.buffers.set(clientRunId, text); const now = Date.now(); const last = chatRunState.deltaSentAt.get(clientRunId) ?? 0; @@ -151,7 +157,7 @@ export function createAgentEventHandler({ }, }; // Suppress webchat broadcast for heartbeat runs when showOk is false - if (!shouldSuppressHeartbeatBroadcast(clientRunId)) { + if (!shouldSuppressHeartbeatBroadcast(agentRunId)) { broadcast("chat", payload, { dropIfSlow: true }); } nodeSendToSession(sessionKey, "chat", payload); @@ -162,7 +168,8 @@ export function createAgentEventHandler({ clientRunId: string, seq: number, jobState: "done" | "error", - error?: unknown, + error: unknown | undefined, + agentRunId: string, ) => { const text = chatRunState.buffers.get(clientRunId)?.trim() ?? ""; chatRunState.buffers.delete(clientRunId); @@ -182,7 +189,7 @@ export function createAgentEventHandler({ : undefined, }; // Suppress webchat broadcast for heartbeat runs when showOk is false - if (!shouldSuppressHeartbeatBroadcast(clientRunId)) { + if (!shouldSuppressHeartbeatBroadcast(agentRunId)) { broadcast("chat", payload); } nodeSendToSession(sessionKey, "chat", payload); @@ -250,7 +257,7 @@ export function createAgentEventHandler({ if (sessionKey) { nodeSendToSession(sessionKey, "agent", agentPayload); if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") { - emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text); + emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text, evt.runId); } else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) { if (chatLink) { const finished = chatRunState.registry.shift(evt.runId); @@ -264,6 +271,7 @@ export function createAgentEventHandler({ evt.seq, lifecyclePhase === "error" ? "error" : "done", evt.data?.error, + evt.runId, ); } else { emitChatFinal( @@ -272,6 +280,7 @@ export function createAgentEventHandler({ evt.seq, lifecyclePhase === "error" ? "error" : "done", evt.data?.error, + evt.runId, ); } } else if (isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {