Skip to content

Commit 1052652

Browse files
SunnyShu0925claudesallyom
authored
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 <[email protected]> * 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 <[email protected]> * fix(session-memory): reset mirror dedupe on command turns Signed-off-by: sallyom <[email protected]> --------- Signed-off-by: sallyom <[email protected]> Co-authored-by: Claude Sonnet 4.6 <[email protected]> Co-authored-by: sallyom <[email protected]>
1 parent 825d9a6 commit 1052652

2 files changed

Lines changed: 164 additions & 0 deletions

File tree

src/hooks/bundled/session-memory/handler.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,4 +832,150 @@ describe("session-memory hook", () => {
832832
expect(memoryContent).toContain("user: Only message 1");
833833
expect(memoryContent).toContain("assistant: Only message 2");
834834
});
835+
836+
it("preserves delivery-mirror with unique text when no raw assistant precedes it (message-tool scenario)", async () => {
837+
// When a delivery-mirror row is the only assistant reply (e.g., the
838+
// response comes from a message-tool send, not a raw model turn),
839+
// it must be preserved — not filtered out by a blanket DM skip.
840+
const sessionContent = [
841+
JSON.stringify({ type: "message", message: { role: "user", content: "Turn on the lights" } }),
842+
JSON.stringify({
843+
type: "message",
844+
message: {
845+
role: "assistant",
846+
provider: "openclaw",
847+
model: "delivery-mirror",
848+
content: [{ type: "text", text: "Lights turned on" }],
849+
},
850+
}),
851+
].join("\n");
852+
853+
const memoryContent = await readSessionTranscript({ sessionContent });
854+
const assistantLines = memoryContent!.split("\n").filter((l) => l.startsWith("assistant:"));
855+
// The delivery-mirror is the only assistant reply — must be preserved
856+
expect(assistantLines).toEqual(["assistant: Lights turned on"]);
857+
expect(memoryContent).toContain("Lights turned on");
858+
});
859+
860+
it("filters delivery-mirror duplicates but preserves standalone gateway-injected assistant rows (fixes #92563)", async () => {
861+
const sessionContent = [
862+
JSON.stringify({ type: "message", message: { role: "user", content: "What is 2+2?" } }),
863+
JSON.stringify({
864+
type: "message",
865+
message: {
866+
role: "assistant",
867+
provider: "openclaw",
868+
model: "claude",
869+
content: [
870+
{ type: "thinking", text: "..." },
871+
{ type: "text", text: "2+2 = 4" },
872+
],
873+
},
874+
}),
875+
JSON.stringify({
876+
type: "message",
877+
message: {
878+
role: "assistant",
879+
provider: "openclaw",
880+
model: "delivery-mirror",
881+
content: [{ type: "text", text: "2+2 = 4" }],
882+
},
883+
}),
884+
JSON.stringify({
885+
type: "message",
886+
message: {
887+
role: "assistant",
888+
provider: "openclaw",
889+
model: "gateway-injected",
890+
content: [{ type: "text", text: "standalone gateway reply" }],
891+
},
892+
}),
893+
].join("\n");
894+
895+
const memoryContent = await readSessionTranscript({ sessionContent });
896+
const assistantLines = memoryContent!.split("\n").filter((l) => l.startsWith("assistant:"));
897+
// delivery-mirror duplicate is filtered, gateway-injected standalone is preserved
898+
expect(assistantLines).toEqual(["assistant: 2+2 = 4", "assistant: standalone gateway reply"]);
899+
expect(memoryContent).toContain("standalone gateway reply");
900+
});
901+
902+
it("preserves delivery-mirror after user turn even when mirroring older assistant text", async () => {
903+
// Without the user-turn reset of `lastAssistantText`, a delivery-mirror
904+
// row after a user message that echoes a *previous* turn's assistant
905+
// content would be incorrectly filtered.
906+
const sessionContent = [
907+
JSON.stringify({
908+
type: "message",
909+
message: { role: "assistant", content: "Your number is 123-4567" },
910+
}),
911+
JSON.stringify({
912+
type: "message",
913+
message: {
914+
role: "assistant",
915+
provider: "openclaw",
916+
model: "delivery-mirror",
917+
content: [{ type: "text", text: "Your number is 123-4567" }],
918+
},
919+
}),
920+
JSON.stringify({
921+
type: "message",
922+
message: { role: "user", content: "I changed it to 987-6543" },
923+
}),
924+
// This delivery-mirror echoes the old assistant text from a previous turn.
925+
// In the new turn, it must NOT be filtered — there is no other assistant
926+
// reply in this turn to deduplicate against.
927+
JSON.stringify({
928+
type: "message",
929+
message: {
930+
role: "assistant",
931+
provider: "openclaw",
932+
model: "delivery-mirror",
933+
content: [{ type: "text", text: "Your number is 123-4567" }],
934+
},
935+
}),
936+
].join("\n");
937+
938+
const memoryContent = await readSessionTranscript({ sessionContent });
939+
const lines = memoryContent!.split("\n").filter((l) => l.startsWith("assistant:"));
940+
expect(lines).toEqual([
941+
"assistant: Your number is 123-4567",
942+
"assistant: Your number is 123-4567",
943+
]);
944+
});
945+
946+
it("preserves delivery-mirror after an omitted slash-command user turn", async () => {
947+
const sessionContent = [
948+
JSON.stringify({
949+
type: "message",
950+
message: { role: "assistant", content: "Done" },
951+
}),
952+
JSON.stringify({
953+
type: "message",
954+
message: {
955+
role: "assistant",
956+
provider: "openclaw",
957+
model: "delivery-mirror",
958+
content: [{ type: "text", text: "Done" }],
959+
},
960+
}),
961+
JSON.stringify({
962+
type: "message",
963+
message: { role: "user", content: "/new" },
964+
}),
965+
JSON.stringify({
966+
type: "message",
967+
message: {
968+
role: "assistant",
969+
provider: "openclaw",
970+
model: "delivery-mirror",
971+
content: [{ type: "text", text: "Done" }],
972+
},
973+
}),
974+
].join("\n");
975+
976+
const memoryContent = await readSessionTranscript({ sessionContent });
977+
const lines = memoryContent!.split("\n").filter((l) => l.startsWith("assistant:"));
978+
expect(lines).toEqual(["assistant: Done", "assistant: Done"]);
979+
expect(memoryContent).not.toContain("user: /new");
980+
});
835981
});

src/hooks/bundled/session-memory/transcript.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
33
import path from "node:path";
44
import { sanitizeModelSpecialTokens } from "../../../security/external-content.js";
55
import { hasInterSessionUserProvenance } from "../../../sessions/input-provenance.js";
6+
import { isOpenClawDeliveryMirrorAssistantMessage } from "../../../shared/transcript-only-openclaw-assistant.js";
67

78
const SESSION_MEMORY_TOOL_DIRECTIVE_PREFIX = String.raw`(?:(?:\|DSML\|)|(?:\uFF5CDSML\uFF5C))?`;
89
const SESSION_MEMORY_TOOL_DIRECTIVE_KIND = String.raw`(?:tool_calls?|function_calls?|tool_use_error)`;
@@ -64,6 +65,7 @@ export async function getRecentSessionContent(
6465
const lines = content.trim().split("\n");
6566

6667
const allMessages: string[] = [];
68+
let lastAssistantText: string | undefined;
6769
for (const line of lines) {
6870
try {
6971
const entry = JSON.parse(line);
@@ -78,10 +80,26 @@ export async function getRecentSessionContent(
7880
if (role === "user" && hasInterSessionUserProvenance(msg)) {
7981
continue;
8082
}
83+
if (role === "user") {
84+
// New turn: reset even when slash commands are omitted from
85+
// memory, so later standalone delivery mirrors are preserved.
86+
lastAssistantText = undefined;
87+
}
8188
const text = extractTextMessageContent(msg.content);
8289
const sanitized = text ? sanitizeSessionMemoryTranscriptText(text) : null;
90+
// Skip delivery-mirror rows only when they duplicate the preceding
91+
// assistant text. Delivery-mirror rows with unique visible content
92+
// (e.g., message-tool replies) are preserved.
93+
if (isOpenClawDeliveryMirrorAssistantMessage(msg)) {
94+
if (sanitized && sanitized === lastAssistantText) {
95+
continue;
96+
}
97+
}
8398
if (sanitized && !sanitized.startsWith("/")) {
8499
allMessages.push(`${role}: ${sanitized}`);
100+
if (role === "assistant") {
101+
lastAssistantText = sanitized;
102+
}
85103
}
86104
}
87105
}

0 commit comments

Comments
 (0)