From 7d08c81a0962b4731ca41e89cddc9ed9eb103455 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 18 Jun 2026 22:17:57 +0200 Subject: [PATCH] fix(coding-agent): avoid empty compaction summaries closes #4811 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/agent-session.ts | 53 +++++++------------ .../src/core/compaction/compaction.ts | 4 ++ .../src/modes/interactive/interactive-mode.ts | 9 ---- .../test/compaction-extensions.test.ts | 1 + packages/coding-agent/test/compaction.test.ts | 25 +-------- .../suite/agent-session-compaction.test.ts | 19 ++++--- 7 files changed, 37 insertions(+), 75 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ab9812c5..0fcc303a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 23fb5983..cae330f3 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1892,19 +1892,10 @@ export class AgentSession { */ private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise { 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; diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 07f2f9fe..f3f3d59f 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -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); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 00054eb2..962c414f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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 { - 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; diff --git a/packages/coding-agent/test/compaction-extensions.test.ts b/packages/coding-agent/test/compaction-extensions.test.ts index 2c41210e..c95125f2 100644 --- a/packages/coding-agent/test/compaction-extensions.test.ts +++ b/packages/coding-agent/test/compaction-extensions.test.ts @@ -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); diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 929d06ec..be3d5c63 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -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", () => { diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index e790d896..a3302adf 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -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) => {