From 3f63ba8fd808f46566aabbca194fa54c2d6b4871 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:10:49 +0100 Subject: [PATCH] fix(webchat): hide heartbeat history artifacts --- CHANGELOG.md | 1 + docs/gateway/heartbeat.md | 3 + src/gateway/session-history-state.test.ts | 119 ++++++++++++++++++++++ src/gateway/session-history-state.ts | 106 ++++++++++++++++++- ui/src/ui/chat/message-extract.test.ts | 21 ++++ ui/src/ui/chat/message-extract.ts | 8 +- ui/src/ui/controllers/chat.test.ts | 33 ++++++ ui/src/ui/controllers/chat.ts | 62 ++++++++++- 8 files changed, 346 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d8c16fc50b..3a89bfea1c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- 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. - 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. - 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. - 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. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index 8bc9f63cfcd..008c44782ad 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -265,6 +265,9 @@ Use `accountId` to target a specific account on multi-account channels like Tele send chat output to, and it is disabled by `typingMode: "never"`. - Heartbeat-only replies do **not** keep the session alive; the last `updatedAt` is restored so idle expiry behaves normally. +- Control UI and WebChat history hide heartbeat prompts and OK-only + acknowledgments. The underlying session transcript can still contain those + turns for audit/replay. - 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. ## Visibility controls diff --git a/src/gateway/session-history-state.test.ts b/src/gateway/session-history-state.test.ts index a7a847a148f..95d827f727c 100644 --- a/src/gateway/session-history-state.test.ts +++ b/src/gateway/session-history-state.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test, vi } from "vitest"; +import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js"; import { buildSessionHistorySnapshot, SessionHistorySseState } from "./session-history-state.js"; import * as sessionUtils from "./session-utils.js"; @@ -107,4 +108,122 @@ describe("SessionHistorySseState", () => { ).content?.[0]?.text, ).toBe("visible ask"); }); + + test("drops internal-only user messages after envelope stripping", () => { + const snapshot = buildSessionHistorySnapshot({ + rawMessages: [ + { + role: "user", + content: [ + { + type: "text", + text: [ + "<<>>", + "subagent completion payload", + "<<>>", + ].join("\n"), + }, + ], + __openclaw: { seq: 1 }, + }, + { + role: "assistant", + content: [{ type: "text", text: "visible answer" }], + __openclaw: { seq: 2 }, + }, + ], + }); + + expect(snapshot.history.messages).toEqual([ + { + role: "assistant", + content: [{ type: "text", text: "visible answer" }], + __openclaw: { seq: 2 }, + }, + ]); + }); + + test("hides heartbeat prompt and ok acknowledgements from visible history", () => { + const snapshot = buildSessionHistorySnapshot({ + rawMessages: [ + { + role: "user", + content: `${HEARTBEAT_PROMPT}\nWhen reading HEARTBEAT.md, use workspace file /tmp/HEARTBEAT.md (exact case). Do not read docs/heartbeat.md.`, + __openclaw: { seq: 1 }, + }, + { + role: "assistant", + content: [{ type: "text", text: "HEARTBEAT_OK" }], + __openclaw: { seq: 2 }, + }, + { + role: "user", + content: HEARTBEAT_PROMPT, + __openclaw: { seq: 3 }, + }, + { + role: "assistant", + content: [{ type: "text", text: "Disk usage crossed 95 percent." }], + __openclaw: { seq: 4 }, + }, + ], + }); + + expect(snapshot.history.messages).toEqual([ + { + role: "assistant", + content: [{ type: "text", text: "Disk usage crossed 95 percent." }], + __openclaw: { seq: 4 }, + }, + ]); + expect(snapshot.rawTranscriptSeq).toBe(4); + }); + + test("does not append heartbeat or internal-only SSE messages", () => { + const state = SessionHistorySseState.fromRawSnapshot({ + target: { sessionId: "sess-main" }, + rawMessages: [ + { + role: "assistant", + content: [{ type: "text", text: "already visible" }], + __openclaw: { seq: 1 }, + }, + ], + }); + + expect( + state.appendInlineMessage({ + message: { + role: "user", + content: HEARTBEAT_PROMPT, + }, + }), + ).toBeNull(); + expect( + state.appendInlineMessage({ + message: { + role: "assistant", + content: [{ type: "text", text: "HEARTBEAT_OK" }], + }, + }), + ).toBeNull(); + expect( + state.appendInlineMessage({ + message: { + role: "user", + content: [ + { + type: "text", + text: [ + "<<>>", + "runtime details", + "<<>>", + ].join("\n"), + }, + ], + }, + }), + ).toBeNull(); + expect(state.snapshot().messages).toHaveLength(1); + }); }); diff --git a/src/gateway/session-history-state.ts b/src/gateway/session-history-state.ts index 8c076afb5ff..c66fa4ab75c 100644 --- a/src/gateway/session-history-state.ts +++ b/src/gateway/session-history-state.ts @@ -1,3 +1,5 @@ +import { isHeartbeatOkResponse, isHeartbeatUserMessage } from "../auto-reply/heartbeat-filter.js"; +import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js"; import { stripEnvelopeFromMessages } from "./chat-sanitize.js"; import { DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS, @@ -31,6 +33,102 @@ type SessionHistoryTranscriptTarget = { sessionFile?: string; }; +type RoleContentMessage = { + role: string; + content?: unknown; +}; + +function asRoleContentMessage(message: SessionHistoryMessage): RoleContentMessage | null { + const role = typeof message.role === "string" ? message.role.toLowerCase() : ""; + if (!role) { + return null; + } + return { + role, + ...(message.content !== undefined + ? { content: message.content } + : message.text !== undefined + ? { content: message.text } + : {}), + }; +} + +function isEmptyTextOnlyContent(content: unknown): boolean { + if (typeof content === "string") { + return content.trim().length === 0; + } + if (!Array.isArray(content)) { + return false; + } + if (content.length === 0) { + return true; + } + let sawText = false; + for (const block of content) { + if (!block || typeof block !== "object") { + return false; + } + const entry = block as { type?: unknown; text?: unknown }; + if (entry.type !== "text") { + return false; + } + sawText = true; + if (typeof entry.text !== "string" || entry.text.trim().length > 0) { + return false; + } + } + return sawText; +} + +function shouldHideSanitizedHistoryMessage(message: SessionHistoryMessage): boolean { + const roleContent = asRoleContentMessage(message); + if (!roleContent) { + return false; + } + if (roleContent.role === "user" && isEmptyTextOnlyContent(message.content ?? message.text)) { + return true; + } + if (isHeartbeatUserMessage(roleContent, HEARTBEAT_PROMPT)) { + return true; + } + return isHeartbeatOkResponse(roleContent); +} + +function filterVisibleSessionHistoryMessages( + messages: SessionHistoryMessage[], +): SessionHistoryMessage[] { + if (messages.length === 0) { + return messages; + } + let changed = false; + const visible: SessionHistoryMessage[] = []; + for (let i = 0; i < messages.length; i++) { + const current = messages[i]; + if (!current) { + continue; + } + const currentRoleContent = asRoleContentMessage(current); + const next = messages[i + 1]; + const nextRoleContent = next ? asRoleContentMessage(next) : null; + if ( + currentRoleContent && + nextRoleContent && + isHeartbeatUserMessage(currentRoleContent, HEARTBEAT_PROMPT) && + isHeartbeatOkResponse(nextRoleContent) + ) { + changed = true; + i++; + continue; + } + if (shouldHideSanitizedHistoryMessage(current)) { + changed = true; + continue; + } + visible.push(current); + } + return changed ? visible : messages; +} + function resolveCursorSeq(cursor: string | undefined): number | undefined { if (!cursor) { return undefined; @@ -100,16 +198,15 @@ export function buildSessionHistorySnapshot(params: { limit?: number; cursor?: string; }): SessionHistorySnapshot { - const history = paginateSessionMessages( + const visibleMessages = filterVisibleSessionHistoryMessages( toSessionHistoryMessages( sanitizeChatHistoryMessages( stripEnvelopeFromMessages(params.rawMessages), params.maxChars ?? DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS, ), ), - params.limit, - params.cursor, ); + const history = paginateSessionMessages(visibleMessages, params.limit, params.cursor); const rawHistoryMessages = toSessionHistoryMessages(params.rawMessages); return { history, @@ -190,6 +287,9 @@ export class SessionHistorySseState { if (!sanitizedMessage) { return null; } + if (shouldHideSanitizedHistoryMessage(sanitizedMessage)) { + return null; + } const nextMessages = [...this.sentHistory.messages, sanitizedMessage]; this.sentHistory = buildPaginatedSessionHistory({ messages: nextMessages, diff --git a/ui/src/ui/chat/message-extract.test.ts b/ui/src/ui/chat/message-extract.test.ts index 6455255f46c..bf97875d156 100644 --- a/ui/src/ui/chat/message-extract.test.ts +++ b/ui/src/ui/chat/message-extract.test.ts @@ -77,6 +77,27 @@ describe("extractTextCached", () => { expect(extractText(message)).toBeNull(); expect(extractTextCached(message)).toBeNull(); }); + + it("strips internal runtime context blocks from user text", () => { + const message = { + role: "user", + content: [ + { + type: "text", + text: [ + "<<>>", + "internal subagent payload", + "<<>>", + "", + "visible ask", + ].join("\n"), + }, + ], + }; + + expect(extractText(message)).toBe("visible ask"); + expect(extractTextCached(message)).toBe("visible ask"); + }); }); describe("extractThinkingCached", () => { diff --git a/ui/src/ui/chat/message-extract.ts b/ui/src/ui/chat/message-extract.ts index e743d8c305a..bfae47a98e6 100644 --- a/ui/src/ui/chat/message-extract.ts +++ b/ui/src/ui/chat/message-extract.ts @@ -1,3 +1,4 @@ +import { stripInternalRuntimeContext } from "../../../../src/agents/internal-runtime-context.js"; import { stripInboundMetadata } from "../../../../src/auto-reply/reply/strip-inbound-meta.js"; import { stripEnvelope } from "../../../../src/shared/chat-envelope.js"; import { extractAssistantVisibleText as extractSharedAssistantVisibleText } from "../../../../src/shared/chat-message-content.js"; @@ -9,12 +10,13 @@ const thinkingCache = new WeakMap(); function processMessageText(text: string, role: string): string { const shouldStripInboundMetadata = normalizeLowercaseStringOrEmpty(role) === "user"; + const withoutInternalContext = stripInternalRuntimeContext(text); if (role === "assistant") { - return stripThinkingTags(text); + return stripThinkingTags(withoutInternalContext); } return shouldStripInboundMetadata - ? stripInboundMetadata(stripEnvelope(text)) - : stripEnvelope(text); + ? stripInboundMetadata(stripEnvelope(withoutInternalContext)) + : stripEnvelope(withoutInternalContext); } export function extractText(message: unknown): string | null { diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index f761f0813b1..c74a4689814 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -753,6 +753,39 @@ describe("loadChatHistory", () => { expect(state.lastError).toBeNull(); }); + it("filters heartbeat acknowledgements and internal-only user messages", async () => { + const request = vi.fn().mockResolvedValue({ + messages: [ + { role: "assistant", content: [{ type: "text", text: "HEARTBEAT_OK" }] }, + { + role: "user", + content: [ + { + type: "text", + text: [ + "<<>>", + "subagent completion payload", + "<<>>", + ].join("\n"), + }, + ], + }, + { role: "assistant", content: [{ type: "text", text: "visible answer" }] }, + ], + thinkingLevel: "low", + }); + const state = createState({ + connected: true, + client: { request } as unknown as ChatState["client"], + }); + + await loadChatHistory(state); + + expect(state.chatMessages).toEqual([ + { role: "assistant", content: [{ type: "text", text: "visible answer" }] }, + ]); + }); + it("shows a targeted message when chat history is unauthorized", async () => { const request = vi.fn().mockRejectedValue( new GatewayRequestError({ diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 8b48d848109..ffac5f18502 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -1,3 +1,4 @@ +import { isHeartbeatOkResponse } from "../../../../src/auto-reply/heartbeat-filter.js"; import { resetToolStream } from "../app-tool-stream.ts"; import { extractText } from "../chat/message-extract.ts"; import { formatConnectError } from "../connect-error.ts"; @@ -71,8 +72,67 @@ function isSyntheticTranscriptRepairToolResult(message: unknown): boolean { return typeof text === "string" && text.trim() === SYNTHETIC_TRANSCRIPT_REPAIR_RESULT; } +function isTextOnlyContent(content: unknown): boolean { + if (typeof content === "string") { + return true; + } + if (!Array.isArray(content)) { + return false; + } + if (content.length === 0) { + return true; + } + let sawText = false; + for (const block of content) { + if (!block || typeof block !== "object") { + return false; + } + const entry = block as { type?: unknown; text?: unknown }; + if (entry.type !== "text") { + return false; + } + sawText = true; + if (typeof entry.text !== "string") { + return false; + } + } + return sawText; +} + +function isEmptyUserTextOnlyMessage(message: unknown): boolean { + if (!message || typeof message !== "object") { + return false; + } + const entry = message as Record; + if (normalizeLowercaseStringOrEmpty(entry.role) !== "user") { + return false; + } + if (!isTextOnlyContent(entry.content ?? entry.text)) { + return false; + } + return (extractText(message)?.trim() ?? "") === ""; +} + +function isAssistantHeartbeatAck(message: unknown): boolean { + if (!message || typeof message !== "object") { + return false; + } + const entry = message as Record; + const role = normalizeLowercaseStringOrEmpty(entry.role); + if (role !== "assistant") { + return false; + } + const content = entry.content ?? entry.text; + return isHeartbeatOkResponse({ role, content }); +} + function shouldHideHistoryMessage(message: unknown): boolean { - return isAssistantSilentReply(message) || isSyntheticTranscriptRepairToolResult(message); + return ( + isAssistantSilentReply(message) || + isAssistantHeartbeatAck(message) || + isSyntheticTranscriptRepairToolResult(message) || + isEmptyUserTextOnlyMessage(message) + ); } function isRetryableStartupUnavailable(err: unknown, method: string): err is GatewayRequestError {