Skip to content

Commit 3f63ba8

Browse files
committed
fix(webchat): hide heartbeat history artifacts
1 parent a2a49b4 commit 3f63ba8

8 files changed

Lines changed: 346 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai
6969

7070
### Fixes
7171

72+
- Control UI/WebChat: hide heartbeat prompts, `HEARTBEAT_OK` acknowledgments, and internal-only runtime context turns from visible chat history while leaving the underlying transcript intact. Fixes #71381. Thanks @gerald1950ggg-ai.
7273
- Talk/TTS: resolve configured extension speech providers from the active runtime registry before provider-list discovery, so Talk mode no longer rejects valid plugin speech providers as unsupported.
7374
- Sessions/subagents: stop stale ended runs and old store-only child reverse links from reappearing in `childSessions`, while keeping live descendants and recently-ended children visible. Fixes #57920.
7475
- Subagents: stop stale unended runs from counting as active or pending forever, while preserving restart-aborted recovery for recoverable child sessions. Fixes #71252. Thanks @hclsys.

docs/gateway/heartbeat.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,9 @@ Use `accountId` to target a specific account on multi-account channels like Tele
265265
send chat output to, and it is disabled by `typingMode: "never"`.
266266
- Heartbeat-only replies do **not** keep the session alive; the last `updatedAt`
267267
is restored so idle expiry behaves normally.
268+
- Control UI and WebChat history hide heartbeat prompts and OK-only
269+
acknowledgments. The underlying session transcript can still contain those
270+
turns for audit/replay.
268271
- Detached [background tasks](/automation/tasks) can enqueue a system event and wake heartbeat when the main session should notice something quickly. That wake does not make the heartbeat run a background task.
269272

270273
## Visibility controls

src/gateway/session-history-state.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, test, vi } from "vitest";
2+
import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js";
23
import { buildSessionHistorySnapshot, SessionHistorySseState } from "./session-history-state.js";
34
import * as sessionUtils from "./session-utils.js";
45

@@ -107,4 +108,122 @@ describe("SessionHistorySseState", () => {
107108
).content?.[0]?.text,
108109
).toBe("visible ask");
109110
});
111+
112+
test("drops internal-only user messages after envelope stripping", () => {
113+
const snapshot = buildSessionHistorySnapshot({
114+
rawMessages: [
115+
{
116+
role: "user",
117+
content: [
118+
{
119+
type: "text",
120+
text: [
121+
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
122+
"subagent completion payload",
123+
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
124+
].join("\n"),
125+
},
126+
],
127+
__openclaw: { seq: 1 },
128+
},
129+
{
130+
role: "assistant",
131+
content: [{ type: "text", text: "visible answer" }],
132+
__openclaw: { seq: 2 },
133+
},
134+
],
135+
});
136+
137+
expect(snapshot.history.messages).toEqual([
138+
{
139+
role: "assistant",
140+
content: [{ type: "text", text: "visible answer" }],
141+
__openclaw: { seq: 2 },
142+
},
143+
]);
144+
});
145+
146+
test("hides heartbeat prompt and ok acknowledgements from visible history", () => {
147+
const snapshot = buildSessionHistorySnapshot({
148+
rawMessages: [
149+
{
150+
role: "user",
151+
content: `${HEARTBEAT_PROMPT}\nWhen reading HEARTBEAT.md, use workspace file /tmp/HEARTBEAT.md (exact case). Do not read docs/heartbeat.md.`,
152+
__openclaw: { seq: 1 },
153+
},
154+
{
155+
role: "assistant",
156+
content: [{ type: "text", text: "HEARTBEAT_OK" }],
157+
__openclaw: { seq: 2 },
158+
},
159+
{
160+
role: "user",
161+
content: HEARTBEAT_PROMPT,
162+
__openclaw: { seq: 3 },
163+
},
164+
{
165+
role: "assistant",
166+
content: [{ type: "text", text: "Disk usage crossed 95 percent." }],
167+
__openclaw: { seq: 4 },
168+
},
169+
],
170+
});
171+
172+
expect(snapshot.history.messages).toEqual([
173+
{
174+
role: "assistant",
175+
content: [{ type: "text", text: "Disk usage crossed 95 percent." }],
176+
__openclaw: { seq: 4 },
177+
},
178+
]);
179+
expect(snapshot.rawTranscriptSeq).toBe(4);
180+
});
181+
182+
test("does not append heartbeat or internal-only SSE messages", () => {
183+
const state = SessionHistorySseState.fromRawSnapshot({
184+
target: { sessionId: "sess-main" },
185+
rawMessages: [
186+
{
187+
role: "assistant",
188+
content: [{ type: "text", text: "already visible" }],
189+
__openclaw: { seq: 1 },
190+
},
191+
],
192+
});
193+
194+
expect(
195+
state.appendInlineMessage({
196+
message: {
197+
role: "user",
198+
content: HEARTBEAT_PROMPT,
199+
},
200+
}),
201+
).toBeNull();
202+
expect(
203+
state.appendInlineMessage({
204+
message: {
205+
role: "assistant",
206+
content: [{ type: "text", text: "HEARTBEAT_OK" }],
207+
},
208+
}),
209+
).toBeNull();
210+
expect(
211+
state.appendInlineMessage({
212+
message: {
213+
role: "user",
214+
content: [
215+
{
216+
type: "text",
217+
text: [
218+
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
219+
"runtime details",
220+
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
221+
].join("\n"),
222+
},
223+
],
224+
},
225+
}),
226+
).toBeNull();
227+
expect(state.snapshot().messages).toHaveLength(1);
228+
});
110229
});

src/gateway/session-history-state.ts

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { isHeartbeatOkResponse, isHeartbeatUserMessage } from "../auto-reply/heartbeat-filter.js";
2+
import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js";
13
import { stripEnvelopeFromMessages } from "./chat-sanitize.js";
24
import {
35
DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS,
@@ -31,6 +33,102 @@ type SessionHistoryTranscriptTarget = {
3133
sessionFile?: string;
3234
};
3335

36+
type RoleContentMessage = {
37+
role: string;
38+
content?: unknown;
39+
};
40+
41+
function asRoleContentMessage(message: SessionHistoryMessage): RoleContentMessage | null {
42+
const role = typeof message.role === "string" ? message.role.toLowerCase() : "";
43+
if (!role) {
44+
return null;
45+
}
46+
return {
47+
role,
48+
...(message.content !== undefined
49+
? { content: message.content }
50+
: message.text !== undefined
51+
? { content: message.text }
52+
: {}),
53+
};
54+
}
55+
56+
function isEmptyTextOnlyContent(content: unknown): boolean {
57+
if (typeof content === "string") {
58+
return content.trim().length === 0;
59+
}
60+
if (!Array.isArray(content)) {
61+
return false;
62+
}
63+
if (content.length === 0) {
64+
return true;
65+
}
66+
let sawText = false;
67+
for (const block of content) {
68+
if (!block || typeof block !== "object") {
69+
return false;
70+
}
71+
const entry = block as { type?: unknown; text?: unknown };
72+
if (entry.type !== "text") {
73+
return false;
74+
}
75+
sawText = true;
76+
if (typeof entry.text !== "string" || entry.text.trim().length > 0) {
77+
return false;
78+
}
79+
}
80+
return sawText;
81+
}
82+
83+
function shouldHideSanitizedHistoryMessage(message: SessionHistoryMessage): boolean {
84+
const roleContent = asRoleContentMessage(message);
85+
if (!roleContent) {
86+
return false;
87+
}
88+
if (roleContent.role === "user" && isEmptyTextOnlyContent(message.content ?? message.text)) {
89+
return true;
90+
}
91+
if (isHeartbeatUserMessage(roleContent, HEARTBEAT_PROMPT)) {
92+
return true;
93+
}
94+
return isHeartbeatOkResponse(roleContent);
95+
}
96+
97+
function filterVisibleSessionHistoryMessages(
98+
messages: SessionHistoryMessage[],
99+
): SessionHistoryMessage[] {
100+
if (messages.length === 0) {
101+
return messages;
102+
}
103+
let changed = false;
104+
const visible: SessionHistoryMessage[] = [];
105+
for (let i = 0; i < messages.length; i++) {
106+
const current = messages[i];
107+
if (!current) {
108+
continue;
109+
}
110+
const currentRoleContent = asRoleContentMessage(current);
111+
const next = messages[i + 1];
112+
const nextRoleContent = next ? asRoleContentMessage(next) : null;
113+
if (
114+
currentRoleContent &&
115+
nextRoleContent &&
116+
isHeartbeatUserMessage(currentRoleContent, HEARTBEAT_PROMPT) &&
117+
isHeartbeatOkResponse(nextRoleContent)
118+
) {
119+
changed = true;
120+
i++;
121+
continue;
122+
}
123+
if (shouldHideSanitizedHistoryMessage(current)) {
124+
changed = true;
125+
continue;
126+
}
127+
visible.push(current);
128+
}
129+
return changed ? visible : messages;
130+
}
131+
34132
function resolveCursorSeq(cursor: string | undefined): number | undefined {
35133
if (!cursor) {
36134
return undefined;
@@ -100,16 +198,15 @@ export function buildSessionHistorySnapshot(params: {
100198
limit?: number;
101199
cursor?: string;
102200
}): SessionHistorySnapshot {
103-
const history = paginateSessionMessages(
201+
const visibleMessages = filterVisibleSessionHistoryMessages(
104202
toSessionHistoryMessages(
105203
sanitizeChatHistoryMessages(
106204
stripEnvelopeFromMessages(params.rawMessages),
107205
params.maxChars ?? DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS,
108206
),
109207
),
110-
params.limit,
111-
params.cursor,
112208
);
209+
const history = paginateSessionMessages(visibleMessages, params.limit, params.cursor);
113210
const rawHistoryMessages = toSessionHistoryMessages(params.rawMessages);
114211
return {
115212
history,
@@ -190,6 +287,9 @@ export class SessionHistorySseState {
190287
if (!sanitizedMessage) {
191288
return null;
192289
}
290+
if (shouldHideSanitizedHistoryMessage(sanitizedMessage)) {
291+
return null;
292+
}
193293
const nextMessages = [...this.sentHistory.messages, sanitizedMessage];
194294
this.sentHistory = buildPaginatedSessionHistory({
195295
messages: nextMessages,

ui/src/ui/chat/message-extract.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,27 @@ describe("extractTextCached", () => {
7777
expect(extractText(message)).toBeNull();
7878
expect(extractTextCached(message)).toBeNull();
7979
});
80+
81+
it("strips internal runtime context blocks from user text", () => {
82+
const message = {
83+
role: "user",
84+
content: [
85+
{
86+
type: "text",
87+
text: [
88+
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
89+
"internal subagent payload",
90+
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
91+
"",
92+
"visible ask",
93+
].join("\n"),
94+
},
95+
],
96+
};
97+
98+
expect(extractText(message)).toBe("visible ask");
99+
expect(extractTextCached(message)).toBe("visible ask");
100+
});
80101
});
81102

82103
describe("extractThinkingCached", () => {

ui/src/ui/chat/message-extract.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { stripInternalRuntimeContext } from "../../../../src/agents/internal-runtime-context.js";
12
import { stripInboundMetadata } from "../../../../src/auto-reply/reply/strip-inbound-meta.js";
23
import { stripEnvelope } from "../../../../src/shared/chat-envelope.js";
34
import { extractAssistantVisibleText as extractSharedAssistantVisibleText } from "../../../../src/shared/chat-message-content.js";
@@ -9,12 +10,13 @@ const thinkingCache = new WeakMap<object, string | null>();
910

1011
function processMessageText(text: string, role: string): string {
1112
const shouldStripInboundMetadata = normalizeLowercaseStringOrEmpty(role) === "user";
13+
const withoutInternalContext = stripInternalRuntimeContext(text);
1214
if (role === "assistant") {
13-
return stripThinkingTags(text);
15+
return stripThinkingTags(withoutInternalContext);
1416
}
1517
return shouldStripInboundMetadata
16-
? stripInboundMetadata(stripEnvelope(text))
17-
: stripEnvelope(text);
18+
? stripInboundMetadata(stripEnvelope(withoutInternalContext))
19+
: stripEnvelope(withoutInternalContext);
1820
}
1921

2022
export function extractText(message: unknown): string | null {

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,39 @@ describe("loadChatHistory", () => {
753753
expect(state.lastError).toBeNull();
754754
});
755755

756+
it("filters heartbeat acknowledgements and internal-only user messages", async () => {
757+
const request = vi.fn().mockResolvedValue({
758+
messages: [
759+
{ role: "assistant", content: [{ type: "text", text: "HEARTBEAT_OK" }] },
760+
{
761+
role: "user",
762+
content: [
763+
{
764+
type: "text",
765+
text: [
766+
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
767+
"subagent completion payload",
768+
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
769+
].join("\n"),
770+
},
771+
],
772+
},
773+
{ role: "assistant", content: [{ type: "text", text: "visible answer" }] },
774+
],
775+
thinkingLevel: "low",
776+
});
777+
const state = createState({
778+
connected: true,
779+
client: { request } as unknown as ChatState["client"],
780+
});
781+
782+
await loadChatHistory(state);
783+
784+
expect(state.chatMessages).toEqual([
785+
{ role: "assistant", content: [{ type: "text", text: "visible answer" }] },
786+
]);
787+
});
788+
756789
it("shows a targeted message when chat history is unauthorized", async () => {
757790
const request = vi.fn().mockRejectedValue(
758791
new GatewayRequestError({

0 commit comments

Comments
 (0)