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
This commit is contained in:
David Brailovsky 2026-09-03 09:40:24 +02:00
parent 4e69b0c280
commit bea67d90d1
5 changed files with 70 additions and 27 deletions

View file

@ -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

View file

@ -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"}

View file

@ -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<void> {
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();
}
}

View file

@ -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
}

View file

@ -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<void>;
}> {
let markCompactionStarted = () => {};
const compactionStarted = new Promise<void>((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 () => {