diff --git a/.agents/skills/senpi-qa/scripts/scenarios/compaction-absolute-cap-qa.mjs b/.agents/skills/senpi-qa/scripts/scenarios/compaction-absolute-cap-qa.mjs index 7fab7e588e..37247aaf18 100644 --- a/.agents/skills/senpi-qa/scripts/scenarios/compaction-absolute-cap-qa.mjs +++ b/.agents/skills/senpi-qa/scripts/scenarios/compaction-absolute-cap-qa.mjs @@ -5,8 +5,8 @@ * Drives the real senpi CLI from source over RPC with a local fake Anthropic * server and proves the post-#728 admission policy end to end: * - compactions past the former per-turn soft cap (3) are admitted and - * accepted up to the absolute session cap (10), - * - the 11th compaction is rejected with the absolute-session-cap message + * accepted up to the absolute runtime cap (10), + * - the 11th compaction is rejected with the absolute-runtime-cap message * (not the misleading per-turn wording), and * - the rejection is non-fatal: the session keeps serving prompts. * @@ -29,7 +29,8 @@ import { checkRealAuthUnchanged, hermeticEnv } from "../lib/mock-loop-support.mj const SUMMARY_MARKER = "context summarization assistant"; const ABSOLUTE_CAP = 10; const FORMER_SOFT_CAP = 3; -const REJECTION_NEEDLE = "absolute compaction cap reached for this session"; +const REJECTION_NEEDLE = + "Compaction rejected: the absolute compaction cap was reached for this runtime. Restart the CLI to resume this session, or start a new session."; const CONTEXT_WINDOW = 128_000; function flag(name) { @@ -249,7 +250,7 @@ async function main() { observed.rejection = { success: rejected.success, error: rejected.error ?? null }; checks.ok("compaction past the absolute cap is rejected", rejected.success === false); checks.ok( - "the rejection names the absolute session cap, not the per-turn cap", + "the rejection names the absolute runtime cap, not the per-turn cap", String(rejected.error ?? "").includes(REJECTION_NEEDLE), `error=${String(rejected.error ?? "").slice(0, 160)}`, ); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 49f38805a0..3b6d4bc23f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,11 @@ ### Fixed +- Required compaction now reports when the runtime's absolute compaction cap is exhausted and explains how to + recover by restarting the CLI before resuming the session or by starting a new session. Previously prompt + admission replaced that actionable rejection with the generic `compaction did not complete` error, while the + builtin extension described the in-memory cap as session-scoped. + ### New Features ### Breaking Changes diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index cf11887931..3f18860188 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -362,6 +362,10 @@ type PendingCompactionAdmission = { outcome?: "completed" | "failed" | "aborted"; }; +type RequiredCompactionRejectionCapture = { + rejectionCause?: CompactionRejectionCause; +}; + function isCompactionOwnedPreCompactDiagnostic(message: AgentMessage, requestId: string): boolean { if (message.role !== "custom" || message.customType !== "senpi.hook") return false; const details = message.details; @@ -388,8 +392,8 @@ function describeCompactionRejection(cause: CompactionRejectionCause): string { return "Compaction rejected: the compaction circuit breaker is open after repeated failures. Wait for the cooldown and retry."; case "per-turn-cap": // Historical cause identifier kept for extension-API stability; since the - // per-turn soft cap was removed it fires only at the absolute session cap. - return "Compaction rejected: absolute compaction cap reached for this session."; + // per-turn soft cap was removed it fires only at the absolute runtime cap. + return "Compaction rejected: the absolute compaction cap was reached for this runtime. Restart the CLI to resume this session, or start a new session."; case "stale-revision": return "Compaction rejected: the session changed while the summary was being prepared. Retry compaction against the latest context."; } @@ -446,8 +450,12 @@ function isCompactionExecutionAborted(error: unknown): boolean { } class RequiredCompactionError extends Error { - constructor() { - super("Context remains above the compaction threshold because compaction did not complete"); + constructor(rejectionCause?: CompactionRejectionCause) { + super( + rejectionCause === undefined + ? "Context remains above the compaction threshold because compaction did not complete" + : `Context remains above the compaction threshold because compaction did not complete. ${describeCompactionRejection(rejectionCause)}`, + ); this.name = "RequiredCompactionError"; } } @@ -633,7 +641,13 @@ export class AgentSession { // A retry continuation immediately follows an accepted compaction. Its first // response must not retrigger threshold compaction from stale provider usage. private _skipNextPostRetryCompactionCheck = false; - private _blockedPostCompactionAssistant: { assistant: AssistantMessage; revision: number } | undefined; + private _blockedPostCompactionAssistant: + | { + assistant: AssistantMessage; + revision: number; + rejectionCause?: CompactionRejectionCause; + } + | undefined; private _skipNextPostCompactionAssistantCheck = false; private _scheduledContinuationRecompacted = false; private readonly _assistantsPendingAtCompaction = new WeakSet(); @@ -1779,13 +1793,21 @@ export class AgentSession { this.settingsManager.getRetrySettings().enabled && (retryableError || hardErrorFallbackEligible); let compactedBeforeRetry = false; + const retryCompactionRejectionCapture: RequiredCompactionRejectionCapture = {}; if ( retryCanAdmitProvider && requiredAutoCompaction && !(requiredAutoCompaction === "threshold" && this._hasPendingPostCompactionUsageExemption(msg)) ) { this._retireFailedRetryAssistant(msg); - compactedBeforeRetry = await this._runPrePromptCompaction(msg, true, "threshold", true); + compactedBeforeRetry = await this._runPrePromptCompaction( + msg, + true, + "threshold", + true, + false, + retryCompactionRejectionCapture, + ); retryContinuationBlocked = !compactedBeforeRetry && !this._isCompactionDelegated(); } @@ -1819,7 +1841,13 @@ export class AgentSession { this._scheduleContinuationAfterCurrentEvent(); launchedContinuation = true; } else { - launchedContinuation = await this._checkCompaction(msg, true, undefined, retryAfterRequiredCompaction); + launchedContinuation = await this._checkCompaction( + msg, + true, + undefined, + retryAfterRequiredCompaction, + retryCompactionRejectionCapture, + ); if (launchedContinuation && this.agent.hasQueuedMessages()) { // Same supersession on the post-check path: an accepted recovery // compaction owns the continuation now. @@ -1838,7 +1866,9 @@ export class AgentSession { this.agent.hasQueuedMessages() && this._getRequiredAutoCompactionReason(msg) !== undefined ) { - this._requiredCompactionAdmissionError = new RequiredCompactionError(); + this._requiredCompactionAdmissionError = new RequiredCompactionError( + retryCompactionRejectionCapture.rejectionCause, + ); } } } @@ -4597,16 +4627,23 @@ export class AgentSession { blockedAdmission.assistant === assistantMessage && blockedAdmission.revision === this._messageRevision ) { - throw new RequiredCompactionError(); + throw new RequiredCompactionError(blockedAdmission.rejectionCause); } + const rejectionCapture: RequiredCompactionRejectionCapture = {}; const settings = this.settingsManager.getCompactionSettings(); const model = this.model; const contextTokens = estimateContextTokens( filterContextExcludedMessages(this.sessionManager.buildSessionContext().messages), ).tokens; const compacted = assistantMessage - ? await this._checkCompaction(assistantMessage, skipAbortedCheck, inlineReason, retryAfterCompaction) + ? await this._checkCompaction( + assistantMessage, + skipAbortedCheck, + inlineReason, + retryAfterCompaction, + rejectionCapture, + ) : false; if (compacted || (assistantMessage && this._postCompactionUsageExemptAssistants.has(assistantMessage))) { return compacted; @@ -4629,11 +4666,18 @@ export class AgentSession { return false; } if (assistantBeforeLatestCompaction && assistantMessage) { - const compacted = await this._runPrePromptCompaction(assistantMessage, skipAbortedCheck, inlineReason); + const compacted = await this._runPrePromptCompaction( + assistantMessage, + skipAbortedCheck, + inlineReason, + false, + false, + rejectionCapture, + ); if (compacted) return true; } if (this._isCompactionOnCooldown() || this._isCompactionDelegated()) return false; - throw new RequiredCompactionError(); + throw new RequiredCompactionError(rejectionCapture.rejectionCause); } /** @@ -4673,11 +4717,19 @@ export class AgentSession { throw new RequiredCompactionError(); } - const compacted = await this._runPrePromptCompaction(lastAssistantMessage, false, "pre_prompt"); + const rejectionCapture: RequiredCompactionRejectionCapture = {}; + const compacted = await this._runPrePromptCompaction( + lastAssistantMessage, + false, + "pre_prompt", + false, + false, + rejectionCapture, + ); if (!compacted && this._isCompactionDelegated()) return; if (!compacted && !isOversized() && this._isCompactionOnCooldown()) return; if (!compacted || isOversized()) { - throw new RequiredCompactionError(); + throw new RequiredCompactionError(rejectionCapture.rejectionCause); } } @@ -4686,6 +4738,7 @@ export class AgentSession { skipAbortedCheck = true, inlineReason?: "pre_prompt" | "threshold", retryAfterCompaction = false, + rejectionCapture: RequiredCompactionRejectionCapture = {}, ): Promise { const settings = this.settingsManager.getCompactionSettings(); if (!settings.enabled) return false; @@ -4738,7 +4791,7 @@ export class AgentSession { const willRetry = retryAfterCompaction || assistantMessage.stopReason !== "stop"; if (!willRetry) { - const compacted = await this._runAutoCompaction("overflow", false); + const compacted = await this._runAutoCompaction("overflow", false, rejectionCapture); if ( !compacted && this._compactionLifecycle.state.status === "failed" && @@ -4748,6 +4801,7 @@ export class AgentSession { this._blockedPostCompactionAssistant = { assistant: assistantMessage, revision: this._messageRevision, + rejectionCause: rejectionCapture.rejectionCause, }; } return compacted; @@ -4781,14 +4835,21 @@ export class AgentSession { this._incrementMessageRevision(); } const compacted = inlineReason - ? await this._runPrePromptCompaction(assistantMessage, skipAbortedCheck, "overflow", willRetry) - : await this._runAutoCompaction("overflow", willRetry); + ? await this._runPrePromptCompaction( + assistantMessage, + skipAbortedCheck, + "overflow", + willRetry, + false, + rejectionCapture, + ) + : await this._runAutoCompaction("overflow", willRetry, rejectionCapture); if (!compacted && removedOverflowAssistant) { this._restoreAgentMessagesFromSession(); this._incrementMessageRevision(); } if (!compacted && inlineReason && !this._isCompactionDelegated()) { - throw new RequiredCompactionError(); + throw new RequiredCompactionError(rejectionCapture.rejectionCause); } return compacted; } @@ -4838,9 +4899,11 @@ export class AgentSession { skipAbortedCheck, inlineReason, retryAfterCompaction, + false, + rejectionCapture, ); } else { - const compacted = await this._runAutoCompaction("threshold", retryAfterCompaction); + const compacted = await this._runAutoCompaction("threshold", retryAfterCompaction, rejectionCapture); if ( !compacted && this._compactionLifecycle.state.status === "failed" && @@ -4850,6 +4913,7 @@ export class AgentSession { this._blockedPostCompactionAssistant = { assistant: assistantMessage, revision: this._messageRevision, + rejectionCause: rejectionCapture.rejectionCause, }; } return compacted; @@ -4881,6 +4945,7 @@ export class AgentSession { reason: "pre_prompt" | "overflow" | "threshold" = "pre_prompt", willRetry = false, allowSummaryOnly = false, + rejectionCapture?: RequiredCompactionRejectionCapture, ): Promise { const controller = new AbortController(); const requestId = randomUUID(); @@ -4905,6 +4970,9 @@ export class AgentSession { ) { this._overflowRecoveryAttempted = false; } + if (!execution.accepted && execution.rejectionCause === "per-turn-cap") { + if (rejectionCapture) rejectionCapture.rejectionCause = execution.rejectionCause; + } return execution.accepted; } catch (error) { if (!compactionExecutionOwnsTerminalTransition(error)) { @@ -4946,10 +5014,18 @@ export class AgentSession { : estimate.tokens; if (!shouldCompact(contextTokens, model.contextWindow, settings)) return; - const compacted = await this._runPrePromptCompaction(this._findLastAssistantMessage(), true, "pre_prompt"); + const rejectionCapture: RequiredCompactionRejectionCapture = {}; + const compacted = await this._runPrePromptCompaction( + this._findLastAssistantMessage(), + true, + "pre_prompt", + false, + false, + rejectionCapture, + ); if (!compacted) { if (this._isCompactionOnCooldown() || this._isCompactionDelegated()) return; - throw new RequiredCompactionError(); + throw new RequiredCompactionError(rejectionCapture.rejectionCause); } this._scheduledContinuationRecompacted = true; } @@ -5046,7 +5122,11 @@ export class AgentSession { /** * Internal: Run auto-compaction with events. */ - private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise { + private async _runAutoCompaction( + reason: "overflow" | "threshold", + willRetry: boolean, + rejectionCapture?: RequiredCompactionRejectionCapture, + ): Promise { const finishCompactionWork = this._sessionWorkBarrier.begin(); const agentMessagesAtStart = this.agent.state.messages.slice(); const autoCompactionController = new AbortController(); @@ -5112,6 +5192,9 @@ export class AgentSession { }); if (!execution.accepted) { if (reason === "overflow") this._overflowRecoveryAttempted = false; + if (rejectionCapture && execution.rejectionCause === "per-turn-cap") { + rejectionCapture.rejectionCause = execution.rejectionCause; + } return false; } if (this._autoCompactionAbortController === autoCompactionController) { @@ -6302,7 +6385,15 @@ export class AgentSession { model && shouldCompact(contextTokens, model.contextWindow, compactionSettings) ) { - const preRetryCompaction = await this._runPrePromptCompaction(message, true, "threshold", true, true); + const rejectionCapture: RequiredCompactionRejectionCapture = {}; + const preRetryCompaction = await this._runPrePromptCompaction( + message, + true, + "threshold", + true, + true, + rejectionCapture, + ); if (!preRetryCompaction && !this._isCompactionOnCooldown() && !this._isCompactionDelegated()) { const attempt = this._retryAttempt; this._retryAttempt = 0; @@ -6311,7 +6402,7 @@ export class AgentSession { type: "auto_retry_end", success: false, attempt, - finalError: new RequiredCompactionError().message, + finalError: new RequiredCompactionError(rejectionCapture.rejectionCause).message, }); this._resolveRetry(); return "blocked"; diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index c24fcbd150..67e3310c94 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -1,5 +1,31 @@ # changes +## Explain absolute compaction-cap recovery (2026-08-14) + +### What changed + +- Required pre-prompt compaction now preserves the actionable `per-turn-cap` rejection instead of replacing it with + the generic required-compaction error. +- The absolute cap message identifies the runtime-scoped limit and tells users to restart the CLI to resume the + session or start a new session. +- Coverage: `test/suite/regressions/pre-prompt-compaction-no-continue.test.ts`. + +### Why + +- Once a long-lived runtime accepted ten compactions, the next prompt was correctly blocked but only reported that + compaction did not complete. The real cap reason and recovery path were visible only in debug logs, so restarting + and typing into the resumed session looked like a hang while recovery work continued. + +### Why an extension could not implement this + +- The builtin extension can report why its compaction request was rejected, but only core owns the subsequent + provider-admission error. Core must carry the request-specific rejection cause into `RequiredCompactionError` + instead of replacing it after the extension returns. + +### Expected merge conflict zones + +- LOW: `agent-session.ts` required-compaction error formatting and pre-prompt rejection handling. + ## Admit provider-owned compaction lanes (2026-08-14) ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md b/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md index b124a398ff..272e3787af 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md @@ -1,5 +1,28 @@ # Builtin compaction extension changes +## Explain the runtime absolute-cap recovery path (2026-08-14) + +### What changed + +- The builtin `per-turn-cap` rejection now identifies the absolute cap as runtime-scoped and tells users to restart + the CLI before resuming the session or start a new session. +- The real-CLI absolute-cap QA scenario now pins the same recovery text. + +### Why + +- `acceptedAbsolute` belongs to the builtin extension's in-memory state and is recreated when the CLI restarts. The + previous "for this session" wording hid the available recovery path and made a blocked resumed prompt look hung. + +### Why an extension could not do this + +- This extension owns the absolute-cap counter and therefore owns the accurate scope and recovery wording. Core + separately preserves its structured `per-turn-cap` cause for the provider-admission error. + +### Expected merge-conflict zones + +- LOW: `index.ts`, in the absolute-cap branch of `session_before_compact`. +- LOW: `.agents/skills/senpi-qa/scripts/scenarios/compaction-absolute-cap-qa.mjs`, around the rejection needle. + ## Report provider-owned compaction as delegated (2026-08-14) ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts index 2be1f632b6..e1a56f16b6 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts @@ -560,7 +560,8 @@ export default function compactionExtension( return { cancel: true, rejectionCause: "per-turn-cap", - reason: "absolute compaction cap reached for this session", + reason: + "the absolute compaction cap was reached for this runtime. Restart the CLI to resume this session, or start a new session.", }; } const now = Date.now(); 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 5aeaeb8b36..16bda4760b 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -1726,7 +1726,7 @@ describe("AgentSession compaction characterization", () => { await checkCompaction(harness.session, overflowMessage); - expect(runAutoCompactionSpy).toHaveBeenCalledWith("overflow", true); + expect(runAutoCompactionSpy).toHaveBeenCalledWith("overflow", true, expect.any(Object)); }); it("compacts successful overflow responses without retrying", async () => { @@ -1821,7 +1821,7 @@ describe("AgentSession compaction characterization", () => { await checkCompaction(harness.session, errorAssistant); - expect(runAutoCompactionSpy).toHaveBeenCalledWith("threshold", false); + expect(runAutoCompactionSpy).toHaveBeenCalledWith("threshold", false, expect.any(Object)); }); it("does not trigger threshold compaction for error messages when no prior usage exists", async () => { diff --git a/packages/coding-agent/test/suite/regressions/compaction-rejection-feedback.test.ts b/packages/coding-agent/test/suite/regressions/compaction-rejection-feedback.test.ts index 0fd14b9614..20e7cae5a7 100644 --- a/packages/coding-agent/test/suite/regressions/compaction-rejection-feedback.test.ts +++ b/packages/coding-agent/test/suite/regressions/compaction-rejection-feedback.test.ts @@ -94,7 +94,8 @@ describe("Regression: manual /compact silent rejection", () => { pi.on("session_before_compact", async () => ({ cancel: true, rejectionCause: "per-turn-cap" as const, - reason: "absolute compaction cap reached for this session", + reason: + "the absolute compaction cap was reached for this runtime. Restart the CLI to resume this session, or start a new session.", })); }, ], @@ -108,7 +109,9 @@ describe("Regression: manual /compact silent rejection", () => { expect(compactionEnd).toBeDefined(); expect(compactionEnd?.accepted).toBe(false); expect(compactionEnd?.rejectionCause).toBe("per-turn-cap"); - expect(compactionEnd?.errorMessage ?? "").toContain("absolute compaction cap reached for this session"); + expect(compactionEnd?.errorMessage ?? "").toContain( + "the absolute compaction cap was reached for this runtime. Restart the CLI to resume this session, or start a new session.", + ); }); }); diff --git a/packages/coding-agent/test/suite/regressions/context-overflow-model-alias.test.ts b/packages/coding-agent/test/suite/regressions/context-overflow-model-alias.test.ts index bb5dcd65cc..f59239e1a6 100644 --- a/packages/coding-agent/test/suite/regressions/context-overflow-model-alias.test.ts +++ b/packages/coding-agent/test/suite/regressions/context-overflow-model-alias.test.ts @@ -81,7 +81,7 @@ describe("context overflow recovery", () => { await getCheckCompaction(harness.session)(overflowMessage); - expect(runAutoCompactionSpy).toHaveBeenCalledWith("overflow", true); + expect(runAutoCompactionSpy).toHaveBeenCalledWith("overflow", true, expect.any(Object)); }); it("does not auto-compact unrelated same-provider model overflow below the threshold", async () => { diff --git a/packages/coding-agent/test/suite/regressions/pre-prompt-compaction-no-continue.test.ts b/packages/coding-agent/test/suite/regressions/pre-prompt-compaction-no-continue.test.ts index 9ddaed336a..bc601c4d1e 100644 --- a/packages/coding-agent/test/suite/regressions/pre-prompt-compaction-no-continue.test.ts +++ b/packages/coding-agent/test/suite/regressions/pre-prompt-compaction-no-continue.test.ts @@ -2,6 +2,9 @@ import { type AssistantMessage, fauxAssistantMessage } from "@earendil-works/pi- import { afterEach, describe, expect, it, vi } from "vitest"; import { createHarness, getAssistantTexts, getUserTexts, type Harness } from "../harness.ts"; +const EXPECTED_CAP_RECOVERY = + "Compaction rejected: the absolute compaction cap was reached for this runtime. Restart the CLI to resume this session, or start a new session."; + function createUsage(totalTokens: number) { return { input: totalTokens, @@ -152,6 +155,73 @@ describe("pre-prompt compaction regression", () => { ); }); + it("explains how to recover when the runtime compaction cap blocks the next provider call", async () => { + const harness = await createHarness({ + models: [{ id: "faux-1", contextWindow: 10_000, maxTokens: 1_000 }], + settings: { compaction: { enabled: true, keepRecentTokens: 1, reserveTokens: 1_000 } }, + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", async () => ({ + cancel: true, + rejectionCause: "per-turn-cap", + reason: + "the absolute compaction cap was reached for this runtime. Restart the CLI to resume this session, or start a new session.", + })); + }, + ], + }); + harnesses.push(harness); + + const now = Date.now(); + const model = harness.getModel(); + harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "earlier prompt" }], + timestamp: now - 3000, + }); + harness.sessionManager.appendMessage({ + ...fauxAssistantMessage("earlier response", { timestamp: now - 2000 }), + api: model.api, + provider: model.provider, + model: model.id, + usage: createUsage(50), + }); + harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "previous prompt" }], + timestamp: now - 1000, + }); + const overflowAssistant: AssistantMessage = { + ...fauxAssistantMessage("", { + stopReason: "error", + errorMessage: "context_length_exceeded", + timestamp: now - 500, + }), + api: model.api, + provider: model.provider, + model: model.id, + usage: createUsage(100), + }; + harness.sessionManager.appendMessage(overflowAssistant); + harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages; + harness.setResponses([fauxAssistantMessage("must not reach provider")]); + + await expect(harness.session.prompt("next prompt")).rejects.toThrow(EXPECTED_CAP_RECOVERY); + await expect(harness.session.prompt("retry prompt")).rejects.toThrow(EXPECTED_CAP_RECOVERY); + + expect(harness.faux.state.callCount).toBe(0); + expect(getUserTexts(harness)).not.toContain("next prompt"); + expect(getUserTexts(harness)).not.toContain("retry prompt"); + expect(harness.eventsOfType("compaction_end").filter((event) => event.accepted === false)).toHaveLength(2); + expect(harness.eventsOfType("compaction_end")).toContainEqual( + expect.objectContaining({ + reason: "overflow", + accepted: false, + rejectionCause: "per-turn-cap", + }), + ); + }); + it("compacts upstream model alias overflow before a dot retry", async () => { const harness = await createHarness({ api: "openai-responses", @@ -281,8 +351,9 @@ describe("pre-prompt compaction regression", () => { (pi) => { pi.on("session_before_compact", async () => ({ cancel: true, - rejectionCause: "cancelled-by-extension", - reason: "required recovery rejected", + rejectionCause: "per-turn-cap", + reason: + "the absolute compaction cap was reached for this runtime. Restart the CLI to resume this session, or start a new session.", })); }, ], @@ -309,23 +380,19 @@ describe("pre-prompt compaction regression", () => { await harness.session.followUp("retain native follow-up"); releaseProvider.resolve(); - await expect(initialPrompt).rejects.toThrow( - "Context remains above the compaction threshold because compaction did not complete", - ); + await expect(initialPrompt).rejects.toThrow(EXPECTED_CAP_RECOVERY); expect(harness.faux.state.callCount).toBe(1); expect(harness.session.getSteeringMessages()).toEqual(["retain native steer"]); expect(harness.session.getFollowUpMessages()).toEqual(["retain native follow-up"]); expect(harness.session.agent.hasQueuedMessages()).toBe(true); - await expect(harness.session.prompt("later normal admission")).rejects.toThrow( - "Context remains above the compaction threshold because compaction did not complete", - ); + await expect(harness.session.prompt("later normal admission")).rejects.toThrow(EXPECTED_CAP_RECOVERY); await expect( harness.session.sendCustomMessage( { customType: "extension-note", content: "later custom admission", display: true }, { triggerTurn: true }, ), - ).rejects.toThrow("Context remains above the compaction threshold because compaction did not complete"); + ).rejects.toThrow(EXPECTED_CAP_RECOVERY); expect(harness.faux.state.callCount).toBe(1); expect(harness.session.getSteeringMessages()).toEqual(["retain native steer"]); @@ -828,8 +895,9 @@ describe("pre-prompt compaction regression", () => { compactionRequests++; return { cancel: true, - rejectionCause: "cancelled-by-extension", - reason: "reject follow-up compaction", + rejectionCause: "per-turn-cap", + reason: + "the absolute compaction cap was reached for this runtime. Restart the CLI to resume this session, or start a new session.", }; }); }, @@ -894,13 +962,9 @@ describe("pre-prompt compaction regression", () => { ); expect(normalError).toBeInstanceOf(Error); - expect((normalError as Error).message).toContain( - "Context remains above the compaction threshold because compaction did not complete", - ); + expect((normalError as Error).message).toContain(EXPECTED_CAP_RECOVERY); expect(customError).toBeInstanceOf(Error); - expect((customError as Error).message).toContain( - "Context remains above the compaction threshold because compaction did not complete", - ); + expect((customError as Error).message).toContain(EXPECTED_CAP_RECOVERY); expect(compactionRequests).toBe(2); expect(harness.faux.state.callCount).toBe(0); expect(harness.session.messages).toContainEqual(