fix(coding-agent): avoid empty compaction summaries

closes #4811
This commit is contained in:
Armin Ronacher
2026-06-18 22:17:57 +02:00
parent 6b9f3f492f
commit 7d08c81a09
7 changed files with 37 additions and 75 deletions

View File

@@ -4,6 +4,7 @@
### Fixed
- Fixed compaction to refuse sessions with no eligible messages instead of producing empty summaries ([#4811](https://github.com/earendil-works/pi/issues/4811)).
- Fixed successful overflow-triggered auto-compaction to avoid retrying completed assistant responses ([#5720](https://github.com/earendil-works/pi/issues/5720)).
## [0.79.7] - 2026-06-18

View File

@@ -1892,19 +1892,10 @@ export class AgentSession {
*/
private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<boolean> {
const settings = this.settingsManager.getCompactionSettings();
this._emit({ type: "compaction_start", reason });
this._autoCompactionAbortController = new AbortController();
let started = false;
try {
if (!this.model) {
this._emit({
type: "compaction_end",
reason,
result: undefined,
aborted: false,
willRetry: false,
});
return false;
}
@@ -1914,13 +1905,6 @@ export class AgentSession {
if (this.agent.streamFn === streamSimple) {
const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model);
if (!authResult.ok || !authResult.apiKey) {
this._emit({
type: "compaction_end",
reason,
result: undefined,
aborted: false,
willRetry: false,
});
return false;
}
apiKey = authResult.apiKey;
@@ -1934,16 +1918,13 @@ export class AgentSession {
const preparation = prepareCompaction(pathEntries, settings);
if (!preparation) {
this._emit({
type: "compaction_end",
reason,
result: undefined,
aborted: false,
willRetry: false,
});
return false;
}
this._emit({ type: "compaction_start", reason });
this._autoCompactionAbortController = new AbortController();
started = true;
let extensionCompaction: CompactionResult | undefined;
let fromExtension = false;
@@ -2054,17 +2035,19 @@ export class AgentSession {
return this.agent.hasQueuedMessages();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "compaction failed";
this._emit({
type: "compaction_end",
reason,
result: undefined,
aborted: false,
willRetry: false,
errorMessage:
reason === "overflow"
? `Context overflow recovery failed: ${errorMessage}`
: `Auto-compaction failed: ${errorMessage}`,
});
if (started) {
this._emit({
type: "compaction_end",
reason,
result: undefined,
aborted: false,
willRetry: false,
errorMessage:
reason === "overflow"
? `Context overflow recovery failed: ${errorMessage}`
: `Auto-compaction failed: ${errorMessage}`,
});
}
return false;
} finally {
this._autoCompactionAbortController = undefined;

View File

@@ -698,6 +698,10 @@ export function prepareCompaction(
}
}
if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) {
return undefined;
}
// Extract file operations from messages and previous compaction
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);

View File

@@ -3700,7 +3700,6 @@ export class InteractiveMode {
showError(errorMessage: string): void {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0));
this.chatContainer.addChild(new Spacer(1));
this.ui.requestRender();
}
@@ -5695,14 +5694,6 @@ export class InteractiveMode {
}
private async handleCompactCommand(customInstructions?: string): Promise<void> {
const entries = this.sessionManager.getEntries();
const messageCount = entries.filter((e) => e.type === "message").length;
if (messageCount < 2) {
this.showWarning("Nothing to compact (no messages yet)");
return;
}
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;

View File

@@ -98,6 +98,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
const sessionManager = SessionManager.create(tempDir);
const settingsManager = SettingsManager.create(tempDir, tempDir);
settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage);

View File

@@ -9,7 +9,6 @@ import {
calculateContextTokens,
compact,
DEFAULT_COMPACTION_SETTINGS,
estimateContextTokens,
findCutPoint,
getLastAssistantUsage,
prepareCompaction,
@@ -396,7 +395,7 @@ describe("buildSessionContext", () => {
});
describe("prepareCompaction with previous compaction", () => {
it("should preserve kept messages across repeated compactions when they still fit", () => {
it("should skip repeated compactions when kept messages still fit", () => {
const u1 = createMessageEntry(createUserMessage("user msg 1 (summarized by compaction1)"));
const a1 = createMessageEntry(createAssistantMessage("assistant msg 1"));
const u2 = createMessageEntry(createUserMessage("user msg 2 - kept by compaction1"));
@@ -408,29 +407,9 @@ describe("prepareCompaction with previous compaction", () => {
const a4 = createMessageEntry(createAssistantMessage("assistant msg 4", createMockUsage(8000, 2000)));
const pathEntries = [u1, a1, u2, a2, u3, a3, compaction1, u4, a4];
const contextBefore = buildSessionContext(pathEntries);
const preparation = prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS);
expect(preparation).toBeDefined();
expect(preparation!.firstKeptEntryId).toBe(u2.id);
expect(preparation!.previousSummary).toBe("First summary");
expect(extractText(preparation!.messagesToSummarize)).not.toContain("First summary");
expect(preparation!.tokensBefore).toBe(estimateContextTokens(contextBefore.messages).tokens);
const compaction2: CompactionEntry = {
type: "compaction",
id: "compaction2-id",
parentId: a4.id,
timestamp: new Date().toISOString(),
summary: "Second summary",
firstKeptEntryId: preparation!.firstKeptEntryId,
tokensBefore: preparation!.tokensBefore,
};
const contextAfter = buildSessionContext([...pathEntries, compaction2]);
const contextAfterText = extractText(contextAfter.messages);
expect(contextAfterText).toContain("user msg 2 - kept by compaction1");
expect(contextAfterText).toContain("user msg 3 - kept by compaction1");
expect(preparation).toBeUndefined();
});
it("should re-summarize previously kept messages when the recent window moves past them", () => {

View File

@@ -67,19 +67,20 @@ function useSummaryStreamFn(harness: Harness, summary: string): () => number {
}
function seedCompactableSession(harness: Harness): void {
harness.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
const now = Date.now();
harness.sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "message to compact" }],
timestamp: now - 1000,
});
harness.sessionManager.appendMessage(
createAssistant(harness, {
stopReason: "stop",
totalTokens: 100,
timestamp: now - 500,
}),
);
const assistant = createAssistant(harness, {
stopReason: "stop",
totalTokens: 100,
timestamp: now - 500,
});
assistant.content = [{ type: "text", text: "assistant response to compact" }];
harness.sessionManager.appendMessage(assistant);
harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages;
}
@@ -96,6 +97,7 @@ describe("AgentSession compaction characterization", () => {
it("manually compacts using an extension-provided summary", async () => {
const harness = await createHarness({
settings: { compaction: { keepRecentTokens: 1 } },
extensionFactories: [
(pi) => {
pi.on("session_before_compact", async (event) => ({
@@ -145,7 +147,7 @@ describe("AgentSession compaction characterization", () => {
const result = await harness.session.compact();
expect(result.summary).toBe("summary from custom stream");
expect(result.summary).toContain("summary from custom stream");
expect(getStreamCallCount()).toBe(1);
});
@@ -165,6 +167,7 @@ describe("AgentSession compaction characterization", () => {
it("cancels in-progress manual compaction when abortCompaction is called", async () => {
const harness = await createHarness({
settings: { compaction: { keepRecentTokens: 1 } },
extensionFactories: [
(pi) => {
pi.on("session_before_compact", async (event) => {