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

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