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

Merged via squash.

Prepared head SHA: 05981b6c9f
Co-authored-by: yetval <102706514+yetval@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
This commit is contained in:
Yuval Dinodia
2026-06-23 04:41:12 -04:00
committed by GitHub
parent a59b2f2958
commit 9f0d2427cd
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") ||