mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 19:20:42 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user