From 6534279002643100e0d93ffed813deea29c3256f Mon Sep 17 00:00:00 2001 From: sigridjineth Date: Mon, 7 Sep 2026 11:02:20 +0900 Subject: [PATCH 1/8] fix(compaction): allow manual recovery on SDK-owned sessions --- .../extensions/builtin/compaction/changes.md | 18 ++++ .../extensions/builtin/compaction/index.ts | 2 +- ...ude-sdk-oauth-compaction-alignment.test.ts | 28 +++--- .../sdk-manual-compaction-recovery.test.ts | 97 +++++++++++++++++++ 4 files changed, 133 insertions(+), 12 deletions(-) create mode 100644 packages/coding-agent/test/suite/sdk-manual-compaction-recovery.test.ts 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 8ec10e71fe..c684d9c0cc 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,23 @@ # changes.md — builtin compaction policy +## Allow explicit manual compaction on SDK-owned automatic lanes (2026-09-07) + +### What changed + +- `packages/coding-agent/src/core/extensions/builtin/compaction/index.ts` exempts manual requests from the SDK-native `session_before_compact` cancellation. Automatic threshold, overflow, pre-prompt, and speculative ownership remain SDK-owned. + +### Why + +- A rejected downsizing leaves the larger model selected so the user can compact first. Cancelling explicit `/compact` with `external-owner` blocked that recovery; manual requests must reach the existing summary generation and persistence path without weakening model admission. + +### Why an extension could not handle it + +- The cancellation is owned by this builtin hook. Another extension cannot safely undo its rejection or replace the coordinated compaction lifecycle. + +### Expected merge conflict zones + +- LOW: `packages/coding-agent/src/core/extensions/builtin/compaction/index.ts` around the SDK-native lane guard in `session_before_compact`. + ## Omit speculation lead from resumed-session admission (2026-09-03) ### 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 f1b73a0f80..b8338fbfc4 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts @@ -556,7 +556,7 @@ export default function compactionExtension( let warmJobConsumed = false; invalidateSpeculativeCompaction(ctx); try { - if (lanePolicy.disablesSenpiCompaction(ctx)) { + if (event.reason !== "manual" && lanePolicy.disablesSenpiCompaction(ctx)) { return { cancel: true, rejectionCause: "external-owner", diff --git a/packages/coding-agent/test/claude-sdk-oauth-compaction-alignment.test.ts b/packages/coding-agent/test/claude-sdk-oauth-compaction-alignment.test.ts index 5cec021c90..d37fd06f45 100644 --- a/packages/coding-agent/test/claude-sdk-oauth-compaction-alignment.test.ts +++ b/packages/coding-agent/test/claude-sdk-oauth-compaction-alignment.test.ts @@ -215,17 +215,23 @@ describe("claude-sdk-oauth lane: senpi compaction stands down", () => { expect(harness.registration.state.callCount).toBe(0); }); - it("cancels a requested senpi compaction with the lane reason", async () => { - const harness = createHarness({ provider: "claude-sdk-oauth" }); - - const result = await harness.sessionBeforeCompact(beforeCompactEvent(), harness.ctx); - - expect(result).toMatchObject({ - cancel: true, - reason: SDK_NATIVE_LANE_REJECTION_REASON, - rejectionCause: "external-owner", - }); - }); + it.each(["threshold", "overflow", "pre_prompt"] as const)( + "cancels automatic %s compaction with the lane reason", + async (reason) => { + const harness = createHarness({ provider: "claude-sdk-oauth" }); + + const result = await harness.sessionBeforeCompact( + { ...beforeCompactEvent(), reason, willRetry: reason === "overflow" }, + harness.ctx, + ); + + expect(result).toMatchObject({ + cancel: true, + reason: SDK_NATIVE_LANE_REJECTION_REASON, + rejectionCause: "external-owner", + }); + }, + ); it("leaves context messages untouched while the same load reduces them for other providers", () => { const reductionMessages = () => [ diff --git a/packages/coding-agent/test/suite/sdk-manual-compaction-recovery.test.ts b/packages/coding-agent/test/suite/sdk-manual-compaction-recovery.test.ts new file mode 100644 index 0000000000..79c1d43c71 --- /dev/null +++ b/packages/coding-agent/test/suite/sdk-manual-compaction-recovery.test.ts @@ -0,0 +1,97 @@ +import { fauxAssistantMessage } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import compactionExtension from "../../src/core/extensions/builtin/compaction/index.ts"; +import { ModelUsabilityBudgetError } from "../../src/core/extensions/builtin/compaction/model-usability-budget.ts"; +import { SessionManager } from "../../src/core/session-manager.ts"; +import { createHarness, type Harness } from "./harness.ts"; + +const harnesses: Harness[] = []; +afterEach(() => { + for (const harness of harnesses.splice(0)) harness.cleanup(); + vi.restoreAllMocks(); +}); + +describe("explicit compaction recovers a rejected model downswitch", () => { + it.each(["claude-sdk-oauth", "faux"])("persists a usable manual summary on %s", async (provider) => { + // Given the real builtin, no compaction-model override, and an oversized live transcript. + vi.spyOn(Date, "now").mockReturnValue(1_800_000_000_000); + const harness = await createHarness({ + provider, + models: [ + { id: "million", contextWindow: 1_000_000, maxTokens: 32_000 }, + { id: "target", contextWindow: 272_000, maxTokens: 32_000 }, + ], + settings: { compaction: { enabled: true, keepRecentTokens: 1 } }, + extensionFactories: [compactionExtension], + persistSession: true, + }); + harnesses.push(harness); + const source = harness.getModel(); + const target = harness.getModel("target"); + if (!target) throw new Error("missing target fixture"); + harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "historical context ".repeat(30_000) }], + timestamp: 1, + }); + harness.sessionManager.appendMessage({ + ...fauxAssistantMessage("historical response", { timestamp: 2 }), + api: source.api, + provider, + model: source.id, + usage: { + input: 845_096, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 845_096, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }); + const keptEntryId = harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "KEEP_AFTER_COMPACTION" }], + timestamp: 3, + }); + harness.agent.state.messages = harness.sessionManager.buildSessionContext().messages; + await expect(harness.session.setModel(target)).rejects.toMatchObject({ + name: ModelUsabilityBudgetError.name, + projection: { usable: false, contextWindow: 272_000 }, + }); + expect(harness.session.model?.id).toBe("million"); + expect(harness.sessionManager.getEntries().filter((entry) => entry.type === "model_change")).toEqual([]); + const summary = "MANUAL_COMPACTION_RECOVERY_SUMMARY"; + harness.setResponses([fauxAssistantMessage(summary, { timestamp: 4 })]); + + // When explicit /compact's session entry point runs, the harness is already subscribed + // to lifecycle events. Await its terminal promise, not elapsed time or polling. + const outcome = await harness.session.compact().then( + (result) => ({ status: "compacted" as const, result }), + (error: unknown) => ({ status: "rejected" as const, error }), + ); + + // Then cancellation is not recovery: require an actual result and persisted usable history. + expect(outcome, JSON.stringify(harness.eventsOfType("compaction_end"))).toMatchObject({ status: "compacted" }); + if (outcome.status !== "compacted") throw outcome.error; + expect(outcome.result).toMatchObject({ summary, firstKeptEntryId: keptEntryId }); + expect(harness.eventsOfType("compaction_end")).toEqual([ + expect.objectContaining({ reason: "manual", accepted: true, aborted: false }), + ]); + const sessionFile = harness.sessionManager.getSessionFile(); + if (!sessionFile) throw new Error("missing persisted session fixture"); + const reopened = SessionManager.open(sessionFile); + expect(reopened.getEntries().filter((entry) => entry.type === "compaction")).toEqual([ + expect.objectContaining({ summary, firstKeptEntryId: keptEntryId }), + ]); + expect(reopened.buildSessionContext().messages).toEqual(harness.session.messages); + expect(harness.session.messages).toEqual([ + expect.objectContaining({ role: "compactionSummary", summary }), + expect.objectContaining({ role: "user", content: [{ type: "text", text: "KEEP_AFTER_COMPACTION" }] }), + ]); + await harness.session.setModel(target); + expect(harness.session.model?.id).toBe("target"); + expect(harness.sessionManager.getEntries().filter((entry) => entry.type === "model_change")).toEqual([ + expect.objectContaining({ provider, modelId: "target" }), + ]); + }); +}); From 6560c99212551aded486896f7c203db0a1ef50ab Mon Sep 17 00:00:00 2001 From: sigridjineth Date: Mon, 7 Sep 2026 11:44:38 +0900 Subject: [PATCH 2/8] docs(coding-agent): document SDK compaction recovery --- packages/coding-agent/CHANGELOG.md | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 611baa29d9..61e656db4d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,26 +12,9 @@ ### Fixed -- A plain `--session ` resume on the `claude-sdk-oauth` provider no longer re-sends the whole conversation with `Session continuity lost (options_changed)` after a restart (code-yeongyu/oh-my-openagent#7884). A restored session binding whose system prompt or toolset fingerprint drifted - an engine upgrade, a prompt-content change, or the UTC date rolling over - now reattaches to the existing SDK session and sends only the new turn, the same way a live session already did; only an account or model identity change still cold-seeds. The continuity observation names which half drifted (`system_prompt_changed` / `toolset_changed`) instead of the bare `options_changed`. +- A plain `--session ` resume on the `claude-sdk-oauth` provider no longer re-sends the whole conversation with `Session continuity lost (options_changed)` after a restart (code-yeongyu/oh-my-openagent#7884). A restored session binding whose system prompt or toolset fingerprint drifted - an engine upgrade, a prompt-content change, or the UTC date rolling over - now reattaches to the existing SDK session and sends only the new turn, the same way a live session already did; only an account or model identity change still cold-seeds. The continuity observation names which half drifted (`system_prompt_changed` / `toolset_changed`) instead of the bare `options_changed`). - The `Current date:` line is normalized out of the `claude-sdk-oauth` prompt fingerprint even when extension prompt sections follow the `Current working directory:` line, which is the shape every real session has. Previously the normalization only matched when that line ended the prompt, so the fingerprint changed at every UTC midnight. - -### Removed - -## [2026.9.7] - 2026-09-07 - -### Breaking Changes - -### Added - -### Changed - -### Fixed - -- Context overflow is failure-proof again (#1422). `compaction.enabled=false` now switches off only proactive threshold compaction: a turn the provider rejected as a context overflow (or a zero-output `length` stop that filled the window) still gets its one-shot compact-and-retry recovery instead of leaving the session with no automatic way forward. -- The RPC `set_auto_compaction` command is session-scoped: it no longer rewrites the persisted global `compaction.enabled` setting, so one OmO Desktop thread toggling auto-compaction cannot disable it for every other session on the machine. The interactive `/settings` toggle still persists. -- A goal no longer re-prompts a context that the provider just rejected as too large; every automatic continuation path now blocks mechanically with `context overflow ended the turn (compaction did not recover)`, and the next user message resumes it. Ordinary provider errors keep their single recovery continuation. -- The OpenAI input-cap rule now covers every provider that serves a GPT-5.x/GPT-6 model (Amazon Bedrock, Azure, GitHub Copilot, OpenRouter, Vercel AI Gateway, OpenGateway, Cloudflare AI Gateway, OpenCode): 128 catalog rows move from the 400,000/1,050,000 totals to the 272,000/922,000 prompt budgets (luna, terra, sol, astra and the pro models included), and `gpt-5-pro` reports its documented 128,000 max output instead of the mirrored 272,000. A catalog test now fails if any such row carries a total window again. -- OpenAI catalog `contextWindow` values now store the documented prompt budget: 922,000 for the 1,050,000-token tier (`gpt-6-astra`, `gpt-5.4-pro`, `gpt-5.5-pro`, the Azure flagship deployments) and 272,000 for the 400,000-token tier (`gpt-5` through `gpt-5.4-nano`). OpenAI rejects a request with `context_too_large` once the prompt alone exceeds window minus max output, so the previous totals let sessions run past the point where compaction could still help. +- Explicit `/compact` now works on Claude SDK-owned sessions after a rejected smaller-model switch, while automatic SDK compaction ownership remains unchanged ([#1423](https://github.com/code-yeongyu/senpi/pull/1423) by [@realsigridjin](https://github.com/realsigridjin/senpi)).) - The permission system's external-directory check no longer freezes the whole session when a `bash` or `monitor` command mentions a path such as `/home/user/...`: path normalization now resolves symlinks with `lstat`/`readlink` per component instead of `fs.realpathSync`, which under Bun `open(2)`s every directory it resolves and blocks forever on an autofs trigger (macOS `/home`) or misclassifies files under execute-only directories as external. ### Removed From ad4296788924af2aebdac9ce1815532dac753633 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 7 Sep 2026 13:14:49 +0900 Subject: [PATCH 3/8] fix(compaction): keep manual SDK failures observable --- .../src/core/extensions/builtin/compaction/changes.md | 4 +++- .../core/extensions/builtin/compaction/circuit-breaker.ts | 5 ++--- .../src/core/extensions/builtin/compaction/index.ts | 4 ++-- .../src/core/extensions/builtin/compaction/lane-policy.ts | 5 +++++ 4 files changed, 12 insertions(+), 6 deletions(-) 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 c684d9c0cc..c1ceb8fd29 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md @@ -4,7 +4,9 @@ ### What changed -- `packages/coding-agent/src/core/extensions/builtin/compaction/index.ts` exempts manual requests from the SDK-native `session_before_compact` cancellation. Automatic threshold, overflow, pre-prompt, and speculative ownership remain SDK-owned. +- `lane-policy.ts` exposes one reason-aware `ownsCompaction` predicate: manual requests are senpi-owned for recovery even on SDK-native lanes; automatic threshold, overflow, pre-prompt, and speculative routes remain SDK-owned. The predicate is used for the before-compact admission and failure-accounting sites; the message-end degradation site is automatic-only and remains unchanged. +- Failed manual compactions are recorded by the circuit breaker, including on SDK-native lanes; manual requests no longer bypass the breaker. +- Concurrent SDK/native and senpi work is protected by the existing speculative generation and message-revision checks before generated results are applied. ### Why diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/circuit-breaker.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/circuit-breaker.ts index 81c408c0a1..1ba6558444 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/circuit-breaker.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/circuit-breaker.ts @@ -54,8 +54,7 @@ export function isTripped(state: CompactionExtensionState, now: number): boolean return state.trippedAt !== null && now < state.trippedAt + COOLDOWN_MS; } -export function shouldBypass(_state: CompactionExtensionState, opts?: ShouldBypassOptions): boolean { - if (opts?.manual === true) return true; - if (opts?.reason === "manual") return true; +export function shouldBypass(_state: CompactionExtensionState, _opts?: ShouldBypassOptions): boolean { + // Manual recovery is still subject to the breaker: repeated failures must trip it. return false; } 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 b8338fbfc4..f323865e52 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts @@ -556,7 +556,7 @@ export default function compactionExtension( let warmJobConsumed = false; invalidateSpeculativeCompaction(ctx); try { - if (event.reason !== "manual" && lanePolicy.disablesSenpiCompaction(ctx)) { + if (!lanePolicy.ownsCompaction(ctx, event.reason)) { return { cancel: true, rejectionCause: "external-owner", @@ -763,7 +763,7 @@ export default function compactionExtension( return; } if (compactEvent.rejectionCause === "external-owner") return; - if (!lanePolicy.disablesSenpiCompaction(ctx)) { + if (lanePolicy.ownsCompaction(ctx, compactEvent.reason)) { state = breaker.recordFailure(state, Date.now(), { route: compactEvent.reason }); } }); diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/lane-policy.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/lane-policy.ts index dd4e6ddf92..06f065d7bf 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/lane-policy.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/lane-policy.ts @@ -57,6 +57,8 @@ export interface LaneContext { export interface CompactionLanePolicy { /** True when the SDK owns this lane's context and senpi compaction must stand down. */ disablesSenpiCompaction(context: LaneContext): boolean; + /** Manual requests are explicitly owned by senpi for recovery, even on SDK lanes. */ + ownsCompaction(context: LaneContext, reason: "manual" | string): boolean; } export interface CompactBoundaryEntry { @@ -91,6 +93,9 @@ export function createCompactionLanePolicy( let cachedCwd: string | undefined; let cachedResumeMode: string | undefined; return { + ownsCompaction(context: LaneContext, reason: "manual" | string): boolean { + return reason === "manual" || !this.disablesSenpiCompaction(context); + }, disablesSenpiCompaction(context: LaneContext): boolean { if (context.model?.provider !== CLAUDE_SDK_OAUTH_PROVIDER_ID) return false; // A configured compaction model override makes senpi own summarization From 261662a2c31879066785e55a745c1af933b62fba Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 7 Sep 2026 13:17:19 +0900 Subject: [PATCH 4/8] test(compaction): cover manual breaker protection --- .../test/compaction/circuit-breaker.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/test/compaction/circuit-breaker.test.ts b/packages/coding-agent/test/compaction/circuit-breaker.test.ts index cc480a2492..4d34aad134 100644 --- a/packages/coding-agent/test/compaction/circuit-breaker.test.ts +++ b/packages/coding-agent/test/compaction/circuit-breaker.test.ts @@ -130,16 +130,16 @@ describe("compaction circuit breaker", () => { }); describe("Given the breaker is tripped", () => { - describe("When manual /compact bypasses breaker", () => { - it("Then the manual route proceeds without circuit_breaker cancellation", () => { + describe("When manual /compact reaches the breaker", () => { + it("Then the manual route is cancelled while the breaker is tripped", () => { const tripped: FutureBreakerState = { consecutiveFailures: TRIP_THRESHOLD, trippedAt: 0 }; const bypassed = shouldBypassFuture(tripped, { manual: true }); const manualDecision = evaluateAutoCompact(tripped, COOLDOWN_MS / 2, { manual: true }); - expect(bypassed).toBe(true); - expect(manualDecision.cancel).toBe(false); - expect(manualDecision.reason).toBeUndefined(); + expect(bypassed).toBe(false); + expect(manualDecision.cancel).toBe(true); + expect(manualDecision.reason).toBe(CIRCUIT_BREAKER_REASON); }); }); }); From b58311ce64ecbb2d9d5b7a656463c2791a9c355f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 7 Sep 2026 13:24:38 +0900 Subject: [PATCH 5/8] docs(coding-agent): restore changelog entries dropped in the rebase The rebase resolution removed the released 2026.9.7 section and this PR's own entry, and left a stray parenthesis in the restart-continuity entry. Restore the file from main and re-add the #1423 entry under Unreleased. --- packages/coding-agent/CHANGELOG.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 61e656db4d..8d3aa7e94f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,9 +12,27 @@ ### Fixed -- A plain `--session ` resume on the `claude-sdk-oauth` provider no longer re-sends the whole conversation with `Session continuity lost (options_changed)` after a restart (code-yeongyu/oh-my-openagent#7884). A restored session binding whose system prompt or toolset fingerprint drifted - an engine upgrade, a prompt-content change, or the UTC date rolling over - now reattaches to the existing SDK session and sends only the new turn, the same way a live session already did; only an account or model identity change still cold-seeds. The continuity observation names which half drifted (`system_prompt_changed` / `toolset_changed`) instead of the bare `options_changed`). +- Explicit `/compact` now works on Claude SDK-owned sessions after a rejected smaller-model switch, so the recommended recovery from a failed downswitch is usable again ([#1423](https://github.com/code-yeongyu/senpi/pull/1423) by [@realsigridjin](https://github.com/realsigridjin)). Automatic threshold, overflow, pre-prompt, and speculative compaction stay SDK-owned, and failed manual attempts are now recorded by the compaction circuit breaker instead of being invisible to it. +- A plain `--session ` resume on the `claude-sdk-oauth` provider no longer re-sends the whole conversation with `Session continuity lost (options_changed)` after a restart (code-yeongyu/oh-my-openagent#7884). A restored session binding whose system prompt or toolset fingerprint drifted - an engine upgrade, a prompt-content change, or the UTC date rolling over - now reattaches to the existing SDK session and sends only the new turn, the same way a live session already did; only an account or model identity change still cold-seeds. The continuity observation names which half drifted (`system_prompt_changed` / `toolset_changed`) instead of the bare `options_changed`. - The `Current date:` line is normalized out of the `claude-sdk-oauth` prompt fingerprint even when extension prompt sections follow the `Current working directory:` line, which is the shape every real session has. Previously the normalization only matched when that line ended the prompt, so the fingerprint changed at every UTC midnight. -- Explicit `/compact` now works on Claude SDK-owned sessions after a rejected smaller-model switch, while automatic SDK compaction ownership remains unchanged ([#1423](https://github.com/code-yeongyu/senpi/pull/1423) by [@realsigridjin](https://github.com/realsigridjin/senpi)).) + +### Removed + +## [2026.9.7] - 2026-09-07 + +### Breaking Changes + +### Added + +### Changed + +### Fixed + +- Context overflow is failure-proof again (#1422). `compaction.enabled=false` now switches off only proactive threshold compaction: a turn the provider rejected as a context overflow (or a zero-output `length` stop that filled the window) still gets its one-shot compact-and-retry recovery instead of leaving the session with no automatic way forward. +- The RPC `set_auto_compaction` command is session-scoped: it no longer rewrites the persisted global `compaction.enabled` setting, so one OmO Desktop thread toggling auto-compaction cannot disable it for every other session on the machine. The interactive `/settings` toggle still persists. +- A goal no longer re-prompts a context that the provider just rejected as too large; every automatic continuation path now blocks mechanically with `context overflow ended the turn (compaction did not recover)`, and the next user message resumes it. Ordinary provider errors keep their single recovery continuation. +- The OpenAI input-cap rule now covers every provider that serves a GPT-5.x/GPT-6 model (Amazon Bedrock, Azure, GitHub Copilot, OpenRouter, Vercel AI Gateway, OpenGateway, Cloudflare AI Gateway, OpenCode): 128 catalog rows move from the 400,000/1,050,000 totals to the 272,000/922,000 prompt budgets (luna, terra, sol, astra and the pro models included), and `gpt-5-pro` reports its documented 128,000 max output instead of the mirrored 272,000. A catalog test now fails if any such row carries a total window again. +- OpenAI catalog `contextWindow` values now store the documented prompt budget: 922,000 for the 1,050,000-token tier (`gpt-6-astra`, `gpt-5.4-pro`, `gpt-5.5-pro`, the Azure flagship deployments) and 272,000 for the 400,000-token tier (`gpt-5` through `gpt-5.4-nano`). OpenAI rejects a request with `context_too_large` once the prompt alone exceeds window minus max output, so the previous totals let sessions run past the point where compaction could still help. - The permission system's external-directory check no longer freezes the whole session when a `bash` or `monitor` command mentions a path such as `/home/user/...`: path normalization now resolves symlinks with `lstat`/`readlink` per component instead of `fs.realpathSync`, which under Bun `open(2)`s every directory it resolves and blocks forever on an autofs trigger (macOS `/home`) or misclassifies files under execute-only directories as external. ### Removed From 291fa3a044e6c3daf6f068c01cbe690e45623f91 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 7 Sep 2026 13:25:11 +0900 Subject: [PATCH 6/8] fix(compaction): keep manual /compact usable while the breaker is tripped A tripped breaker must halt automatic compaction only. Refusing an explicit /compact during the cooldown strands the very session this recovery exists for, since a rejected downswitch tells the user to compact and retry. Manual failures are still recorded through the reason-aware ownership predicate, so they keep counting toward the trip that protects the automatic routes. --- .../builtin/compaction/circuit-breaker.ts | 13 +++++++++++-- .../test/compaction/circuit-breaker.test.ts | 10 +++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/circuit-breaker.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/circuit-breaker.ts index 1ba6558444..224ab0512a 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/circuit-breaker.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/circuit-breaker.ts @@ -54,7 +54,16 @@ export function isTripped(state: CompactionExtensionState, now: number): boolean return state.trippedAt !== null && now < state.trippedAt + COOLDOWN_MS; } -export function shouldBypass(_state: CompactionExtensionState, _opts?: ShouldBypassOptions): boolean { - // Manual recovery is still subject to the breaker: repeated failures must trip it. +/** + * A tripped breaker halts *automatic* compaction only. An explicit `/compact` is the + * user's escape hatch - and on an SDK-owned lane it is the documented recovery from a + * rejected model downswitch - so refusing it during the cooldown would strand the very + * session the recovery exists for. Manual failures are still recorded (see the + * `ownsCompaction` failure-accounting site in `index.ts`), so they count toward the + * trip that protects the automatic routes. + */ +export function shouldBypass(_state: CompactionExtensionState, opts?: ShouldBypassOptions): boolean { + if (opts?.manual === true) return true; + if (opts?.reason === "manual") return true; return false; } diff --git a/packages/coding-agent/test/compaction/circuit-breaker.test.ts b/packages/coding-agent/test/compaction/circuit-breaker.test.ts index 4d34aad134..cc480a2492 100644 --- a/packages/coding-agent/test/compaction/circuit-breaker.test.ts +++ b/packages/coding-agent/test/compaction/circuit-breaker.test.ts @@ -130,16 +130,16 @@ describe("compaction circuit breaker", () => { }); describe("Given the breaker is tripped", () => { - describe("When manual /compact reaches the breaker", () => { - it("Then the manual route is cancelled while the breaker is tripped", () => { + describe("When manual /compact bypasses breaker", () => { + it("Then the manual route proceeds without circuit_breaker cancellation", () => { const tripped: FutureBreakerState = { consecutiveFailures: TRIP_THRESHOLD, trippedAt: 0 }; const bypassed = shouldBypassFuture(tripped, { manual: true }); const manualDecision = evaluateAutoCompact(tripped, COOLDOWN_MS / 2, { manual: true }); - expect(bypassed).toBe(false); - expect(manualDecision.cancel).toBe(true); - expect(manualDecision.reason).toBe(CIRCUIT_BREAKER_REASON); + expect(bypassed).toBe(true); + expect(manualDecision.cancel).toBe(false); + expect(manualDecision.reason).toBeUndefined(); }); }); }); From f1a138637bee81a284baadc631c8b75907e45d59 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 7 Sep 2026 13:25:44 +0900 Subject: [PATCH 7/8] refactor(compaction): type lane ownership by reason and drop the this binding ownsCompaction took 'manual' | string, which collapses to string and loses every compile-time guarantee; it now takes CompactionReason. The predicate is a local, so destructuring the policy object cannot unbind the receiver. --- .../builtin/compaction/lane-policy.ts | 57 +++++++++++-------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/lane-policy.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/lane-policy.ts index 06f065d7bf..cc32233e57 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/lane-policy.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/lane-policy.ts @@ -14,8 +14,10 @@ * This module also owns the shape of the mirrored `compact_boundary` ledger * entry, so the SDK's native compactions stay visible in senpi history. */ + import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AssistantMessageDiagnostic } from "@earendil-works/pi-ai"; +import type { CompactionReason } from "../../types.ts"; import { CLAUDE_SDK_OAUTH_PROVIDER_ID } from "../claude-sdk-oauth/account-management.ts"; import type { ClaudeSdkOauthProviderSettings } from "../claude-sdk-oauth/settings.ts"; import { loadClaudeSdkOauthProviderSettingsFromDisk } from "../claude-sdk-oauth/settings.ts"; @@ -58,7 +60,7 @@ export interface CompactionLanePolicy { /** True when the SDK owns this lane's context and senpi compaction must stand down. */ disablesSenpiCompaction(context: LaneContext): boolean; /** Manual requests are explicitly owned by senpi for recovery, even on SDK lanes. */ - ownsCompaction(context: LaneContext, reason: "manual" | string): boolean; + ownsCompaction(context: LaneContext, reason: CompactionReason): boolean; } export interface CompactBoundaryEntry { @@ -92,31 +94,36 @@ export function createCompactionLanePolicy( const load = options.loadProviderSettings ?? loadClaudeSdkOauthProviderSettingsFromDisk; let cachedCwd: string | undefined; let cachedResumeMode: string | undefined; - return { - ownsCompaction(context: LaneContext, reason: "manual" | string): boolean { - return reason === "manual" || !this.disablesSenpiCompaction(context); - }, - disablesSenpiCompaction(context: LaneContext): boolean { - if (context.model?.provider !== CLAUDE_SDK_OAUTH_PROVIDER_ID) return false; - // A configured compaction model override makes senpi own summarization - // for the lane, so the SDK-native stand-down no longer applies. This is - // the escape hatch for lanes whose SDK never fires native compaction. - if (context.getCompactionSettings?.().model) return false; - // Per-cwd cache is the intended contract (pinned by lane-policy.test.ts): - // resumeMode is read once per cwd. A mid-session switch takes effect on - // the next cwd or session. - if (cachedCwd !== context.cwd) { - try { - cachedResumeMode = load(context.cwd).resumeMode; - } catch { - // A settings read failure must never silently disable senpi compaction: - // fail closed by keeping senpi's own compaction fully active. - cachedCwd = undefined; - return false; - } - cachedCwd = context.cwd; + // Declared as a local so `ownsCompaction` never depends on `this`: the policy object + // is routinely destructured at call sites, which would otherwise unbind the receiver. + const disablesSenpiCompaction = (context: LaneContext): boolean => { + if (context.model?.provider !== CLAUDE_SDK_OAUTH_PROVIDER_ID) return false; + // A configured compaction model override makes senpi own summarization + // for the lane, so the SDK-native stand-down no longer applies. This is + // the escape hatch for lanes whose SDK never fires native compaction. + if (context.getCompactionSettings?.().model) return false; + // Per-cwd cache is the intended contract (pinned by lane-policy.test.ts): + // resumeMode is read once per cwd. A mid-session switch takes effect on + // the next cwd or session. + if (cachedCwd !== context.cwd) { + try { + cachedResumeMode = load(context.cwd).resumeMode; + } catch { + // A settings read failure must never silently disable senpi compaction: + // fail closed by keeping senpi's own compaction fully active. + cachedCwd = undefined; + return false; } - return isSdkNativeCompactionLane({ model: context.model, resumeMode: cachedResumeMode }); + cachedCwd = context.cwd; + } + return isSdkNativeCompactionLane({ model: context.model, resumeMode: cachedResumeMode }); + }; + return { + disablesSenpiCompaction, + ownsCompaction(context: LaneContext, reason: CompactionReason): boolean { + // Manual is senpi-owned everywhere: it is the user's explicit recovery path, + // including on an SDK-native lane whose automatic routes stay SDK-owned. + return reason === "manual" || !disablesSenpiCompaction(context); }, }; } From 922b5e21987a2227af09f00724072c529f61c174 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 7 Sep 2026 13:26:17 +0900 Subject: [PATCH 8/8] test(compaction): document the faux case as a non-SDK control The faux case passes with the production change reverted, so it is not regression coverage. It is a control for the lane the exemption must not change, and it now guards the shared ownsCompaction predicate's non-SDK branch. --- .../test/suite/sdk-manual-compaction-recovery.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/coding-agent/test/suite/sdk-manual-compaction-recovery.test.ts b/packages/coding-agent/test/suite/sdk-manual-compaction-recovery.test.ts index 79c1d43c71..22ea848503 100644 --- a/packages/coding-agent/test/suite/sdk-manual-compaction-recovery.test.ts +++ b/packages/coding-agent/test/suite/sdk-manual-compaction-recovery.test.ts @@ -11,6 +11,13 @@ afterEach(() => { vi.restoreAllMocks(); }); +/** + * `claude-sdk-oauth` is the regression case: it fails without the manual ownership + * exemption, with `rejectionCause: "external-owner"`. `faux` is a deliberate control + * for the lane the exemption must NOT change - it reaches the same persisted-summary + * outcome through the ordinary senpi-owned path, and it fails if the shared + * `ownsCompaction` predicate ever regresses the non-SDK branch. + */ describe("explicit compaction recovers a rejected model downswitch", () => { it.each(["claude-sdk-oauth", "faux"])("persists a usable manual summary on %s", async (provider) => { // Given the real builtin, no compaction-model override, and an oversized live transcript.