Skip to content

Commit 0ccc8f2

Browse files
committed
fix(agents): preserve kept-tail prompt during compaction transcript rotation
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.
1 parent cfdcd5c commit 0ccc8f2

2 files changed

Lines changed: 82 additions & 1 deletion

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
5+
import { afterEach, describe, expect, it } from "vitest";
6+
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
7+
import { rotateTranscriptAfterCompaction } from "./compaction-successor-transcript.js";
8+
import { readTranscriptFileState } from "./transcript-file-state.js";
9+
10+
let tmpDir: string | undefined;
11+
afterEach(async () => {
12+
if (tmpDir) {
13+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
14+
tmpDir = undefined;
15+
}
16+
});
17+
18+
function makeAssistant(text: string, timestamp: number) {
19+
return makeAgentAssistantMessage({ content: [{ type: "text", text }], timestamp });
20+
}
21+
22+
function readUserTexts(entries: { type: string; message?: unknown }[]): string[] {
23+
return entries
24+
.filter(
25+
(entry) =>
26+
entry.type === "message" &&
27+
(entry.message as { role?: unknown } | undefined)?.role === "user",
28+
)
29+
.map((entry) => {
30+
const content = (entry.message as { content?: unknown } | undefined)?.content;
31+
if (typeof content === "string") {
32+
return content;
33+
}
34+
if (Array.isArray(content)) {
35+
return content.map((block) => (block as { text?: string })?.text ?? "").join("");
36+
}
37+
return "";
38+
});
39+
}
40+
41+
const PROMPT = "Please refactor the authentication module right now";
42+
43+
describe("rotateTranscriptAfterCompaction duplicate-prompt preservation", () => {
44+
it("keeps a kept-tail prompt whose earlier copy was summarized away", async () => {
45+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "successor-dup-loss-"));
46+
const manager = SessionManager.create(tmpDir, tmpDir);
47+
48+
manager.appendMessage({ role: "user", content: PROMPT, timestamp: 1000 });
49+
manager.appendMessage(makeAssistant("Working on it.", 1001));
50+
const firstKeptId = manager.appendMessage({
51+
role: "user",
52+
content: "go on",
53+
timestamp: 1002,
54+
});
55+
manager.appendMessage(makeAssistant("Continuing.", 1003));
56+
manager.appendCompaction("Summary of earlier turns.", firstKeptId, 5000);
57+
manager.appendMessage({ role: "user", content: PROMPT, timestamp: 40000 });
58+
manager.appendMessage(makeAssistant("On it again.", 40001));
59+
60+
const sessionFile = manager.getSessionFile();
61+
if (!sessionFile) {
62+
throw new Error("no session file");
63+
}
64+
65+
const result = await rotateTranscriptAfterCompaction({ sessionManager: manager, sessionFile });
66+
expect(result.rotated).toBe(true);
67+
const successorFile = result.sessionFile;
68+
if (!successorFile) {
69+
throw new Error("no successor file");
70+
}
71+
72+
const successor = await readTranscriptFileState(successorFile);
73+
const userPromptTexts = readUserTexts(
74+
successor.getEntries() as { type: string; message?: unknown }[],
75+
);
76+
77+
expect(userPromptTexts.some((text) => text.includes(PROMPT))).toBe(true);
78+
});
79+
});

src/agents/embedded-agent-runner/compaction-successor-transcript.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,9 @@ function buildSuccessorEntries(params: {
174174
}
175175

176176
const removedIds = new Set<string>();
177-
const duplicateUserMessageIds = collectDuplicateUserMessageEntryIdsForCompaction(branch);
177+
const keptBranchEntries = branch.filter((entry) => !summarizedBranchIds.has(entry.id));
178+
const duplicateUserMessageIds =
179+
collectDuplicateUserMessageEntryIdsForCompaction(keptBranchEntries);
178180
for (const entry of allEntries) {
179181
if (
180182
(summarizedBranchIds.has(entry.id) && entry.type === "message") ||

0 commit comments

Comments
 (0)