fix(agents): keep post-compaction user re-issue of a kept-tail prompt

Under truncateAfterCompaction, buildSuccessorEntries ran one 60s duplicate-user
dedup window across both the kept pre-compaction tail and all post-compaction
entries. A kept-tail prompt became the dedup first-seen and suppressed a genuine
post-compaction re-issue of the same text, deleting the user instruction and
orphaning its assistant reply (two consecutive assistant messages with no parent
user turn), which strict providers reject on the next request.

Dedup the pre-compaction kept tail and the post-compaction tail independently so
the window resets at the compaction boundary. Each side still drops its own
intra-window retries; a kept-tail prompt no longer reaches across the boundary.

Regression of #93732, which narrowed the dedup input to keptBranchEntries but
left it spanning the boundary.
This commit is contained in:
yetval
2026-06-18 00:53:57 +00:00
committed by Vincent Koc
parent a59b2f2958
commit 05981b6c9f
2 changed files with 90 additions and 3 deletions
@@ -0,0 +1,82 @@
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 = "Run the deployment script for staging now";
describe("rotateTranscriptAfterCompaction post-compaction duplicate", () => {
it("keeps a real post-compaction user turn that repeats a kept-tail prompt", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "successor-dup-post-"));
const manager = SessionManager.create(tmpDir, tmpDir);
manager.appendMessage({ role: "user", content: "set up the project", timestamp: 1000 });
manager.appendMessage(makeAssistant("Project ready.", 1001));
const firstKeptId = manager.appendMessage({
role: "user",
content: PROMPT,
timestamp: 2000,
});
manager.appendMessage(makeAssistant("Deploying to staging.", 2001));
manager.appendCompaction("Summary of project setup.", firstKeptId, 2050);
manager.appendMessage({ role: "user", content: PROMPT, timestamp: 2080 });
manager.appendMessage(makeAssistant("Redeploying to staging (second run).", 2081));
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 }[],
);
const occurrences = userPromptTexts.filter((text) => text.includes(PROMPT)).length;
expect(occurrences).toBe(2);
});
});
@@ -143,9 +143,14 @@ function buildSuccessorEntries(params: {
}
const removedIds = new Set<string>();
const keptBranchEntries = branch.filter((entry) => !summarizedBranchIds.has(entry.id));
const duplicateUserMessageIds =
collectDuplicateUserMessageEntryIdsForCompaction(keptBranchEntries);
const keptPreCompactionEntries = branch
.slice(0, latestCompactionIndex)
.filter((entry) => !summarizedBranchIds.has(entry.id));
const postCompactionEntries = branch.slice(latestCompactionIndex + 1);
const duplicateUserMessageIds = new Set<string>([
...collectDuplicateUserMessageEntryIdsForCompaction(keptPreCompactionEntries),
...collectDuplicateUserMessageEntryIdsForCompaction(postCompactionEntries),
]);
for (const entry of allEntries) {
if (
(summarizedBranchIds.has(entry.id) && entry.type === "message") ||