From 58700db5ffa71e709f5f3b1af75ec7376ec3df42 Mon Sep 17 00:00:00 2001 From: brokemac79 Date: Tue, 12 May 2026 18:45:16 +0100 Subject: [PATCH] fix(gateway): suppress heartbeat tool events in webchat (#80378) --- src/gateway/server-chat.agent-events.test.ts | 38 ++++++++++++++++++++ src/gateway/server-chat.ts | 24 +++++++++---- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index afe0526bd8fd..64dcf93ac2ac 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -1067,6 +1067,44 @@ describe("agent event handler", () => { resetAgentRunContextForTest(); }); + it("suppresses heartbeat tool events for Control UI and verbose node subscribers", () => { + const { + broadcastToConnIds, + nodeSendToSession, + sessionEventSubscribers, + toolEventRecipients, + handler, + } = createHarness({ + resolveSessionKeyForRun: () => "session-heartbeat", + }); + + registerAgentRunContext("run-heartbeat-tool", { + sessionKey: "session-heartbeat", + isHeartbeat: true, + verboseLevel: "on", + }); + toolEventRecipients.add("run-heartbeat-tool", "conn-run"); + sessionEventSubscribers.subscribe("conn-session"); + + handler({ + runId: "run-heartbeat-tool", + seq: 1, + stream: "tool", + ts: 1_234, + data: { + phase: "start", + name: "read", + toolCallId: "tool-heartbeat-1", + args: { path: "HEARTBEAT.md" }, + }, + }); + + expect(broadcastToConnIds).not.toHaveBeenCalled(); + const nodeToolCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "agent"); + expect(nodeToolCalls).toHaveLength(0); + resetAgentRunContextForTest(); + }); + it("hydrates run-scoped tool events with session ownership metadata", () => { const { broadcastToConnIds, toolEventRecipients, handler } = createHarness({ resolveSessionKeyForRun: () => "session-1", diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index ce898fa1d599..a615567d609a 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -120,6 +120,10 @@ function shouldHideHeartbeatChatOutput(runId: string, sourceRunId?: string): boo } } +function shouldSuppressHeartbeatToolEvents(runId: string, sourceRunId?: string): boolean { + return Boolean(resolveHeartbeatContext(runId, sourceRunId)?.isHeartbeat); +} + function normalizeHeartbeatChatFinalText(params: { runId: string; sourceRunId?: string; @@ -659,6 +663,8 @@ export function createAgentEventHandler({ const isToolEvent = evt.stream === "tool"; const isItemEvent = evt.stream === "item"; const toolVerbose = isToolEvent ? resolveToolVerboseLevel(evt.runId, sessionKey) : "off"; + const suppressHeartbeatToolEvents = + isToolEvent && shouldSuppressHeartbeatToolEvents(clientRunId, evt.runId); // Channel/node subscribers respect verbose; authenticated Control UI // recipients need tool result payloads to render live tool cards. const channelToolPayload = @@ -690,7 +696,13 @@ export function createAgentEventHandler({ const toolPhase = typeof evt.data?.phase === "string" ? evt.data.phase : ""; // Flush pending assistant text before tool-start events so clients can // render complete pre-tool text above tool cards (not truncated by delta throttle). - if (toolPhase === "start" && isControlUiVisible && sessionKey && !isAborted) { + if ( + toolPhase === "start" && + isControlUiVisible && + sessionKey && + !isAborted && + !suppressHeartbeatToolEvents + ) { flushBufferedChatDeltaIfNeeded(sessionKey, clientRunId, evt.runId, evt.seq); } // Always broadcast tool events to registered WS recipients with @@ -698,7 +710,7 @@ export function createAgentEventHandler({ // setting only controls whether tool details are sent as channel // messages to messaging surfaces (Telegram, Discord, etc.). const recipients = toolEventRecipients.get(evt.runId); - if (isControlUiVisible && recipients && recipients.size > 0) { + if (isControlUiVisible && !suppressHeartbeatToolEvents && recipients && recipients.size > 0) { broadcastToConnIds( "agent", sessionKey ? { ...agentPayload, ...buildSessionEventSnapshot(sessionKey) } : agentPayload, @@ -710,7 +722,7 @@ export function createAgentEventHandler({ // not know the runId in advance, so they cannot register as run-scoped // tool recipients. Mirror tool lifecycle onto a session-scoped event so // they can render live pending tool cards without polling history. - if (isControlUiVisible && sessionKey) { + if (isControlUiVisible && sessionKey && !suppressHeartbeatToolEvents) { const sessionSubscribers = sessionEventSubscribers.getAll(); if (sessionSubscribers.size > 0) { broadcastToConnIds( @@ -732,9 +744,9 @@ export function createAgentEventHandler({ } if (isControlUiVisible && sessionKey) { - // Send tool events to node/channel subscribers only when verbose is enabled; - // WS clients already received the event above via broadcastToConnIds. - if (!isToolEvent || toolVerbose !== "off") { + // Send non-heartbeat tool events to node/channel subscribers only when + // verbose is enabled; WS clients already received the event above. + if (!isToolEvent || (!suppressHeartbeatToolEvents && toolVerbose !== "off")) { nodeSendToSession( sessionKey, "agent",