diff --git a/CHANGELOG.md b/CHANGELOG.md index ff1f1cdcd67..d5f96341eb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -183,6 +183,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context. - fix(discord): gate user allowlist name resolution [AI]. (#79002) Thanks @pgondhi987. - fix(msteams): gate startup user allowlist resolution [AI]. (#79003) Thanks @pgondhi987. - Harden macOS shell wrapper allowlist parsing [AI]. (#78518) Thanks @pgondhi987. diff --git a/src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts b/src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts index fb14b807034..de12933f76e 100644 --- a/src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts +++ b/src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts @@ -157,6 +157,67 @@ describe("hardenManualCompactionBoundary", () => { ]); }); + it("keeps the recent tail when manual compaction produced an empty summary", async () => { + const dir = await makeTmpDir(); + const session = SessionManager.create(dir, dir); + + session.appendMessage({ role: "user", content: "old question", timestamp: 1 }); + session.appendMessage(createAssistantTextMessage("old answer", 2)); + session.appendMessage({ role: "user", content: "fresh question", timestamp: 3 }); + const keepId = requireString(session.getBranch().at(-1)?.id, "keep id"); + session.appendMessage(createAssistantTextMessage("fresh answer", 4)); + session.appendCompaction("", keepId, 200); + const sessionFile = requireString(session.getSessionFile(), "session file"); + + const hardened = await hardenManualCompactionBoundary({ sessionFile }); + expect(hardened.applied).toBe(false); + expect(hardened.firstKeptEntryId).toBe(keepId); + expect(hardened.messages.map((message) => message.role)).toEqual([ + "compactionSummary", + "user", + "assistant", + ]); + expect(hardened.messages.map((message) => messageText(message)).join("\n")).toContain( + "fresh question", + ); + + const reopened = SessionManager.open(sessionFile); + const latest = reopened.getLeafEntry(); + expect(latest?.type).toBe("compaction"); + if (!latest || latest.type !== "compaction") { + throw new Error("expected latest leaf to be a compaction entry"); + } + expect(latest.firstKeptEntryId).toBe(keepId); + }); + + it("keeps the recent tail when manual compaction had no messages to summarize", async () => { + const dir = await makeTmpDir(); + const session = SessionManager.create(dir, dir); + + session.appendMessage({ role: "user", content: "fresh question", timestamp: 1 }); + const keepId = requireString(session.getBranch().at(-1)?.id, "keep id"); + session.appendMessage(createAssistantTextMessage("fresh answer", 2)); + session.appendCompaction("No prior history.", keepId, 200); + const sessionFile = requireString(session.getSessionFile(), "session file"); + + const hardened = await hardenManualCompactionBoundary({ sessionFile }); + expect(hardened.applied).toBe(false); + expect(hardened.firstKeptEntryId).toBe(keepId); + expect(hardened.messages.map((message) => message.role)).toEqual([ + "compactionSummary", + "user", + "assistant", + ]); + + const reopened = SessionManager.open(sessionFile); + const latest = reopened.getLeafEntry(); + expect(latest?.type).toBe("compaction"); + if (!latest || latest.type !== "compaction") { + throw new Error("expected latest leaf to be a compaction entry"); + } + expect(latest.firstKeptEntryId).toBe(keepId); + }); + it("is a no-op when the latest leaf is not a compaction entry", async () => { const dir = await makeTmpDir(); const session = SessionManager.create(dir, dir); diff --git a/src/agents/pi-embedded-runner/manual-compaction-boundary.ts b/src/agents/pi-embedded-runner/manual-compaction-boundary.ts index c615d877c67..2a1ebb05361 100644 --- a/src/agents/pi-embedded-runner/manual-compaction-boundary.ts +++ b/src/agents/pi-embedded-runner/manual-compaction-boundary.ts @@ -33,6 +33,42 @@ function replaceLatestCompactionBoundary(params: { }); } +function entryCreatesCompactionInputMessage(entry: SessionEntry): boolean { + return ( + entry.type === "message" || entry.type === "custom_message" || entry.type === "branch_summary" + ); +} + +function hasMessagesToSummarizeBeforeKeptTail(params: { + branch: SessionEntry[]; + compaction: CompactionEntry; +}): boolean { + const compactionIndex = params.branch.findIndex((entry) => entry.id === params.compaction.id); + const firstKeptIndex = params.branch.findIndex( + (entry) => entry.id === params.compaction.firstKeptEntryId, + ); + if (compactionIndex <= 0 || firstKeptIndex < 0 || firstKeptIndex >= compactionIndex) { + return false; + } + + let boundaryStartIndex = 0; + for (let i = compactionIndex - 1; i >= 0; i -= 1) { + const entry = params.branch[i]; + if (entry?.type !== "compaction") { + continue; + } + const previousFirstKeptIndex = params.branch.findIndex( + (candidate) => candidate.id === entry.firstKeptEntryId, + ); + boundaryStartIndex = previousFirstKeptIndex >= 0 ? previousFirstKeptIndex : i + 1; + break; + } + + return params.branch + .slice(boundaryStartIndex, firstKeptIndex) + .some((entry) => entryCreatesCompactionInputMessage(entry)); +} + export async function hardenManualCompactionBoundary(params: { sessionFile: string; preserveRecentTail?: boolean; @@ -56,8 +92,8 @@ export async function hardenManualCompactionBoundary(params: { }; } + const sessionContext = state.buildSessionContext(); if (params.preserveRecentTail) { - const sessionContext = state.buildSessionContext(); return { applied: false, firstKeptEntryId: leaf.firstKeptEntryId, @@ -67,7 +103,6 @@ export async function hardenManualCompactionBoundary(params: { } if (leaf.firstKeptEntryId === leaf.id) { - const sessionContext = state.buildSessionContext(); return { applied: false, firstKeptEntryId: leaf.id, @@ -76,6 +111,21 @@ export async function hardenManualCompactionBoundary(params: { }; } + if ( + !leaf.summary.trim() || + !hasMessagesToSummarizeBeforeKeptTail({ + branch: state.getBranch(leaf.id), + compaction: leaf, + }) + ) { + return { + applied: false, + firstKeptEntryId: leaf.firstKeptEntryId, + leafId: state.getLeafId() ?? undefined, + messages: sessionContext.messages, + }; + } + const replacedEntries = replaceLatestCompactionBoundary({ entries: state.getEntries(), compactionEntryId: leaf.id, @@ -86,11 +136,11 @@ export async function hardenManualCompactionBoundary(params: { }); await writeTranscriptFileAtomic(params.sessionFile, [header, ...replacedEntries]); - const sessionContext = replacedState.buildSessionContext(); + const replacedSessionContext = replacedState.buildSessionContext(); return { applied: true, firstKeptEntryId: leaf.id, leafId: replacedState.getLeafId() ?? undefined, - messages: sessionContext.messages, + messages: replacedSessionContext.messages, }; }