fix(webchat): hide heartbeat history artifacts

This commit is contained in:
Peter Steinberger
2026-04-25 06:10:49 +01:00
parent a2a49b430c
commit 3f63ba8fd8
8 changed files with 346 additions and 7 deletions

View File

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

View File

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

View File

@@ -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: [
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
"subagent completion payload",
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
].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: [
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
"runtime details",
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
].join("\n"),
},
],
},
}),
).toBeNull();
expect(state.snapshot().messages).toHaveLength(1);
});
});

View File

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

View File

@@ -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: [
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
"internal subagent payload",
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
"",
"visible ask",
].join("\n"),
},
],
};
expect(extractText(message)).toBe("visible ask");
expect(extractTextCached(message)).toBe("visible ask");
});
});
describe("extractThinkingCached", () => {

View File

@@ -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<object, string | null>();
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 {

View File

@@ -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: [
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
"subagent completion payload",
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
].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({

View File

@@ -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<string, unknown>;
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<string, unknown>;
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 {