Skip to content

Commit 1d6de8d

Browse files
authored
fix(ui): hide heartbeat acknowledgements
Suppress assistant HEARTBEAT_OK acknowledgements at the Control UI live-event and persisted-history render boundaries. The persisted transcript case can include hidden thinking/reasoning blocks plus a final HEARTBEAT_OK text block, so the display filter now ignores hidden reasoning while preserving turns with visible non-text content. Validation: - pnpm test ui/src/ui/controllers/chat.test.ts ui/src/ui/chat/build-chat-items.test.ts - pnpm exec oxfmt --check --threads=1 CHANGELOG.md docs/web/control-ui.md ui/src/ui/chat/build-chat-items.test.ts ui/src/ui/chat/build-chat-items.ts ui/src/ui/chat/heartbeat-display.ts ui/src/ui/controllers/chat.test.ts ui/src/ui/controllers/chat.ts - git diff --check - pnpm check:changelog-attributions - Testbox: pnpm check:changed - In-app browser preview confirmed HEARTBEAT_OK count 0 in the astra chat DOM
1 parent 2de0113 commit 1d6de8d

7 files changed

Lines changed: 279 additions & 123 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Docs: https://docs.openclaw.ai
3131
- Channels/streaming: cap progress-draft tool lines by default so edited progress boxes avoid jumpy reflow from long wrapped lines.
3232
- Agents/verbose: use compact explain-mode tool summaries for `/verbose` and progress drafts by default, with `agents.defaults.toolProgressDetail: "raw"` and per-agent overrides for debugging raw command/detail output.
3333
- Control UI/chat: add an agent-first filter to the chat session picker, keep chat controls/composer responsive across phone/tablet/desktop widths, keep desktop chat controls on one row, avoid duplicate avatar refreshes during initial chat load, and hide that row while scrolling down the transcript. Thanks @BunsDev.
34-
- Control UI/chat: collapse consecutive duplicate text messages into one bubble with a count so no-op heartbeat acknowledgements stay compact without hiding nearby context.
34+
- Control UI/chat: collapse consecutive duplicate text messages into one bubble with a count so repeated text-only messages stay compact without hiding nearby context.
3535
- Agents/subagents: preserve every grouped child result when direct completion fallback has to bypass the requester-agent announce turn. Thanks @vincentkoc.
3636
- TTS/telephony: honor provider voice/model overrides in telephony synthesis providers so Google Meet agent speech logs match the backend that actually produced the audio. Thanks @vincentkoc.
3737
- Voice Call/realtime: bound the paced Twilio audio queue and close overloaded realtime streams before provider audio can pile up behind the websocket backpressure guard. Thanks @vincentkoc.
@@ -70,6 +70,7 @@ Docs: https://docs.openclaw.ai
7070
- Plugins/install: honor the beta update channel for onboarding and doctor-managed plugin installs by requesting floating npm and ClawHub specs with `@beta` while keeping persistent install records on the catalog default. Thanks @vincentkoc.
7171
- WhatsApp/onboarding: canonicalize setup and pairing allowlist entries to WhatsApp's digit-only phone ids while still accepting E.164, JID, and `whatsapp:` inputs, so personal-phone allowlists match WhatsApp Web sender ids after setup. Thanks @vincentkoc.
7272
- Gateway/startup: load provider plugins that own explicitly configured image, video, or music generation defaults so generation tools become live after gateway restart instead of remaining catalog-only. Fixes #77244. Thanks @buyuangtampan, @Nikoxx99, and @vincentkoc.
73+
- Control UI/chat: suppress `HEARTBEAT_OK` acknowledgement history, streams, deltas, and final events before they enter the transcript view, so repeated heartbeat no-op turns do not stack noisy bubbles. Thanks @BunsDev.
7374
- Slack/subagents: keep resumed parent `message.send` calls in the originating Slack thread when ambient session thread context is present, and suppress successful silent child completion rows from follow-up findings. Thanks @bek91.
7475
- Slack/mentions: record thread participation for successful visible threaded Slack sends, including message-tool and media delivery paths, so unmentioned replies in bot-participated threads can bypass mention gating as documented. Fixes #77648. Thanks @bek91.
7576
- Infra/Windows: skip the POSIX `/tmp/openclaw` preferred path on Windows in `resolvePreferredOpenClawTmpDir` so log files, TTS temp files, and other writes land in `%TEMP%\openclaw-<uid>` instead of `C:\tmp\openclaw`. Fixes #60713. Thanks @juan-flores077.

docs/web/control-ui.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ Imported themes are stored only in the current browser profile. They are not wri
154154
- Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion.
155155
- `chat.history` responses are size-bounded for UI safety. When transcript entries are too large, Gateway may truncate long text fields, omit heavy metadata blocks, and replace oversized messages with a placeholder (`[chat.history omitted: message too large]`).
156156
- Assistant/generated images are persisted as managed media references and served back through authenticated Gateway media URLs, so reloads do not depend on raw base64 image payloads staying in the chat history response.
157-
- `chat.history` also strips display-only inline directive tags from visible assistant text (for example `[[reply_to_*]]` and `[[audio_as_voice]]`), plain-text tool-call XML payloads (including `<tool_call>...</tool_call>`, `<function_call>...</function_call>`, `<tool_calls>...</tool_calls>`, `<function_calls>...</function_calls>`, and truncated tool-call blocks), and leaked ASCII/full-width model control tokens, and omits assistant entries whose whole visible text is only the exact silent token `NO_REPLY` / `no_reply`.
157+
- When rendering `chat.history`, the Control UI strips display-only inline directive tags from visible assistant text (for example `[[reply_to_*]]` and `[[audio_as_voice]]`), plain-text tool-call XML payloads (including `<tool_call>...</tool_call>`, `<function_call>...</function_call>`, `<tool_calls>...</tool_calls>`, `<function_calls>...</function_calls>`, and truncated tool-call blocks), and leaked ASCII/full-width model control tokens, and omits assistant entries whose whole visible text is only the exact silent token `NO_REPLY` / `no_reply` or the heartbeat acknowledgement token `HEARTBEAT_OK`.
158158
- During an active send and the final history refresh, the chat view keeps local optimistic user/assistant messages visible if `chat.history` briefly returns an older snapshot; the canonical transcript replaces those local messages once the Gateway history catches up.
159159
- Live `chat` events are delivery state, while `chat.history` is rebuilt from the durable session transcript. After tool-final events the Control UI reloads history and merges only a small optimistic tail; the transcript boundary is documented in [WebChat](/web/webchat).
160160
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).

ui/src/ui/chat/build-chat-items.test.ts

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@ describe("buildChatItems", () => {
5050
it("collapses consecutive duplicate text messages into one rendered item with a count", () => {
5151
const groups = messageGroups({
5252
messages: [
53-
{ role: "assistant", content: [{ type: "text", text: "HEARTBEAT_OK" }], timestamp: 1 },
54-
{ role: "assistant", content: [{ type: "text", text: "HEARTBEAT_OK" }], timestamp: 2 },
55-
{ role: "assistant", content: [{ type: "text", text: "HEARTBEAT_OK" }], timestamp: 3 },
53+
{ role: "assistant", content: [{ type: "text", text: "Same update" }], timestamp: 1 },
54+
{ role: "assistant", content: [{ type: "text", text: "Same update" }], timestamp: 2 },
55+
{ role: "assistant", content: [{ type: "text", text: "Same update" }], timestamp: 3 },
5656
],
5757
});
5858

@@ -61,6 +61,96 @@ describe("buildChatItems", () => {
6161
expect(groups[0].messages[0]).toMatchObject({ duplicateCount: 3 });
6262
});
6363

64+
it("suppresses assistant HEARTBEAT_OK acknowledgements before rendering history", () => {
65+
const groups = messageGroups({
66+
messages: [
67+
{ role: "assistant", content: [{ type: "text", text: "HEARTBEAT_OK" }], timestamp: 1 },
68+
{ role: "assistant", content: "HEARTBEAT_OK", timestamp: 2 },
69+
{ role: "user", content: [{ type: "text", text: "HEARTBEAT_OK" }], timestamp: 3 },
70+
{ role: "assistant", content: [{ type: "text", text: "Visible reply" }], timestamp: 4 },
71+
],
72+
});
73+
74+
expect(groups).toHaveLength(2);
75+
expect(groups[0].role).toBe("user");
76+
expect(groups[1].role).toBe("assistant");
77+
expect(groups[1].messages[0].message).toMatchObject({
78+
content: [{ type: "text", text: "Visible reply" }],
79+
});
80+
});
81+
82+
it("suppresses assistant HEARTBEAT_OK acknowledgements that carry hidden thinking blocks", () => {
83+
const groups = messageGroups({
84+
messages: [
85+
{
86+
role: "assistant",
87+
content: [
88+
{ type: "thinking", thinking: "Checking scheduled work." },
89+
{
90+
type: "text",
91+
text: "HEARTBEAT_OK",
92+
textSignature: JSON.stringify({ v: 1, phase: "final_answer" }),
93+
},
94+
],
95+
timestamp: 1,
96+
},
97+
{
98+
role: "assistant",
99+
content: [
100+
{ id: "rs_1", type: "reasoning" },
101+
{ type: "text", text: "HEARTBEAT_OK" },
102+
],
103+
timestamp: 2,
104+
},
105+
{
106+
role: "assistant",
107+
content: [
108+
{ type: "thinking", thinking: "Useful hidden reasoning." },
109+
{ type: "text", text: "Visible reply" },
110+
],
111+
timestamp: 3,
112+
},
113+
],
114+
});
115+
116+
expect(groups).toHaveLength(1);
117+
expect(groups[0].messages).toHaveLength(1);
118+
expect(groups[0].messages[0].message).toMatchObject({
119+
content: [
120+
{ type: "thinking", thinking: "Useful hidden reasoning." },
121+
{ type: "text", text: "Visible reply" },
122+
],
123+
});
124+
});
125+
126+
it("keeps HEARTBEAT_OK turns that carry visible non-text content", () => {
127+
const canvasBlock = createAssistantCanvasBlock({ suffix: "heartbeat_visible_content" });
128+
const groups = messageGroups({
129+
messages: [
130+
{
131+
role: "assistant",
132+
content: [{ type: "text", text: "HEARTBEAT_OK" }, canvasBlock],
133+
timestamp: 1,
134+
},
135+
],
136+
});
137+
138+
expect(groups).toHaveLength(1);
139+
expect(groups[0].messages).toHaveLength(1);
140+
expect(firstMessageContent(groups[0]).some((block) => isCanvasBlock(block))).toBe(true);
141+
});
142+
143+
it("suppresses active HEARTBEAT_OK streams before rendering", () => {
144+
const items = buildChatItems(
145+
createProps({
146+
stream: "HEARTBEAT_OK",
147+
streamStartedAt: 1,
148+
}),
149+
);
150+
151+
expect(items).toEqual([]);
152+
});
153+
64154
it("does not collapse duplicate text messages separated by another message", () => {
65155
const groups = messageGroups({
66156
messages: [

ui/src/ui/chat/build-chat-items.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import type { ChatItem, MessageGroup, ToolCard } from "../types/chat-types.ts";
2+
import {
3+
isAssistantHeartbeatAckForDisplay,
4+
stripHeartbeatTokenForDisplay,
5+
} from "./heartbeat-display.ts";
26
import { extractTextCached } from "./message-extract.ts";
37
import { normalizeMessage } from "./message-normalizer.ts";
48
import { normalizeRoleForGrouping } from "./role-normalizer.ts";
@@ -248,7 +252,9 @@ function collapseSequentialDuplicateMessages(items: ChatItem[]): ChatItem[] {
248252

249253
export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | MessageGroup> {
250254
const items: ChatItem[] = [];
251-
const history = Array.isArray(props.messages) ? props.messages : [];
255+
const history = (Array.isArray(props.messages) ? props.messages : []).filter(
256+
(message) => !isAssistantHeartbeatAckForDisplay(message),
257+
);
252258
const tools = Array.isArray(props.toolMessages) ? props.toolMessages : [];
253259
const historyStart = Math.max(0, history.length - CHAT_HISTORY_RENDER_LIMIT);
254260
if (historyStart > 0) {
@@ -349,12 +355,14 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
349355
if (props.stream !== null) {
350356
const key = `stream:${props.sessionKey}:${props.streamStartedAt ?? "live"}`;
351357
if (props.stream.trim().length > 0) {
352-
items.push({
353-
kind: "stream",
354-
key,
355-
text: props.stream,
356-
startedAt: props.streamStartedAt ?? Date.now(),
357-
});
358+
if (!stripHeartbeatTokenForDisplay(props.stream).shouldSkip) {
359+
items.push({
360+
kind: "stream",
361+
key,
362+
text: props.stream,
363+
startedAt: props.streamStartedAt ?? Date.now(),
364+
});
365+
}
358366
} else {
359367
items.push({ kind: "reading-indicator", key });
360368
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
2+
3+
const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
4+
const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
5+
6+
function escapeRegExp(value: string): string {
7+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8+
}
9+
10+
export function stripHeartbeatTokenForDisplay(
11+
raw: string,
12+
maxAckChars = DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
13+
): { shouldSkip: boolean } {
14+
let text = raw.trim();
15+
if (!text) {
16+
return { shouldSkip: true };
17+
}
18+
const strippedMarkup = text
19+
.replace(/<[^>]*>/g, " ")
20+
.replace(/&nbsp;/gi, " ")
21+
.replace(/^[*`~_]+/, "")
22+
.replace(/[*`~_]+$/, "");
23+
if (!text.includes(HEARTBEAT_TOKEN) && !strippedMarkup.includes(HEARTBEAT_TOKEN)) {
24+
return { shouldSkip: false };
25+
}
26+
27+
const tokenAtEnd = new RegExp(`${escapeRegExp(HEARTBEAT_TOKEN)}[^\\w]{0,4}$`);
28+
let changed = true;
29+
let didStrip = false;
30+
text = strippedMarkup.trim();
31+
while (changed) {
32+
changed = false;
33+
const next = text.trim();
34+
if (next.startsWith(HEARTBEAT_TOKEN)) {
35+
text = next.slice(HEARTBEAT_TOKEN.length).trimStart();
36+
didStrip = true;
37+
changed = true;
38+
continue;
39+
}
40+
if (tokenAtEnd.test(next)) {
41+
const index = next.lastIndexOf(HEARTBEAT_TOKEN);
42+
const before = next.slice(0, index).trimEnd();
43+
const after = next.slice(index + HEARTBEAT_TOKEN.length).trimStart();
44+
text = before ? `${before}${after}`.trimEnd() : "";
45+
didStrip = true;
46+
changed = true;
47+
}
48+
}
49+
50+
if (!didStrip) {
51+
return { shouldSkip: false };
52+
}
53+
return { shouldSkip: !text || text.length <= maxAckChars };
54+
}
55+
56+
function isHiddenDisplayBlockType(type: unknown): boolean {
57+
return type === "thinking" || type === "reasoning";
58+
}
59+
60+
function resolveDisplayContent(content: unknown): {
61+
text: string;
62+
hasVisibleNonTextContent: boolean;
63+
} {
64+
if (typeof content === "string") {
65+
return { text: content, hasVisibleNonTextContent: false };
66+
}
67+
if (!Array.isArray(content)) {
68+
return { text: "", hasVisibleNonTextContent: content != null };
69+
}
70+
let hasVisibleNonTextContent = false;
71+
const text = content
72+
.filter((block): block is { type: "text"; text: string } => {
73+
if (!block || typeof block !== "object" || !("type" in block)) {
74+
hasVisibleNonTextContent = true;
75+
return false;
76+
}
77+
if ((block as { type?: unknown }).type !== "text") {
78+
if (!isHiddenDisplayBlockType((block as { type?: unknown }).type)) {
79+
hasVisibleNonTextContent = true;
80+
}
81+
return false;
82+
}
83+
if (typeof (block as { text?: unknown }).text !== "string") {
84+
hasVisibleNonTextContent = true;
85+
return false;
86+
}
87+
return true;
88+
})
89+
.map((block) => block.text)
90+
.join("");
91+
return { text, hasVisibleNonTextContent };
92+
}
93+
94+
export function isAssistantHeartbeatAckForDisplay(message: unknown): boolean {
95+
if (!message || typeof message !== "object") {
96+
return false;
97+
}
98+
const entry = message as Record<string, unknown>;
99+
const role = normalizeLowercaseStringOrEmpty(entry.role);
100+
if (role !== "assistant") {
101+
return false;
102+
}
103+
104+
const content =
105+
typeof entry.content === "string" || Array.isArray(entry.content) ? entry.content : entry.text;
106+
const { text, hasVisibleNonTextContent } = resolveDisplayContent(content);
107+
if (hasVisibleNonTextContent) {
108+
return false;
109+
}
110+
return stripHeartbeatTokenForDisplay(text).shouldSkip;
111+
}

ui/src/ui/controllers/chat.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,17 @@ describe("handleChatEvent", () => {
223223
expect(state.chatMessages).toEqual([]);
224224
});
225225

226+
it("drops HEARTBEAT_OK final payload from another run without clearing active stream", () => {
227+
const state = createActiveStreamingState();
228+
const payload = createOtherRunSilentFinalPayload("HEARTBEAT_OK");
229+
230+
expect(handleChatEvent(state, payload)).toBe("final");
231+
expect(state.chatRunId).toBe("run-user");
232+
expect(state.chatStream).toBe("Working...");
233+
expect(state.chatStreamStartedAt).toBe(123);
234+
expect(state.chatMessages).toEqual([]);
235+
});
236+
226237
it.each(["no_reply", "ANNOUNCE_SKIP", "REPLY_SKIP"])(
227238
"keeps plain-text %s final payload from another run without clearing active stream",
228239
(text) => {
@@ -237,6 +248,23 @@ describe("handleChatEvent", () => {
237248
},
238249
);
239250

251+
it("ignores HEARTBEAT_OK delta updates", () => {
252+
const state = createState({
253+
sessionKey: "main",
254+
chatRunId: "run-1",
255+
chatStream: "Previous visible text",
256+
});
257+
const payload: ChatEventPayload = {
258+
runId: "run-1",
259+
sessionKey: "main",
260+
state: "delta",
261+
message: { role: "assistant", content: [{ type: "text", text: "HEARTBEAT_OK" }] },
262+
};
263+
264+
expect(handleChatEvent(state, payload)).toBe("delta");
265+
expect(state.chatStream).toBe("Previous visible text");
266+
});
267+
240268
it("replaces the stream when a delta snapshot gets shorter", () => {
241269
const state = createState({
242270
sessionKey: "main",

0 commit comments

Comments
 (0)