Gateway/TUI: filter heartbeat ACK noise in chat events

This commit is contained in:
Vignesh Natarajan 2026-02-20 20:23:17 -08:00
parent 1ded4c672a
commit 2227840989
3 changed files with 196 additions and 17 deletions

View File

@ -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.

View File

@ -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.",
);
});
});

View File

@ -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,