From bea67d90d1a74dde8852c63cac72d476013d3879 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Thu, 3 Sep 2026 09:40:24 +0200 Subject: [PATCH] fix(coding-agent): cancel compaction on session abort Include compaction and branch summarization in session idle tracking so abort waits for cancellation to settle.\n\nfixes #8920 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/rpc.md | 2 +- .../coding-agent/src/core/agent-session.ts | 21 ++++-- .../rpc-prompt-response-semantics.test.ts | 4 +- .../suite/agent-session-compaction.test.ts | 69 ++++++++++++++----- 5 files changed, 70 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1e84bd1e2..75b6b45de 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,7 @@ - Fixed the write tool reporting UTF-16 code-unit counts as byte counts by removing the misleading count ([#8979](https://github.com/earendil-works/pi/issues/8979)). - Fixed proxied plain-HTTP provider requests hanging after a tool call by tunneling them with CONNECT ([#8134](https://github.com/earendil-works/pi/issues/8134)). +- Fixed RPC `abort` reporting success without cancelling an in-progress manual compaction ([#8920](https://github.com/earendil-works/pi/issues/8920)). ## [0.84.4] - 2026-08-28 diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 52dbf884f..d81c23bbf 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -123,7 +123,7 @@ See [set_follow_up_mode](#set_follow_up_mode) for controlling how follow-up mess #### abort -Abort the current agent operation. +Abort the current operation and wait for the session to become idle before responding. ```json {"type": "abort"} diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 8afde1906..ac2bd4b18 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -617,7 +617,7 @@ export class AgentSession { } private _resolveIdleWaitIfIdle(): void { - if (this._isAgentRunActive || !this._resolveIdleWait) { + if (!this.isIdle || !this._resolveIdleWait) { return; } const resolve = this._resolveIdleWait; @@ -921,9 +921,9 @@ export class AgentSession { return this._isAgentRunActive; } - /** Whether the session has no active agent run, retry, auto-compaction, or queued continuation. */ + /** Whether the session has no active agent run, compaction, branch summary, retry, or queued continuation. */ get isIdle(): boolean { - return !this._isAgentRunActive; + return !this._isAgentRunActive && !this.isCompacting; } /** Current effective system prompt (includes any per-turn extension modifications) */ @@ -1618,6 +1618,8 @@ export class AgentSession { */ async abort(): Promise { this.abortRetry(); + this.abortCompaction(); + this.abortBranchSummary(); this.agent.abort(); await this.waitForIdle(); } @@ -1921,6 +1923,11 @@ export class AgentSession { ); } + private _clearManualCompactionState(): void { + this._compactionAbortController = undefined; + this._resolveIdleWaitIfIdle(); + } + /** * Manually compact the session context. * @@ -2051,7 +2058,7 @@ export class AgentSession { details, }; // compaction_end listeners may submit queued prompts, so expose idle state before notifying them. - this._compactionAbortController = undefined; + this._clearManualCompactionState(); this._emit({ type: "compaction_end", reason: "manual", @@ -2064,7 +2071,7 @@ export class AgentSession { const message = error instanceof Error ? error.message : String(error); const aborted = message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError"); const errorMessage = aborted ? undefined : `Compaction failed: ${message}`; - this._compactionAbortController = undefined; + this._clearManualCompactionState(); this._emit({ type: "compaction_end", reason: "manual", @@ -2082,7 +2089,7 @@ export class AgentSession { }); throw error; } finally { - this._compactionAbortController = undefined; + this._clearManualCompactionState(); } } @@ -2419,6 +2426,7 @@ export class AgentSession { return false; } finally { this._autoCompactionAbortController = undefined; + this._resolveIdleWaitIfIdle(); } } @@ -3291,6 +3299,7 @@ export class AgentSession { return { editorText, cancelled: false, summaryEntry }; } finally { this._branchSummaryAbortController = undefined; + this._resolveIdleWaitIfIdle(); } } diff --git a/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts b/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts index bdc47b4a5..5d7c147ff 100644 --- a/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts +++ b/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts @@ -156,9 +156,7 @@ async function createRuntimeHost(options: { withAuth: boolean; responseDelayMs: runtimeHost, cleanup: async () => { try { - if (session.isStreaming) { - await session.abort(); - } + await session.abort(); } catch { // ignore test cleanup failures } 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 64da8b4ab..700b6e610 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -95,6 +95,31 @@ function seedCompactableSession(harness: Harness): void { harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages; } +async function createAbortableCompactionHarness(): Promise<{ + harness: Harness; + compactionStarted: Promise; +}> { + let markCompactionStarted = () => {}; + const compactionStarted = new Promise((resolve) => { + markCompactionStarted = resolve; + }); + const harness = await createHarness({ + settings: { compaction: { keepRecentTokens: 1 } }, + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", async (event) => { + return await new Promise<{ cancel: true }>((resolve) => { + event.signal.addEventListener("abort", () => resolve({ cancel: true }), { once: true }); + markCompactionStarted(); + }); + }); + }, + ], + }); + seedCompactableSession(harness); + return { harness, compactionStarted }; +} + describe("AgentSession compaction characterization", () => { const harnesses: Harness[] = []; @@ -637,28 +662,38 @@ 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) => { - return await new Promise<{ cancel: true }>((resolve) => { - event.signal.addEventListener("abort", () => resolve({ cancel: true }), { once: true }); - }); - }); - }, - ], - }); + const { harness, compactionStarted } = await createAbortableCompactionHarness(); harnesses.push(harness); - await harness.session.prompt("one"); - await harness.session.prompt("two"); - const compactPromise = harness.session.compact(); - await new Promise((resolve) => setTimeout(resolve, 0)); + const compactExpectation = expect(compactPromise).rejects.toThrow("Compaction cancelled"); + await compactionStarted; harness.session.abortCompaction(); - await expect(compactPromise).rejects.toThrow("Compaction cancelled"); + await compactExpectation; + }); + + // Regression test for #8920. + it("aborts an in-progress manual compaction and waits until the session is idle", async () => { + const { harness, compactionStarted } = await createAbortableCompactionHarness(); + harnesses.push(harness); + harness.setResponses([fauxAssistantMessage("continued")]); + + const compactPromise = harness.session.compact(); + const compactExpectation = expect(compactPromise).rejects.toThrow("Compaction cancelled"); + await compactionStarted; + await harness.session.abort(); + await compactExpectation; + + expect(harness.eventsOfType("compaction_end").at(-1)).toMatchObject({ + reason: "manual", + aborted: true, + }); + expect(harness.session.isCompacting).toBe(false); + expect(harness.session.isIdle).toBe(true); + + await expect(harness.session.prompt("next prompt")).resolves.toBeUndefined(); + expect(harness.session.getLastAssistantText()).toBe("continued"); }); it("resumes after threshold compaction when only agent-level queued messages exist", async () => {