From caa86bb9ff07145d100662a067d98ff7f3737be2 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Wed, 13 May 2026 14:35:57 +1000 Subject: [PATCH 1/4] fix: hide openclaw heartbeat artifacts --- .../openclaw/sessions/[key]/preview/route.ts | 3 +- src/app/chat/page.tsx | 15 +++++-- src/lib/openclaw-heartbeat-artifacts.ts | 41 +++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 src/lib/openclaw-heartbeat-artifacts.ts diff --git a/src/app/api/openclaw/sessions/[key]/preview/route.ts b/src/app/api/openclaw/sessions/[key]/preview/route.ts index 84204e4..27ee350 100644 --- a/src/app/api/openclaw/sessions/[key]/preview/route.ts +++ b/src/app/api/openclaw/sessions/[key]/preview/route.ts @@ -2,6 +2,7 @@ import { NextRequest } from "next/server"; import { requireAuth } from "@/lib/require-auth"; import { getGatewayClient } from "@/lib/gateway-chat-pool"; import { ensureEventBridge } from "@/lib/init-event-bridge"; +import { isOpenClawHeartbeatArtifact } from "@/lib/openclaw-heartbeat-artifacts"; export const dynamic = "force-dynamic"; @@ -32,7 +33,7 @@ function normalizePreviewItems(preview: GatewaySessionPreview | null) { text: item.text ?? item.content ?? "", createdAt: item.createdAt ?? item.timestamp ?? (typeof item.ts === "string" ? item.ts : undefined), })) - .filter((item) => item.text); + .filter((item) => item.text && !isOpenClawHeartbeatArtifact({ role: item.role, content: item.text })); } /** diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 8b5b50d..84d6adc 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -38,6 +38,7 @@ import { recordVoiceCrashBreadcrumb, } from "@/lib/agent-mode-diagnostics"; import { DEFAULT_AGENT_VOICE_SETTINGS, normalizeAgentVoiceSettings, type AgentVoiceSettings } from "@/lib/tts-voices"; +import { isOpenClawHeartbeatArtifact } from "@/lib/openclaw-heartbeat-artifacts"; /** Append markers for parsed task references not already embedded. */ function injectTaskCardMarkers(content: string, refs: ReturnType): string { @@ -174,6 +175,14 @@ function displayContentFromGatewayPreview(content: string, sessionKey: string) { return isMessageThreadSessionKey(sessionKey) ? extractUserThreadReply(content) : ""; } +function isVisibleChatMessage(message: Pick) { + return ( + hasRenderableMessageContent(message) && + !isThreadContextEnvelope(message.content) && + !isOpenClawHeartbeatArtifact({ role: message.role, content: message.content }) + ); +} + function stableHash(input: string) { let hash = 5381; for (let i = 0; i < input.length; i += 1) { @@ -956,7 +965,7 @@ async function loadSessionPreviewIntoStore(sessionKey: string) { createdAt, metadata: stableId === messageId ? null : { threadParentId: stableId }, }; - }).filter((m) => m.content); + }).filter((m) => m.content && !isOpenClawHeartbeatArtifact({ role: m.role, content: m.content })); useChatStore.getState().loadSession( sessionKey.toLowerCase(), @@ -1568,7 +1577,7 @@ export default function ChatPage() { useEffect(() => { const messageIds = messages - .filter((message) => isUuid(message.id) && hasRenderableMessageContent(message) && !isThreadContextEnvelope(message.content)) + .filter((message) => isUuid(message.id) && isVisibleChatMessage(message)) .map((message) => message.id); void loadSavedMessages(messageIds); }, [messages, loadSavedMessages]); @@ -1829,7 +1838,7 @@ export default function ChatPage() { ); const visibleMessages = useMemo( () => uniqueMessagesById( - messages.filter((message) => hasRenderableMessageContent(message) && !isThreadContextEnvelope(message.content)) + messages.filter(isVisibleChatMessage) ), [messages] ); diff --git a/src/lib/openclaw-heartbeat-artifacts.ts b/src/lib/openclaw-heartbeat-artifacts.ts new file mode 100644 index 0000000..adebfb2 --- /dev/null +++ b/src/lib/openclaw-heartbeat-artifacts.ts @@ -0,0 +1,41 @@ +export type ChatArtifactRole = "user" | "assistant" | "system"; + +const DEFAULT_HEARTBEAT_PROMPT_PREFIX = "read heartbeat.md if it exists"; + +function normalizeContent(content: string) { + return content.trim().replace(/\r\n/g, "\n"); +} + +function firstMeaningfulLine(content: string) { + return normalizeContent(content) + .split("\n") + .map((line) => line.trim()) + .find(Boolean) ?? ""; +} + +export function isOpenClawHeartbeatArtifact(params: { + role?: ChatArtifactRole | string | null; + content?: string | null; +}) { + const role = params.role; + const content = normalizeContent(params.content ?? ""); + if (!content) return false; + + const compact = content.toLowerCase().replace(/\s+/g, " "); + + if (compact === "heartbeat_ok") return true; + + if (role === "user") { + return ( + compact === "[openclaw heartbeat poll]" || + (compact.startsWith(DEFAULT_HEARTBEAT_PROMPT_PREFIX) && compact.includes("heartbeat_ok")) + ); + } + + if (role !== "assistant") return false; + + if (compact === "call read") return true; + + const heading = firstMeaningfulLine(content).replace(/^#+\s*/, "").toLowerCase(); + return heading === "heartbeat.md"; +} From 358ebe163778d821a94937096c849c018cdc6cb0 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Wed, 13 May 2026 14:36:09 +1000 Subject: [PATCH 2/4] test: cover openclaw heartbeat artifact detection --- src/lib/openclaw-heartbeat-artifacts.test.ts | 45 ++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/lib/openclaw-heartbeat-artifacts.test.ts diff --git a/src/lib/openclaw-heartbeat-artifacts.test.ts b/src/lib/openclaw-heartbeat-artifacts.test.ts new file mode 100644 index 0000000..3134aa2 --- /dev/null +++ b/src/lib/openclaw-heartbeat-artifacts.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { isOpenClawHeartbeatArtifact } from "./openclaw-heartbeat-artifacts"; + +describe("isOpenClawHeartbeatArtifact", () => { + it("detects OpenClaw heartbeat poll prompts", () => { + expect(isOpenClawHeartbeatArtifact({ + role: "user", + content: "[OpenClaw heartbeat poll]", + })).toBe(true); + + expect(isOpenClawHeartbeatArtifact({ + role: "user", + content: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. If nothing needs attention, reply HEARTBEAT_OK.", + })).toBe(true); + }); + + it("detects leaked heartbeat tool artifacts", () => { + expect(isOpenClawHeartbeatArtifact({ + role: "assistant", + content: "call read", + })).toBe(true); + + expect(isOpenClawHeartbeatArtifact({ + role: "assistant", + content: "# HEARTBEAT.md\n\nActive workstream", + })).toBe(true); + + expect(isOpenClawHeartbeatArtifact({ + role: "assistant", + content: "HEARTBEAT_OK", + })).toBe(true); + }); + + it("keeps normal chat content visible", () => { + expect(isOpenClawHeartbeatArtifact({ + role: "user", + content: "Can you explain what HEARTBEAT.md does?", + })).toBe(false); + + expect(isOpenClawHeartbeatArtifact({ + role: "assistant", + content: "HEARTBEAT.md is the optional OpenClaw heartbeat checklist.", + })).toBe(false); + }); +}); From efabecb0cfefa5169f5b348e5a9f1277ccea4f97 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Wed, 13 May 2026 14:39:47 +1000 Subject: [PATCH 3/4] test: identify heartbeat acknowledgements --- src/lib/openclaw-heartbeat-artifacts.test.ts | 14 +++++++++++++- src/lib/openclaw-heartbeat-artifacts.ts | 7 +++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/lib/openclaw-heartbeat-artifacts.test.ts b/src/lib/openclaw-heartbeat-artifacts.test.ts index 3134aa2..2d5140c 100644 --- a/src/lib/openclaw-heartbeat-artifacts.test.ts +++ b/src/lib/openclaw-heartbeat-artifacts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isOpenClawHeartbeatArtifact } from "./openclaw-heartbeat-artifacts"; +import { isOpenClawHeartbeatAck, isOpenClawHeartbeatArtifact } from "./openclaw-heartbeat-artifacts"; describe("isOpenClawHeartbeatArtifact", () => { it("detects OpenClaw heartbeat poll prompts", () => { @@ -31,6 +31,18 @@ describe("isOpenClawHeartbeatArtifact", () => { })).toBe(true); }); + it("separates heartbeat acknowledgements from noisy heartbeat internals", () => { + expect(isOpenClawHeartbeatAck({ + role: "assistant", + content: "HEARTBEAT_OK", + })).toBe(true); + + expect(isOpenClawHeartbeatAck({ + role: "assistant", + content: "# HEARTBEAT.md\n\nActive workstream", + })).toBe(false); + }); + it("keeps normal chat content visible", () => { expect(isOpenClawHeartbeatArtifact({ role: "user", diff --git a/src/lib/openclaw-heartbeat-artifacts.ts b/src/lib/openclaw-heartbeat-artifacts.ts index adebfb2..78961c5 100644 --- a/src/lib/openclaw-heartbeat-artifacts.ts +++ b/src/lib/openclaw-heartbeat-artifacts.ts @@ -39,3 +39,10 @@ export function isOpenClawHeartbeatArtifact(params: { const heading = firstMeaningfulLine(content).replace(/^#+\s*/, "").toLowerCase(); return heading === "heartbeat.md"; } + +export function isOpenClawHeartbeatAck(params: { + role?: ChatArtifactRole | string | null; + content?: string | null; +}) { + return normalizeContent(params.content ?? "").toLowerCase().replace(/\s+/g, " ") === "heartbeat_ok"; +} From 4d11f856ad4f9cbe3e2ee3c3c0b17bb5fea7c355 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Wed, 13 May 2026 14:39:58 +1000 Subject: [PATCH 4/4] fix: show heartbeat acknowledgements as activity --- .../openclaw/sessions/[key]/preview/route.ts | 10 ++- src/app/chat/page.tsx | 80 +++++++++++++------ 2 files changed, 64 insertions(+), 26 deletions(-) diff --git a/src/app/api/openclaw/sessions/[key]/preview/route.ts b/src/app/api/openclaw/sessions/[key]/preview/route.ts index 27ee350..34e46b8 100644 --- a/src/app/api/openclaw/sessions/[key]/preview/route.ts +++ b/src/app/api/openclaw/sessions/[key]/preview/route.ts @@ -2,7 +2,7 @@ import { NextRequest } from "next/server"; import { requireAuth } from "@/lib/require-auth"; import { getGatewayClient } from "@/lib/gateway-chat-pool"; import { ensureEventBridge } from "@/lib/init-event-bridge"; -import { isOpenClawHeartbeatArtifact } from "@/lib/openclaw-heartbeat-artifacts"; +import { isOpenClawHeartbeatAck, isOpenClawHeartbeatArtifact } from "@/lib/openclaw-heartbeat-artifacts"; export const dynamic = "force-dynamic"; @@ -33,7 +33,13 @@ function normalizePreviewItems(preview: GatewaySessionPreview | null) { text: item.text ?? item.content ?? "", createdAt: item.createdAt ?? item.timestamp ?? (typeof item.ts === "string" ? item.ts : undefined), })) - .filter((item) => item.text && !isOpenClawHeartbeatArtifact({ role: item.role, content: item.text })); + .filter((item) => + item.text && + ( + !isOpenClawHeartbeatArtifact({ role: item.role, content: item.text }) || + isOpenClawHeartbeatAck({ role: item.role, content: item.text }) + ) + ); } /** diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 84d6adc..41e2775 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -38,7 +38,7 @@ import { recordVoiceCrashBreadcrumb, } from "@/lib/agent-mode-diagnostics"; import { DEFAULT_AGENT_VOICE_SETTINGS, normalizeAgentVoiceSettings, type AgentVoiceSettings } from "@/lib/tts-voices"; -import { isOpenClawHeartbeatArtifact } from "@/lib/openclaw-heartbeat-artifacts"; +import { isOpenClawHeartbeatAck, isOpenClawHeartbeatArtifact } from "@/lib/openclaw-heartbeat-artifacts"; /** Append markers for parsed task references not already embedded. */ function injectTaskCardMarkers(content: string, refs: ReturnType): string { @@ -183,6 +183,24 @@ function isVisibleChatMessage(message: Pick +
+
+ + ); +} + function stableHash(input: string) { let hash = 5381; for (let i = 0; i < input.length; i += 1) { @@ -965,7 +983,10 @@ async function loadSessionPreviewIntoStore(sessionKey: string) { createdAt, metadata: stableId === messageId ? null : { threadParentId: stableId }, }; - }).filter((m) => m.content && !isOpenClawHeartbeatArtifact({ role: m.role, content: m.content })); + }).filter((m) => + m.content && + (!isOpenClawHeartbeatArtifact({ role: m.role, content: m.content }) || isHeartbeatAckMessage(m)) + ); useChatStore.getState().loadSession( sessionKey.toLowerCase(), @@ -1842,6 +1863,12 @@ export default function ChatPage() { ), [messages] ); + const transcriptItems = useMemo( + () => uniqueMessagesById( + messages.filter((message) => isVisibleChatMessage(message) || isHeartbeatAckMessage(message)) + ), + [messages] + ); const scrollToMessage = useCallback((messageId: string) => { window.requestAnimationFrame(() => { @@ -3990,7 +4017,7 @@ export default function ChatPage() { {/* Messages area */}
- {visibleMessages.length === 0 && !streamingContent && ( + {transcriptItems.length === 0 && !streamingContent && (
)} - {visibleMessages.map((msg, i) => { - const prevDate = i > 0 ? getDateKey(visibleMessages[i - 1].createdAt) : null; + {transcriptItems.map((msg, i) => { + const prevDate = i > 0 ? getDateKey(transcriptItems[i - 1].createdAt) : null; const currDate = getDateKey(msg.createdAt); const showSeparator = currDate && currDate !== prevDate; const threadSummary = threadReplySummaries[`id:${threadParentIdForMessage(msg)}`]; const threadReplies = threadSummary?.replies ?? []; const canPersistMessageAction = isUuid(msg.id); + const isHeartbeatAck = isHeartbeatAckMessage(msg); return (
{showSeparator && } - openThreadForMessage(msg, i, threadSummary?.sessionKey)} - onTogglePin={canPersistMessageAction ? () => void togglePin(msg) : undefined} - onToggleSaved={canPersistMessageAction ? () => void toggleSaved(msg) : undefined} - isPinned={pinnedMessageIds.has(msg.id)} - isSaved={Boolean(savedByMessageId[msg.id])} - threadReplyCount={threadReplies.length} - threadReplies={threadReplies} - voiceSettings={resolvedVoiceSettings} - /> + {isHeartbeatAck ? ( + + ) : ( + openThreadForMessage(msg, i, threadSummary?.sessionKey)} + onTogglePin={canPersistMessageAction ? () => void togglePin(msg) : undefined} + onToggleSaved={canPersistMessageAction ? () => void toggleSaved(msg) : undefined} + isPinned={pinnedMessageIds.has(msg.id)} + isSaved={Boolean(savedByMessageId[msg.id])} + threadReplyCount={threadReplies.length} + threadReplies={threadReplies} + voiceSettings={resolvedVoiceSettings} + /> + )}
); })}