From 2227840989c16c93e20d13ab6c44ae65508bde39 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Fri, 20 Feb 2026 20:23:17 -0800 Subject: [PATCH] Gateway/TUI: filter heartbeat ACK noise in chat events --- CHANGELOG.md | 1 + src/gateway/server-chat.agent-events.test.ts | 119 ++++++++++++++++++- src/gateway/server-chat.ts | 93 ++++++++++++--- 3 files changed, 196 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e397567e2bc..1df7baf5baf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai - CLI/Pairing: default `pairing list` and `pairing approve` to the sole available pairing channel when omitted, so TUI-only setups can recover from `pairing required` without guessing channel arguments. (#21527) Thanks @losts1. - TUI/Pairing: show explicit pairing-required recovery guidance after gateway disconnects that return `pairing required`, including approval steps to unblock quickstart TUI hatching on fresh installs. (#21841) Thanks @nicolinux. - TUI/Input: suppress duplicate backspace events arriving in the same input burst window so SSH sessions no longer delete two characters per backspace press in the composer. (#19318) Thanks @eheimer. +- TUI/Heartbeat: suppress heartbeat ACK/prompt noise in chat streaming when `showOk` is disabled, while still preserving non-ACK heartbeat alerts in final output. (#20228) Thanks @bhalliburton. - Memory/QMD: diversify mixed-source search ranking when both session and memory collections are present so session transcript hits no longer crowd out durable memory-file matches in top results. (#19913) Thanks @alextempr. - Auth/Onboarding: align OAuth profile-id config mapping with stored credential IDs for OpenAI Codex and Chutes flows, preventing `provider:default` mismatches when OAuth returns email-scoped credentials. (#12692) thanks @mudrii. - Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek. diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index 10db72bdccb..9cdbcf87f9f 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -1,12 +1,40 @@ -import { describe, expect, it, vi } from "vitest"; +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; @@ -393,4 +421,93 @@ describe("agent event handler", () => { 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.", + ); + }); }); diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index a40353e5bae..fa4f292a522 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -1,3 +1,4 @@ +import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "../auto-reply/heartbeat.js"; import { normalizeVerboseLevel } from "../auto-reply/thinking.js"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { loadConfig } from "../config/config.js"; @@ -6,12 +7,37 @@ import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js"; import { loadSessionEntry } from "./session-utils.js"; import { formatForLog } from "./ws-log.js"; +function resolveHeartbeatAckMaxChars(): number { + try { + const cfg = loadConfig(); + return Math.max( + 0, + cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + ); + } catch { + return DEFAULT_HEARTBEAT_ACK_MAX_CHARS; + } +} + +function resolveHeartbeatContext(runId: string, sourceRunId?: string) { + const primary = getAgentRunContext(runId); + if (primary?.isHeartbeat) { + return primary; + } + if (sourceRunId && sourceRunId !== runId) { + const source = getAgentRunContext(sourceRunId); + if (source?.isHeartbeat) { + return source; + } + } + return primary; +} + /** - * Check if webchat broadcasts should be suppressed for heartbeat runs. - * Returns true if the run is a heartbeat and showOk is false. + * Check if heartbeat ACK/noise should be hidden from interactive chat surfaces. */ -function shouldSuppressHeartbeatBroadcast(runId: string): boolean { - const runContext = getAgentRunContext(runId); +function shouldHideHeartbeatChatOutput(runId: string, sourceRunId?: string): boolean { + const runContext = resolveHeartbeatContext(runId, sourceRunId); if (!runContext?.isHeartbeat) { return false; } @@ -26,6 +52,28 @@ function shouldSuppressHeartbeatBroadcast(runId: string): boolean { } } +function normalizeHeartbeatChatFinalText(params: { + runId: string; + sourceRunId?: string; + text: string; +}): { suppress: boolean; text: string } { + if (!shouldHideHeartbeatChatOutput(params.runId, params.sourceRunId)) { + return { suppress: false, text: params.text }; + } + + const stripped = stripHeartbeatToken(params.text, { + mode: "heartbeat", + maxAckChars: resolveHeartbeatAckMaxChars(), + }); + if (!stripped.didStrip) { + return { suppress: false, text: params.text }; + } + if (stripped.shouldSkip) { + return { suppress: true, text: "" }; + } + return { suppress: false, text: stripped.text }; +} + export type ChatRunEntry = { sessionKey: string; clientRunId: string; @@ -228,11 +276,20 @@ export function createAgentEventHandler({ clearAgentRunContext, toolEventRecipients, }: AgentEventHandlerOptions) { - const emitChatDelta = (sessionKey: string, clientRunId: string, seq: number, text: string) => { + const emitChatDelta = ( + sessionKey: string, + clientRunId: string, + sourceRunId: string, + seq: number, + text: string, + ) => { if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) { return; } chatRunState.buffers.set(clientRunId, text); + if (shouldHideHeartbeatChatOutput(clientRunId, sourceRunId)) { + return; + } const now = Date.now(); const last = chatRunState.deltaSentAt.get(clientRunId) ?? 0; if (now - last < 150) { @@ -250,22 +307,27 @@ export function createAgentEventHandler({ timestamp: now, }, }; - // Suppress webchat broadcast for heartbeat runs when showOk is false - if (!shouldSuppressHeartbeatBroadcast(clientRunId)) { - broadcast("chat", payload, { dropIfSlow: true }); - } + broadcast("chat", payload, { dropIfSlow: true }); nodeSendToSession(sessionKey, "chat", payload); }; const emitChatFinal = ( sessionKey: string, clientRunId: string, + sourceRunId: string, seq: number, jobState: "done" | "error", error?: unknown, ) => { - const text = chatRunState.buffers.get(clientRunId)?.trim() ?? ""; - const shouldSuppressSilent = isSilentReplyText(text, SILENT_REPLY_TOKEN); + const bufferedText = chatRunState.buffers.get(clientRunId)?.trim() ?? ""; + const normalizedHeartbeatText = normalizeHeartbeatChatFinalText({ + runId: clientRunId, + sourceRunId, + text: bufferedText, + }); + const text = normalizedHeartbeatText.text.trim(); + const shouldSuppressSilent = + normalizedHeartbeatText.suppress || isSilentReplyText(text, SILENT_REPLY_TOKEN); chatRunState.buffers.delete(clientRunId); chatRunState.deltaSentAt.delete(clientRunId); if (jobState === "done") { @@ -283,10 +345,7 @@ export function createAgentEventHandler({ } : undefined, }; - // Suppress webchat broadcast for heartbeat runs when showOk is false - if (!shouldSuppressHeartbeatBroadcast(clientRunId)) { - broadcast("chat", payload); - } + broadcast("chat", payload); nodeSendToSession(sessionKey, "chat", payload); return; } @@ -388,7 +447,7 @@ export function createAgentEventHandler({ nodeSendToSession(sessionKey, "agent", isToolEvent ? toolPayload : agentPayload); } if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") { - emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text); + emitChatDelta(sessionKey, clientRunId, evt.runId, evt.seq, evt.data.text); } else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) { if (chatLink) { const finished = chatRunState.registry.shift(evt.runId); @@ -399,6 +458,7 @@ export function createAgentEventHandler({ emitChatFinal( finished.sessionKey, finished.clientRunId, + evt.runId, evt.seq, lifecyclePhase === "error" ? "error" : "done", evt.data?.error, @@ -407,6 +467,7 @@ export function createAgentEventHandler({ emitChatFinal( sessionKey, eventRunId, + evt.runId, evt.seq, lifecyclePhase === "error" ? "error" : "done", evt.data?.error,