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

View File

@@ -3700,7 +3700,6 @@ export class InteractiveMode {
showError(errorMessage: string): void { showError(errorMessage: string): void {
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0)); this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0));
this.chatContainer.addChild(new Spacer(1));
this.ui.requestRender(); this.ui.requestRender();
} }
@@ -5695,14 +5694,6 @@ export class InteractiveMode {
} }
private async handleCompactCommand(customInstructions?: string): Promise<void> { 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) { if (this.loadingAnimation) {
this.loadingAnimation.stop(); this.loadingAnimation.stop();
this.loadingAnimation = undefined; this.loadingAnimation = undefined;

View File

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

View File

@@ -9,7 +9,6 @@ import {
calculateContextTokens, calculateContextTokens,
compact, compact,
DEFAULT_COMPACTION_SETTINGS, DEFAULT_COMPACTION_SETTINGS,
estimateContextTokens,
findCutPoint, findCutPoint,
getLastAssistantUsage, getLastAssistantUsage,
prepareCompaction, prepareCompaction,
@@ -396,7 +395,7 @@ describe("buildSessionContext", () => {
}); });
describe("prepareCompaction with previous compaction", () => { 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 u1 = createMessageEntry(createUserMessage("user msg 1 (summarized by compaction1)"));
const a1 = createMessageEntry(createAssistantMessage("assistant msg 1")); const a1 = createMessageEntry(createAssistantMessage("assistant msg 1"));
const u2 = createMessageEntry(createUserMessage("user msg 2 - kept by compaction1")); 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 a4 = createMessageEntry(createAssistantMessage("assistant msg 4", createMockUsage(8000, 2000)));
const pathEntries = [u1, a1, u2, a2, u3, a3, compaction1, u4, a4]; const pathEntries = [u1, a1, u2, a2, u3, a3, compaction1, u4, a4];
const contextBefore = buildSessionContext(pathEntries);
const preparation = prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS); const preparation = prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS);
expect(preparation).toBeDefined(); expect(preparation).toBeUndefined();
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");
}); });
it("should re-summarize previously kept messages when the recent window moves past them", () => { 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 { function seedCompactableSession(harness: Harness): void {
harness.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
const now = Date.now(); const now = Date.now();
harness.sessionManager.appendMessage({ harness.sessionManager.appendMessage({
role: "user", role: "user",
content: [{ type: "text", text: "message to compact" }], content: [{ type: "text", text: "message to compact" }],
timestamp: now - 1000, timestamp: now - 1000,
}); });
harness.sessionManager.appendMessage( const assistant = createAssistant(harness, {
createAssistant(harness, { stopReason: "stop",
stopReason: "stop", totalTokens: 100,
totalTokens: 100, timestamp: now - 500,
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; 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 () => { it("manually compacts using an extension-provided summary", async () => {
const harness = await createHarness({ const harness = await createHarness({
settings: { compaction: { keepRecentTokens: 1 } },
extensionFactories: [ extensionFactories: [
(pi) => { (pi) => {
pi.on("session_before_compact", async (event) => ({ pi.on("session_before_compact", async (event) => ({
@@ -145,7 +147,7 @@ describe("AgentSession compaction characterization", () => {
const result = await harness.session.compact(); 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); expect(getStreamCallCount()).toBe(1);
}); });
@@ -165,6 +167,7 @@ describe("AgentSession compaction characterization", () => {
it("cancels in-progress manual compaction when abortCompaction is called", async () => { it("cancels in-progress manual compaction when abortCompaction is called", async () => {
const harness = await createHarness({ const harness = await createHarness({
settings: { compaction: { keepRecentTokens: 1 } },
extensionFactories: [ extensionFactories: [
(pi) => { (pi) => {
pi.on("session_before_compact", async (event) => { pi.on("session_before_compact", async (event) => {