mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user