Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/app/api/openclaw/sessions/[key]/preview/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { isOpenClawHeartbeatAck, isOpenClawHeartbeatArtifact } from "@/lib/openclaw-heartbeat-artifacts";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -32,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);
.filter((item) =>
item.text &&
(
!isOpenClawHeartbeatArtifact({ role: item.role, content: item.text }) ||
isOpenClawHeartbeatAck({ role: item.role, content: item.text })
)
);
}

/**
Expand Down
91 changes: 66 additions & 25 deletions src/app/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
recordVoiceCrashBreadcrumb,
} from "@/lib/agent-mode-diagnostics";
import { DEFAULT_AGENT_VOICE_SETTINGS, normalizeAgentVoiceSettings, type AgentVoiceSettings } from "@/lib/tts-voices";
import { isOpenClawHeartbeatAck, isOpenClawHeartbeatArtifact } from "@/lib/openclaw-heartbeat-artifacts";

/** Append <!--task_card --> markers for parsed task references not already embedded. */
function injectTaskCardMarkers(content: string, refs: ReturnType<typeof parseTaskReferences>): string {
Expand Down Expand Up @@ -174,6 +175,32 @@ function displayContentFromGatewayPreview(content: string, sessionKey: string) {
return isMessageThreadSessionKey(sessionKey) ? extractUserThreadReply(content) : "";
}

function isVisibleChatMessage(message: Pick<Message, "role" | "content" | "metadata">) {
return (
hasRenderableMessageContent(message) &&
!isThreadContextEnvelope(message.content) &&
!isOpenClawHeartbeatArtifact({ role: message.role, content: message.content })
);
}

function isHeartbeatAckMessage(message: { role?: string | null; content?: string | null }) {
return isOpenClawHeartbeatAck({ role: message.role, content: message.content });
}

function HeartbeatAckMarker({ timestamp }: { timestamp?: string | null }) {
const label = timestamp ? new Date(timestamp).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }) : null;

return (
<div className="flex justify-center py-1">
<div className="flex items-center gap-2 rounded-full border border-[var(--border-subtle)] bg-[var(--bg-surface)]/70 px-3 py-1 text-[11px] text-[var(--text-tertiary)]">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--accent)]/70" aria-hidden="true" />
<span>Heartbeat checked</span>
{label && <span className="text-[var(--text-tertiary)]/70">{label}</span>}
</div>
</div>
);
}

function stableHash(input: string) {
let hash = 5381;
for (let i = 0; i < input.length; i += 1) {
Expand Down Expand Up @@ -956,7 +983,10 @@ 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 }) || isHeartbeatAckMessage(m))
);

useChatStore.getState().loadSession(
sessionKey.toLowerCase(),
Expand Down Expand Up @@ -1568,7 +1598,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]);
Expand Down Expand Up @@ -1829,7 +1859,13 @@ export default function ChatPage() {
);
const visibleMessages = useMemo(
() => uniqueMessagesById(
messages.filter((message) => hasRenderableMessageContent(message) && !isThreadContextEnvelope(message.content))
messages.filter(isVisibleChatMessage)
),
[messages]
);
const transcriptItems = useMemo(
() => uniqueMessagesById(
messages.filter((message) => isVisibleChatMessage(message) || isHeartbeatAckMessage(message))
),
[messages]
);
Expand Down Expand Up @@ -3981,7 +4017,7 @@ export default function ChatPage() {
{/* Messages area */}
<div ref={scrollContainerRef} className="relative min-h-0 flex-1 overflow-y-auto px-4 py-4 lg:px-6">
<div className="mx-auto max-w-3xl space-y-4">
{visibleMessages.length === 0 && !streamingContent && (
{transcriptItems.length === 0 && !streamingContent && (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div
className="mb-4 flex h-16 w-16 items-center justify-center rounded-[var(--radius-panel)] border border-[var(--border-medium)] bg-[var(--bg-surface)]"
Expand Down Expand Up @@ -4009,13 +4045,14 @@ export default function ChatPage() {
</div>
)}

{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 (
<div
key={msg.id}
Expand All @@ -4025,25 +4062,29 @@ export default function ChatPage() {
}`}
>
{showSeparator && <DateSeparator date={msg.createdAt!} />}
<ChatMessage
role={msg.role}
content={msg.content}
timestamp={msg.createdAt}
metadata={msg.metadata}
authorName={msg.role === "user" ? userDisplayName : assistantDisplayName}
authorAvatarUrl={msg.role === "user" ? userAvatarUrl : assistantAvatarUrl}
authorEmoji={msg.role === "assistant" ? agentEmoji : null}
identityDetails={msg.role === "user" ? userIdentityDetails : assistantIdentityDetails}
onOpenIdentity={setActiveIdentityProfile}
onReplyInThread={() => 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 ? (
<HeartbeatAckMarker timestamp={msg.createdAt} />
) : (
<ChatMessage
role={msg.role}
content={msg.content}
timestamp={msg.createdAt}
metadata={msg.metadata}
authorName={msg.role === "user" ? userDisplayName : assistantDisplayName}
authorAvatarUrl={msg.role === "user" ? userAvatarUrl : assistantAvatarUrl}
authorEmoji={msg.role === "assistant" ? agentEmoji : null}
identityDetails={msg.role === "user" ? userIdentityDetails : assistantIdentityDetails}
onOpenIdentity={setActiveIdentityProfile}
onReplyInThread={() => 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}
/>
)}
</div>
);
})}
Expand Down
57 changes: 57 additions & 0 deletions src/lib/openclaw-heartbeat-artifacts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { isOpenClawHeartbeatAck, 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("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",
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);
});
});
48 changes: 48 additions & 0 deletions src/lib/openclaw-heartbeat-artifacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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";
}

export function isOpenClawHeartbeatAck(params: {
role?: ChatArtifactRole | string | null;
content?: string | null;
}) {
return normalizeContent(params.content ?? "").toLowerCase().replace(/\s+/g, " ") === "heartbeat_ok";
}
Loading