From fa65b3270e855b19905e75d55730b60160da5ef4 Mon Sep 17 00:00:00 2001 From: Yuval Dinodia <102706514+yetval@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:49:19 -0400 Subject: [PATCH] fix(agents): preserve kept-tail prompt during compaction transcript rotation (#93732) Duplicate user-message detection ran over the full branch, so when a prompt was re-sent within the 60s window its earlier copy in the summarized prefix and the later copy in the kept tail were both removed: the summarized copy via summarizedBranchIds and the kept copy as a duplicate. With truncateAfterCompaction enabled the prompt then vanished from the successor transcript entirely. Restrict dedup to the kept region so the first surviving copy is preserved. --- ...r-transcript.duplicate-prompt-loss.test.ts | 79 +++++++++++++++++++ .../compaction-successor-transcript.ts | 4 +- 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 src/agents/embedded-agent-runner/compaction-successor-transcript.duplicate-prompt-loss.test.ts diff --git a/src/agents/embedded-agent-runner/compaction-successor-transcript.duplicate-prompt-loss.test.ts b/src/agents/embedded-agent-runner/compaction-successor-transcript.duplicate-prompt-loss.test.ts new file mode 100644 index 000000000000..f182f09241af --- /dev/null +++ b/src/agents/embedded-agent-runner/compaction-successor-transcript.duplicate-prompt-loss.test.ts @@ -0,0 +1,79 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; +import { afterEach, describe, expect, it } from "vitest"; +import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js"; +import { rotateTranscriptAfterCompaction } from "./compaction-successor-transcript.js"; +import { readTranscriptFileState } from "./transcript-file-state.js"; + +let tmpDir: string | undefined; +afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); + tmpDir = undefined; + } +}); + +function makeAssistant(text: string, timestamp: number) { + return makeAgentAssistantMessage({ content: [{ type: "text", text }], timestamp }); +} + +function readUserTexts(entries: { type: string; message?: unknown }[]): string[] { + return entries + .filter( + (entry) => + entry.type === "message" && + (entry.message as { role?: unknown } | undefined)?.role === "user", + ) + .map((entry) => { + const content = (entry.message as { content?: unknown } | undefined)?.content; + if (typeof content === "string") { + return content; + } + if (Array.isArray(content)) { + return content.map((block) => (block as { text?: string })?.text ?? "").join(""); + } + return ""; + }); +} + +const PROMPT = "Please refactor the authentication module right now"; + +describe("rotateTranscriptAfterCompaction duplicate-prompt preservation", () => { + it("keeps a kept-tail prompt whose earlier copy was summarized away", async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "successor-dup-loss-")); + const manager = SessionManager.create(tmpDir, tmpDir); + + manager.appendMessage({ role: "user", content: PROMPT, timestamp: 1000 }); + manager.appendMessage(makeAssistant("Working on it.", 1001)); + const firstKeptId = manager.appendMessage({ + role: "user", + content: "go on", + timestamp: 1002, + }); + manager.appendMessage(makeAssistant("Continuing.", 1003)); + manager.appendCompaction("Summary of earlier turns.", firstKeptId, 5000); + manager.appendMessage({ role: "user", content: PROMPT, timestamp: 40000 }); + manager.appendMessage(makeAssistant("On it again.", 40001)); + + const sessionFile = manager.getSessionFile(); + if (!sessionFile) { + throw new Error("no session file"); + } + + const result = await rotateTranscriptAfterCompaction({ sessionManager: manager, sessionFile }); + expect(result.rotated).toBe(true); + const successorFile = result.sessionFile; + if (!successorFile) { + throw new Error("no successor file"); + } + + const successor = await readTranscriptFileState(successorFile); + const userPromptTexts = readUserTexts( + successor.getEntries() as { type: string; message?: unknown }[], + ); + + expect(userPromptTexts.some((text) => text.includes(PROMPT))).toBe(true); + }); +}); diff --git a/src/agents/embedded-agent-runner/compaction-successor-transcript.ts b/src/agents/embedded-agent-runner/compaction-successor-transcript.ts index 8b47f8705540..1ace7e074e87 100644 --- a/src/agents/embedded-agent-runner/compaction-successor-transcript.ts +++ b/src/agents/embedded-agent-runner/compaction-successor-transcript.ts @@ -174,7 +174,9 @@ function buildSuccessorEntries(params: { } const removedIds = new Set(); - const duplicateUserMessageIds = collectDuplicateUserMessageEntryIdsForCompaction(branch); + const keptBranchEntries = branch.filter((entry) => !summarizedBranchIds.has(entry.id)); + const duplicateUserMessageIds = + collectDuplicateUserMessageEntryIdsForCompaction(keptBranchEntries); for (const entry of allEntries) { if ( (summarizedBranchIds.has(entry.id) && entry.type === "message") ||