diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 590d0bcbb..8d5ece28c 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -4235,8 +4235,17 @@ export class AgentSession { const authResult = await this._modelRuntime.getAuth(this.model); if (!this._ownsCompactionController(autoCompactionController, "auto")) return false; + this._emit({ type: "compaction_start", reason }); + if (!this._ownsCompactionController(autoCompactionController, "auto")) return false; if (this.agent.streamFn === streamSimple && !authResult?.auth.apiKey) { if (reason === "overflow") this._overflowRecoveryAttempted = false; + this._emit({ + type: "compaction_end", + reason, + result: undefined, + aborted: false, + willRetry: false, + }); return false; } @@ -4247,10 +4256,16 @@ export class AgentSession { ); if (!preparation) { if (reason === "overflow") this._overflowRecoveryAttempted = false; + this._emit({ + type: "compaction_end", + reason, + result: undefined, + aborted: false, + willRetry: false, + }); return false; } if (!this._ownsCompactionController(autoCompactionController, "auto")) return false; - this._emit({ type: "compaction_start", reason }); const execution = await this._executeCompaction({ controller: autoCompactionController, diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index ec5e08714..e56fdb0bc 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -8,6 +8,8 @@ controller at operation start, rejects stale completion/feedback, and retains the terminal result until another operation begins. Feedback-only aborts publish one terminal event, and accepted completions publish their terminal event before `session_compact` handlers can begin a fresh operation. +- Owned automatic compaction attempts start before authentication/preparation validation and end when preflight + rejects. Ownership is rechecked after start so synchronous listener supersession cannot publish a stale terminal. - Durable append now rejects a generation whose message revision or agent-message snapshot changed during preparation or summary generation (`stale-revision`), preserving intervening context without duplicate replay. - Required compaction uses one provider-admission gate for normal prompts, extension-triggered turns, and every next 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 bc129c2b1..7148b1b02 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -388,6 +388,42 @@ describe("AgentSession compaction characterization", () => { expect(getStreamCallCount()).toBe(1); }); + it("balances auto-compaction events when there is nothing to prepare", async () => { + const harness = await createHarness(); + harnesses.push(harness); + + await runAutoCompaction(harness.session, "threshold", false); + + const compactionEvents = harness.events.filter( + (event) => event.type === "compaction_start" || event.type === "compaction_end", + ); + expect(compactionEvents).toEqual([ + { type: "compaction_start", reason: "threshold" }, + expect.objectContaining({ + type: "compaction_end", + reason: "threshold", + result: undefined, + aborted: false, + willRetry: false, + }), + ]); + }); + + it("does not publish a stale preflight end after a start listener aborts auto-compaction", async () => { + const harness = await createHarness({ withConfiguredAuth: false }); + harnesses.push(harness); + harness.session.subscribe((event) => { + if (event.type === "compaction_start" && event.reason === "threshold") { + harness.session.abortCompaction(); + } + }); + + await runAutoCompaction(harness.session, "threshold", false); + + expect(harness.eventsOfType("compaction_start")).toEqual([{ type: "compaction_start", reason: "threshold" }]); + expect(harness.eventsOfType("compaction_end")).toHaveLength(0); + }); + it("does not emit compaction events for a normal response below the threshold", async () => { // given const harness = await createHarness({ @@ -1353,12 +1389,12 @@ describe("AgentSession compaction characterization", () => { await checkCompaction(harness.session, secondOverflow); const overflowStarts = harness.eventsOfType("compaction_start").filter((event) => event.reason === "overflow"); - const terminalOverflowFailures = harness - .eventsOfType("compaction_end") - .filter((event) => - event.errorMessage?.startsWith("Context overflow recovery failed after one compact-and-retry attempt"), - ); + const overflowEnds = harness.eventsOfType("compaction_end").filter((event) => event.reason === "overflow"); + const terminalOverflowFailures = overflowEnds.filter((event) => + event.errorMessage?.startsWith("Context overflow recovery failed after one compact-and-retry attempt"), + ); expect(overflowStarts).toHaveLength(2); + expect(overflowEnds).toHaveLength(2); expect(terminalOverflowFailures).toHaveLength(0); });