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; + } } } }