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.
This commit is contained in:
Yuval Dinodia
2026-06-16 15:49:19 -04:00
committed by GitHub
parent 642ae61828
commit fa65b3270e
2 changed files with 82 additions and 1 deletions

View File

@@ -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);
});
});

View File

@@ -174,7 +174,9 @@ function buildSuccessorEntries(params: {
}
const removedIds = new Set<string>();
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") ||