From 1052652a7168025dfa7d52c75e80460fde87f8cd Mon Sep 17 00:00:00 2001 From: SunnyShu Date: Mon, 29 Jun 2026 23:19:25 +0800 Subject: [PATCH] fix(session-memory): skip transcript-only assistant messages in getRecentSessionContent (#94401) * fix(session-memory): only skip delivery-mirror duplicates, preserve unique DM rows - Skip delivery-mirror rows only when their text duplicates the preceding assistant text (fixes #92563) - Delivery-mirror rows with unique visible content (e.g., message-tool replies) are preserved - Gateway-injected standalone assistant replies are preserved - Combined with upstream sanitizeSessionMemoryTranscriptText Co-Authored-By: Claude Sonnet 4.6 * fix(session-memory): reset assistant-text tracking across user turns lastAssistantText persisted across user messages, causing delivery-mirror rows that echoed a previous turn's assistant text to be incorrectly filtered. Reset lastAssistantText to undefined when a visible user message is emitted, so cross-turn delivery-mirror duplicates are preserved while same-turn duplicates are still skipped. Co-Authored-By: Claude Sonnet 4.6 * fix(session-memory): reset mirror dedupe on command turns Signed-off-by: sallyom --------- Signed-off-by: sallyom Co-authored-by: Claude Sonnet 4.6 Co-authored-by: sallyom --- .../bundled/session-memory/handler.test.ts | 146 ++++++++++++++++++ .../bundled/session-memory/transcript.ts | 18 +++ 2 files changed, 164 insertions(+) diff --git a/src/hooks/bundled/session-memory/handler.test.ts b/src/hooks/bundled/session-memory/handler.test.ts index 2f962b04d669..42f62e411bc0 100644 --- a/src/hooks/bundled/session-memory/handler.test.ts +++ b/src/hooks/bundled/session-memory/handler.test.ts @@ -832,4 +832,150 @@ describe("session-memory hook", () => { expect(memoryContent).toContain("user: Only message 1"); expect(memoryContent).toContain("assistant: Only message 2"); }); + + it("preserves delivery-mirror with unique text when no raw assistant precedes it (message-tool scenario)", async () => { + // When a delivery-mirror row is the only assistant reply (e.g., the + // response comes from a message-tool send, not a raw model turn), + // it must be preserved — not filtered out by a blanket DM skip. + const sessionContent = [ + JSON.stringify({ type: "message", message: { role: "user", content: "Turn on the lights" } }), + JSON.stringify({ + type: "message", + message: { + role: "assistant", + provider: "openclaw", + model: "delivery-mirror", + content: [{ type: "text", text: "Lights turned on" }], + }, + }), + ].join("\n"); + + const memoryContent = await readSessionTranscript({ sessionContent }); + const assistantLines = memoryContent!.split("\n").filter((l) => l.startsWith("assistant:")); + // The delivery-mirror is the only assistant reply — must be preserved + expect(assistantLines).toEqual(["assistant: Lights turned on"]); + expect(memoryContent).toContain("Lights turned on"); + }); + + it("filters delivery-mirror duplicates but preserves standalone gateway-injected assistant rows (fixes #92563)", async () => { + const sessionContent = [ + JSON.stringify({ type: "message", message: { role: "user", content: "What is 2+2?" } }), + JSON.stringify({ + type: "message", + message: { + role: "assistant", + provider: "openclaw", + model: "claude", + content: [ + { type: "thinking", text: "..." }, + { type: "text", text: "2+2 = 4" }, + ], + }, + }), + JSON.stringify({ + type: "message", + message: { + role: "assistant", + provider: "openclaw", + model: "delivery-mirror", + content: [{ type: "text", text: "2+2 = 4" }], + }, + }), + JSON.stringify({ + type: "message", + message: { + role: "assistant", + provider: "openclaw", + model: "gateway-injected", + content: [{ type: "text", text: "standalone gateway reply" }], + }, + }), + ].join("\n"); + + const memoryContent = await readSessionTranscript({ sessionContent }); + const assistantLines = memoryContent!.split("\n").filter((l) => l.startsWith("assistant:")); + // delivery-mirror duplicate is filtered, gateway-injected standalone is preserved + expect(assistantLines).toEqual(["assistant: 2+2 = 4", "assistant: standalone gateway reply"]); + expect(memoryContent).toContain("standalone gateway reply"); + }); + + it("preserves delivery-mirror after user turn even when mirroring older assistant text", async () => { + // Without the user-turn reset of `lastAssistantText`, a delivery-mirror + // row after a user message that echoes a *previous* turn's assistant + // content would be incorrectly filtered. + const sessionContent = [ + JSON.stringify({ + type: "message", + message: { role: "assistant", content: "Your number is 123-4567" }, + }), + JSON.stringify({ + type: "message", + message: { + role: "assistant", + provider: "openclaw", + model: "delivery-mirror", + content: [{ type: "text", text: "Your number is 123-4567" }], + }, + }), + JSON.stringify({ + type: "message", + message: { role: "user", content: "I changed it to 987-6543" }, + }), + // This delivery-mirror echoes the old assistant text from a previous turn. + // In the new turn, it must NOT be filtered — there is no other assistant + // reply in this turn to deduplicate against. + JSON.stringify({ + type: "message", + message: { + role: "assistant", + provider: "openclaw", + model: "delivery-mirror", + content: [{ type: "text", text: "Your number is 123-4567" }], + }, + }), + ].join("\n"); + + const memoryContent = await readSessionTranscript({ sessionContent }); + const lines = memoryContent!.split("\n").filter((l) => l.startsWith("assistant:")); + expect(lines).toEqual([ + "assistant: Your number is 123-4567", + "assistant: Your number is 123-4567", + ]); + }); + + it("preserves delivery-mirror after an omitted slash-command user turn", async () => { + const sessionContent = [ + JSON.stringify({ + type: "message", + message: { role: "assistant", content: "Done" }, + }), + JSON.stringify({ + type: "message", + message: { + role: "assistant", + provider: "openclaw", + model: "delivery-mirror", + content: [{ type: "text", text: "Done" }], + }, + }), + JSON.stringify({ + type: "message", + message: { role: "user", content: "/new" }, + }), + JSON.stringify({ + type: "message", + message: { + role: "assistant", + provider: "openclaw", + model: "delivery-mirror", + content: [{ type: "text", text: "Done" }], + }, + }), + ].join("\n"); + + const memoryContent = await readSessionTranscript({ sessionContent }); + const lines = memoryContent!.split("\n").filter((l) => l.startsWith("assistant:")); + expect(lines).toEqual(["assistant: Done", "assistant: Done"]); + expect(memoryContent).not.toContain("user: /new"); + }); }); diff --git a/src/hooks/bundled/session-memory/transcript.ts b/src/hooks/bundled/session-memory/transcript.ts index 6ec171b5983c..6df02a367c24 100644 --- a/src/hooks/bundled/session-memory/transcript.ts +++ b/src/hooks/bundled/session-memory/transcript.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { sanitizeModelSpecialTokens } from "../../../security/external-content.js"; import { hasInterSessionUserProvenance } from "../../../sessions/input-provenance.js"; +import { isOpenClawDeliveryMirrorAssistantMessage } from "../../../shared/transcript-only-openclaw-assistant.js"; const SESSION_MEMORY_TOOL_DIRECTIVE_PREFIX = String.raw`(?:(?:\|DSML\|)|(?:\uFF5CDSML\uFF5C))?`; const SESSION_MEMORY_TOOL_DIRECTIVE_KIND = String.raw`(?:tool_calls?|function_calls?|tool_use_error)`; @@ -64,6 +65,7 @@ export async function getRecentSessionContent( const lines = content.trim().split("\n"); const allMessages: string[] = []; + let lastAssistantText: string | undefined; for (const line of lines) { try { const entry = JSON.parse(line); @@ -78,10 +80,26 @@ export async function getRecentSessionContent( if (role === "user" && hasInterSessionUserProvenance(msg)) { continue; } + if (role === "user") { + // New turn: reset even when slash commands are omitted from + // memory, so later standalone delivery mirrors are preserved. + lastAssistantText = undefined; + } const text = extractTextMessageContent(msg.content); const sanitized = text ? sanitizeSessionMemoryTranscriptText(text) : null; + // Skip delivery-mirror rows only when they duplicate the preceding + // assistant text. Delivery-mirror rows with unique visible content + // (e.g., message-tool replies) are preserved. + if (isOpenClawDeliveryMirrorAssistantMessage(msg)) { + if (sanitized && sanitized === lastAssistantText) { + continue; + } + } if (sanitized && !sanitized.startsWith("/")) { allMessages.push(`${role}: ${sanitized}`); + if (role === "assistant") { + lastAssistantText = sanitized; + } } } }