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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix(session-memory): reset mirror dedupe on command turns

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: sallyom <somalley@redhat.com>
This commit is contained in:
SunnyShu
2026-06-29 23:19:25 +08:00
committed by GitHub
parent 825d9a6662
commit 1052652a71
2 changed files with 164 additions and 0 deletions
@@ -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");
});
});
@@ -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;
}
}
}
}