Skip to content

Commit 7a72bdf

Browse files
author
KarbZClaw
committed
fix: hide heartbeat tool prompts from chat history
1 parent 725754e commit 7a72bdf

7 files changed

Lines changed: 237 additions & 45 deletions

File tree

src/auto-reply/heartbeat-filter.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import {
44
isHeartbeatOkResponse,
55
isHeartbeatUserMessage,
66
} from "./heartbeat-filter.js";
7-
import { HEARTBEAT_PROMPT, HEARTBEAT_TRANSCRIPT_PROMPT } from "./heartbeat.js";
7+
import {
8+
HEARTBEAT_PROMPT,
9+
HEARTBEAT_RESPONSE_TOOL_PROMPT,
10+
HEARTBEAT_TRANSCRIPT_PROMPT,
11+
} from "./heartbeat.js";
812

913
describe("isHeartbeatUserMessage", () => {
1014
it("matches heartbeat prompts", () => {
@@ -26,6 +30,13 @@ describe("isHeartbeatUserMessage", () => {
2630
}),
2731
).toBe(true);
2832

33+
expect(
34+
isHeartbeatUserMessage({
35+
role: "user",
36+
content: HEARTBEAT_RESPONSE_TOOL_PROMPT,
37+
}),
38+
).toBe(true);
39+
2940
expect(
3041
isHeartbeatUserMessage({
3142
role: "user",

src/auto-reply/heartbeat-filter.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import { stripHeartbeatToken } from "./heartbeat.js";
2-
import { HEARTBEAT_TRANSCRIPT_PROMPT } from "./heartbeat.js";
1+
import {
2+
HEARTBEAT_PROMPT,
3+
HEARTBEAT_RESPONSE_TOOL_PROMPT,
4+
HEARTBEAT_TRANSCRIPT_PROMPT,
5+
stripHeartbeatToken,
6+
} from "./heartbeat.js";
37

48
const HEARTBEAT_TASK_PROMPT_PREFIX =
59
"Run the following periodic tasks (only those due based on their intervals):";
@@ -46,11 +50,13 @@ export function isHeartbeatUserMessage(
4650
if (!trimmed) {
4751
return false;
4852
}
49-
const normalizedHeartbeatPrompt = heartbeatPrompt?.trim();
53+
const knownHeartbeatPrompts = [heartbeatPrompt, HEARTBEAT_PROMPT, HEARTBEAT_RESPONSE_TOOL_PROMPT]
54+
.map((prompt) => prompt?.trim())
55+
.filter((prompt): prompt is string => Boolean(prompt));
5056
if (trimmed === HEARTBEAT_TRANSCRIPT_PROMPT) {
5157
return true;
5258
}
53-
if (normalizedHeartbeatPrompt && trimmed.startsWith(normalizedHeartbeatPrompt)) {
59+
if (knownHeartbeatPrompts.some((prompt) => trimmed.startsWith(prompt))) {
5460
return true;
5561
}
5662
return (

src/gateway/chat-display-projection.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,88 @@ function toProjectedMessages(messages: unknown[]): Array<Record<string, unknown>
462462
);
463463
}
464464

465+
function projectedHistoryDedupeKey(message: Record<string, unknown>): string | null {
466+
const role = typeof message.role === "string" ? message.role.trim().toLowerCase() : "";
467+
if (!role) {
468+
return null;
469+
}
470+
471+
const textSignature =
472+
typeof message.textSignature === "string" && message.textSignature.trim()
473+
? message.textSignature.trim()
474+
: null;
475+
if (role === "assistant" && textSignature) {
476+
return `${role}:signature:${textSignature}`;
477+
}
478+
479+
const responseId =
480+
typeof message.responseId === "string" && message.responseId.trim()
481+
? message.responseId.trim()
482+
: null;
483+
if (role === "assistant" && responseId) {
484+
return `${role}:response:${responseId}`;
485+
}
486+
487+
const timestamp =
488+
typeof message.timestamp === "number" && Number.isFinite(message.timestamp)
489+
? message.timestamp
490+
: null;
491+
if (timestamp === null) {
492+
return null;
493+
}
494+
495+
const roleContent = asRoleContentMessage(message);
496+
const text = roleContent ? extractMessageTextForDisplayDedupe(roleContent.content).trim() : "";
497+
if (!text) {
498+
return null;
499+
}
500+
return `${role}:ts:${timestamp}:text:${text}`;
501+
}
502+
503+
function extractMessageTextForDisplayDedupe(content: unknown): string {
504+
if (typeof content === "string") {
505+
return content;
506+
}
507+
if (!Array.isArray(content)) {
508+
return "";
509+
}
510+
return content
511+
.map((block) => {
512+
if (!block || typeof block !== "object") {
513+
return "";
514+
}
515+
const entry = block as { type?: unknown; text?: unknown };
516+
return entry.type === "text" && typeof entry.text === "string" ? entry.text : "";
517+
})
518+
.join("");
519+
}
520+
521+
function dedupeAdjacentProjectedHistoryMessages(messages: Array<Record<string, unknown>>): {
522+
messages: Array<Record<string, unknown>>;
523+
changed: boolean;
524+
} {
525+
if (messages.length < 2) {
526+
return { messages, changed: false };
527+
}
528+
let changed = false;
529+
const deduped: Array<Record<string, unknown>> = [];
530+
for (const message of messages) {
531+
const key = projectedHistoryDedupeKey(message);
532+
const previous = deduped[deduped.length - 1];
533+
const previousKey = previous ? projectedHistoryDedupeKey(previous) : null;
534+
if (key && previousKey === key) {
535+
// Keep the newer/right-most transcript copy. Control UI duplicates can
536+
// differ only by internal sender metadata; the later copy displays as
537+
// the local user instead of the synthetic openclaw-control-ui sender.
538+
deduped[deduped.length - 1] = message;
539+
changed = true;
540+
continue;
541+
}
542+
deduped.push(message);
543+
}
544+
return { messages: deduped, changed };
545+
}
546+
465547
function filterVisibleProjectedHistoryMessages(
466548
messages: Array<Record<string, unknown>>,
467549
): Array<Record<string, unknown>> {
@@ -494,7 +576,9 @@ function filterVisibleProjectedHistoryMessages(
494576
}
495577
visible.push(current);
496578
}
497-
return changed ? visible : messages;
579+
const deduped = dedupeAdjacentProjectedHistoryMessages(visible);
580+
changed ||= deduped.changed;
581+
return changed ? deduped.messages : messages;
498582
}
499583

500584
export function projectChatDisplayMessages(

src/gateway/server-methods/server-methods.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import os from "node:os";
44
import path from "node:path";
55
import { fileURLToPath } from "node:url";
66
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
7+
import { HEARTBEAT_PROMPT, HEARTBEAT_RESPONSE_TOOL_PROMPT } from "../../auto-reply/heartbeat.js";
78
import { emitAgentEvent } from "../../infra/agent-events.js";
89
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js";
910
import {
@@ -458,6 +459,45 @@ describe("sanitizeChatHistoryMessages", () => {
458459
});
459460

460461
describe("projectRecentChatDisplayMessages", () => {
462+
it("hides heartbeat user prompts from display history", () => {
463+
const result = projectRecentChatDisplayMessages([
464+
{ role: "user", content: HEARTBEAT_PROMPT, timestamp: 1 },
465+
{ role: "user", content: HEARTBEAT_RESPONSE_TOOL_PROMPT, timestamp: 2 },
466+
{ role: "user", content: "real user message", timestamp: 3 },
467+
]);
468+
469+
expect(result).toEqual([{ role: "user", content: "real user message", timestamp: 3 }]);
470+
});
471+
472+
it("dedupes adjacent duplicate display messages while keeping the local user copy", () => {
473+
const result = projectRecentChatDisplayMessages([
474+
{
475+
role: "user",
476+
content: "same ask",
477+
timestamp: 10,
478+
senderLabel: "openclaw-control-ui",
479+
},
480+
{ role: "user", content: "same ask", timestamp: 10 },
481+
{
482+
role: "assistant",
483+
content: "same answer",
484+
timestamp: 11,
485+
textSignature: "sig-1",
486+
},
487+
{
488+
role: "assistant",
489+
content: "same answer",
490+
timestamp: 11,
491+
textSignature: "sig-1",
492+
},
493+
]);
494+
495+
expect(result).toEqual([
496+
{ role: "user", content: "same ask", timestamp: 10 },
497+
{ role: "assistant", content: "same answer", timestamp: 11, textSignature: "sig-1" },
498+
]);
499+
});
500+
461501
it("applies history limits after dropping display-hidden messages", () => {
462502
const result = projectRecentChatDisplayMessages(
463503
[

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,8 +367,9 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
367367

368368
const timestamp = typeof m.timestamp === "number" ? m.timestamp : Date.now();
369369
const id = typeof m.id === "string" ? m.id : undefined;
370-
const senderLabel =
370+
const rawSenderLabel =
371371
typeof m.senderLabel === "string" && m.senderLabel.trim() ? m.senderLabel.trim() : null;
372+
const senderLabel = rawSenderLabel === "openclaw-control-ui" ? null : rawSenderLabel;
372373

373374
// Strip AI-injected metadata prefix blocks from user messages before display.
374375
if (role === "user" || role === "User") {

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

Lines changed: 54 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ describe("handleChatEvent", () => {
171171
expect(state.chatStream).toBe("Hello");
172172
});
173173

174-
it("ignores NO_REPLY delta updates", () => {
174+
it.each(["NO_REPLY", "HEARTBEAT_OK"])("ignores %s delta updates", (text) => {
175175
const state = createState({
176176
sessionKey: "main",
177177
chatRunId: "run-1",
@@ -181,7 +181,7 @@ describe("handleChatEvent", () => {
181181
runId: "run-1",
182182
sessionKey: "main",
183183
state: "delta",
184-
message: { role: "assistant", content: [{ type: "text", text: "NO_REPLY" }] },
184+
message: { role: "assistant", content: [{ type: "text", text }] },
185185
};
186186

187187
expect(handleChatEvent(state, payload)).toBe("delta");
@@ -212,16 +212,19 @@ describe("handleChatEvent", () => {
212212
expect(state.chatMessages[0]).toEqual(payload.message);
213213
});
214214

215-
it("drops NO_REPLY final payload from another run without clearing active stream", () => {
216-
const state = createActiveStreamingState();
217-
const payload = createOtherRunNoReplyFinalPayload();
215+
it.each(["NO_REPLY", "HEARTBEAT_OK"])(
216+
"drops %s final payload from another run without clearing active stream",
217+
(text) => {
218+
const state = createActiveStreamingState();
219+
const payload = createOtherRunSilentFinalPayload(text);
218220

219-
expect(handleChatEvent(state, payload)).toBe("final");
220-
expect(state.chatRunId).toBe("run-user");
221-
expect(state.chatStream).toBe("Working...");
222-
expect(state.chatStreamStartedAt).toBe(123);
223-
expect(state.chatMessages).toEqual([]);
224-
});
221+
expect(handleChatEvent(state, payload)).toBe("final");
222+
expect(state.chatRunId).toBe("run-user");
223+
expect(state.chatStream).toBe("Working...");
224+
expect(state.chatStreamStartedAt).toBe(123);
225+
expect(state.chatMessages).toEqual([]);
226+
},
227+
);
225228

226229
it.each(["no_reply", "ANNOUNCE_SKIP", "REPLY_SKIP"])(
227230
"keeps plain-text %s final payload from another run without clearing active stream",
@@ -559,11 +562,11 @@ describe("handleChatEvent", () => {
559562
expect(state.chatStream).toBe("Working...");
560563
});
561564

562-
it("drops NO_REPLY final payload from own run", () => {
565+
it.each(["NO_REPLY", "HEARTBEAT_OK"])("drops %s final payload from own run", (text) => {
563566
const state = createState({
564567
sessionKey: "main",
565568
chatRunId: "run-1",
566-
chatStream: "NO_REPLY",
569+
chatStream: text,
567570
chatStreamStartedAt: 100,
568571
});
569572
const payload: ChatEventPayload = {
@@ -572,7 +575,7 @@ describe("handleChatEvent", () => {
572575
state: "final",
573576
message: {
574577
role: "assistant",
575-
content: [{ type: "text", text: "NO_REPLY" }],
578+
content: [{ type: "text", text }],
576579
},
577580
};
578581

@@ -608,22 +611,25 @@ describe("handleChatEvent", () => {
608611
},
609612
);
610613

611-
it("does not persist NO_REPLY stream text on final without message", () => {
612-
const state = createState({
613-
sessionKey: "main",
614-
chatRunId: "run-1",
615-
chatStream: "NO_REPLY",
616-
chatStreamStartedAt: 100,
617-
});
618-
const payload: ChatEventPayload = {
619-
runId: "run-1",
620-
sessionKey: "main",
621-
state: "final",
622-
};
614+
it.each(["NO_REPLY", "HEARTBEAT_OK"])(
615+
"does not persist %s stream text on final without message",
616+
(text) => {
617+
const state = createState({
618+
sessionKey: "main",
619+
chatRunId: "run-1",
620+
chatStream: text,
621+
chatStreamStartedAt: 100,
622+
});
623+
const payload: ChatEventPayload = {
624+
runId: "run-1",
625+
sessionKey: "main",
626+
state: "final",
627+
};
623628

624-
expect(handleChatEvent(state, payload)).toBe("final");
625-
expect(state.chatMessages).toEqual([]);
626-
});
629+
expect(handleChatEvent(state, payload)).toBe("final");
630+
expect(state.chatMessages).toEqual([]);
631+
},
632+
);
627633

628634
it("does not persist NO_REPLY stream text on abort", () => {
629635
const state = createState({
@@ -1062,10 +1068,28 @@ describe("loadChatHistory", () => {
10621068
expect(state.lastError).toBeNull();
10631069
});
10641070

1065-
it("filters heartbeat acknowledgements and internal-only user messages", async () => {
1071+
it("filters heartbeat acknowledgements, heartbeat prompts, and internal-only user messages", async () => {
10661072
const request = vi.fn().mockResolvedValue({
10671073
messages: [
10681074
{ role: "assistant", content: [{ type: "text", text: "HEARTBEAT_OK" }] },
1075+
{
1076+
role: "user",
1077+
content: [
1078+
{
1079+
type: "text",
1080+
text: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. If nothing needs attention, reply HEARTBEAT_OK.",
1081+
},
1082+
],
1083+
},
1084+
{
1085+
role: "user",
1086+
content: [
1087+
{
1088+
type: "text",
1089+
text: "An async command completion event was triggered, but user delivery is disabled for this run. Handle the result internally and reply HEARTBEAT_OK only.",
1090+
},
1091+
],
1092+
},
10691093
{
10701094
role: "user",
10711095
content: [

0 commit comments

Comments
 (0)