fix(compaction): preserve tail for empty manual compact

Manual /compact now preserves Pi's recent tail when the compaction input has no summarizable messages or yields an empty summary, avoiding an empty checkpoint that drops live context.

Verification:
- pnpm test src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts -- --reporter=verbose
- pnpm exec oxfmt --check --threads=1 src/agents/pi-embedded-runner/manual-compaction-boundary.ts src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts
- git diff --check -- src/agents/pi-embedded-runner/manual-compaction-boundary.ts src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts CHANGELOG.md
- Local gateway proof in PR body: real sessions.compact preserved the recent tail while the provider saw an empty conversation.

Note: checks-node-auto-reply-reply-dispatch is already failing on upstream/main with the same four dispatch-from-config.test.ts assertions; this PR only touches compaction boundary files.
This commit is contained in:
scoootscooob
2026-05-08 01:00:20 -07:00
committed by GitHub
parent 94ceddc481
commit 09f83bfec0
3 changed files with 116 additions and 4 deletions

View File

@@ -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.

View File

@@ -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);

View File

@@ -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,
};
}