mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
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:
+82
@@ -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") ||
|
||||
|
||||
Reference in New Issue
Block a user