Skip to content

feat(provider-switch): switch agents off an exhausted provider without a source-side summarization turn - #834

Merged
Juliusolsson05 merged 20 commits into
mainfrom
feat/quota-independent-provider-switch
Sep 7, 2026
Merged

feat(provider-switch): switch agents off an exhausted provider without a source-side summarization turn#834
Juliusolsson05 merged 20 commits into
mainfrom
feat/quota-independent-provider-switch

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

Problem

The bulk provider switch exists for one moment: a subscription window is
exhausted and every agent on that provider needs to move. Until this branch, the
transaction asked the source provider for a live turn in most of those cases —
and a provider that is out of quota cannot answer.

Two shapes forced the source turn, and the Stage 0 census
(docs/decomposition/evidence/provider-switch/census.md, 3,328 local
transcripts and 7.15 GB, no sampling) says how common they are:

  • Any Codex session that has ever compacted. A modern rollout persists
    type=compacted with a provider-encrypted item, which no client can read, so
    the planner returned requires-portable-handoff and asked the live Codex
    session to write a plaintext handoff. 146 of the newest 300 local rollouts
    carry such a record. But a Codex rollout usually keeps its pre-compaction
    records on disk in plaintext: of 230 single-compaction rollouts, 211
    (91.7 %) keep a median 74.3 % of their characters ahead of the compaction
    .
    For those the history the encrypted summary describes was never gone. (The
    remaining 18 have nothing before the compaction at all; see "Carry the raw
    history" for what the switch says about them.)
  • Any conversation over the target's budget. The planner returned
    requires-compaction, and the host delivered /compact to the source — which
    destroys the history the switch was trying to rescue before the request
    fails. 91 local Claude transcripts exceed the 581,400-character Codex budget.

The cost of that design when the source is exhausted: 300 seconds of waiting per
agent, one native dialog per agent, and nothing moved.

A second, sharper failure hid inside the same path (#820). Claude Code's own
compaction only rejects a summary that starts with API Error, so a usage limit
hit during /compact can be persisted as the summary — behind the standard
"This session is being continued from a previous conversation…" preamble, which
makes it look entirely healthy to a fingerprint-and-availability check. Accepting
that carrier switched the pane onto a transcript whose history had been replaced
by "You've hit your monthly spend limit".

Implemented behavior

Parser (agent-transcript-parser, merged as
#25,
main 9c99db00)

  • planConversationContext(..., { allowSourceTurns }). It defaults to true,
    keeping all four existing outcomes and their ordering for every existing
    caller. (Not byte for byte: a conversation whose latest carrier is a Claude
    usage-limit message used to classify portable and be sliced at, discarding
    every pre-limit turn. It is no longer sliced at on either path — a bug(provider-switch): compaction wait may accept a Claude rate-limit message as the portable summary #820 fix
    that reaches the default-true path too, and the only behaviour a pre-existing
    caller can observe changing.) With false
    the outcomes are ready, existing-compaction, raw-history (the unreadable
    carrier is stripped and the plaintext records it claimed to replace are
    carried) and shrunk, or a thrown ConversationUnfittableError. Six outcomes
    total, and the two that cost a live source turn are unreachable on the new
    path.
  • operations/shrink.ts, a deterministic ladder with one consumer (the
    planner): strip compactions the target cannot read → clear tool results oldest
    first, with a net-savings floor and a placeholder quoting the raw length →
    trim long tool-call inputs, object-preserving, capped on the serialized length
    → drop the oldest complete turns at a safe resume boundary behind a
    budget-aware marker that indexes the dropped prompts. Every rung reports what
    it removed (ShrinkReport).
  • Hazard fixes: compactionAvailability returns rejected for a Claude carrier
    containing a rate-limit line at any line start (the match is line-start, not
    startsWith, because the persisted record is wrapped in a ~150-character
    continuation preamble; it is gated on the Claude provider). Claude assistant
    records with isApiErrorMessage: true decode to opaque /
    nativeType: 'api_error' instead of assistant text.

codex-headless (merged as
#47, main
96c5c146) — a 429 whose body says usage_limit_reached is published as its own
api_error errorType carrying resetsAt, limitId and limitName, instead of
falling through to retryable; rate-limit response headers are allowlisted onto
the proxy response event.

Host transactionSwitchProviderRequest.contextPolicy { allowSourceTurns, compactOnArrival }, defaulting to { false, false }. On
that path runtime.compactSource is never reachable. The result reports
strategy: 'native' | 'raw' | 'shrunk' plus a one-line shrinkSummary, and a
new shrinking progress phase carries the same line while the switch runs.
overflowPolicy: 'truncate' now routes to the ladder (a strict upgrade over the
whole-turn fitter it replaces, which refused outright when an encrypted Codex
carrier was in the way); fail keeps its legacy branch unchanged. The opt-in
source path is otherwise the old transaction, plus three fast-fails: a
rejected carrier persisted by the source's own /compact, an api_error
record after the /compact baseline line, and — new in the review pass — an
abort before planning when the source's latest carrier is already rejected.
That last one closes the #820 hazard on the opt-in path specifically: nothing
strips the carrier there, the planner returns ready with it still inside, and
the Codex projector demotes any non-empty summary to a developer handoff without
consulting availability, so the limit text would reach the target framed as its
prior context.

Arrival compaction — new session:compact-after-switch IPC over
providerSwitch/compactOnArrival.ts, with its own in-flight lock keyed on the
new session id. One 30-second wait with two exits (a visible
claude.resume-prompt is answered with selectedIndex Ups + Enter; a ready
input with no prompt gets /compact), then the same compaction wait pointed at
the target. Failure is { ok: false, message } with arrival-specific wording and
is never thrown — the pane is already live with its full history. The renderer
fires it fire-and-forget, only for Claude targets and only when the policy asked.

Exhaustion signalUsageLimitRow.scope
(all-models | model-family | unknown), classified in the normalizers:
Claude session / weekly_all → all-models, weekly_scoped → model-family;
the Codex main rate_limit windows → all-models, additional_rate_limits
entries → model-family. src/shared/usage/exhaustion.ts derives
{ exhausted, scope, resetsAt, label } purely from a snapshot at ≥ 100 %, with
all-models reported ahead of model-family. Live per-pane signal
SessionRuntime.limitHit, set from an appended Claude rate_limit record using
the record's own timestamp (a resumed pane replays its tail through the same
channel; Date.now() would date a days-old episode as current) or a Codex
usage_limit_reached semantic error; cleared on turn_completed and on
turn_started
, deliberately not on turn_stopped. The turn_started clear is
a review fix: a pane that auto-continues once its window resets keeps its
original turnStartedAt (the phase machine stamps that field only when it is
null), so the stale timestamp stays older than limitHit.at and the pane read as
limit-idle for the whole of a live answer — the switch modal would have labelled
a working pane idle and a switch would have killed the turn. A turn the provider
accepted is the earliest honest proof the episode is over, and a fresh 429 re-arms
the signal with a newer timestamp. isLimitIdle widens the switch guard with it,
on the belief that both providers keep processActive true while a window is
exhausted — that belief is unrecorded (limitation 1 below), which is why the
predicate is written so it can only widen the guard, never narrow it.

Bulk modal — banner per exhausted provider with a relative reset ("resets in
2h"); direction defaults to the single exhausted source; "compact on arrival"
offered for Claude targets, default on above 150,000 estimated characters in the
batch's largest pane; "compact on source first" default off and disabled while
the source is exhausted; a "switch model instead" row for Claude family-scoped
limits, suppressed when the exhausted family is the one the command would land
on; one arm-and-confirm per batch instead of a native dialog per agent; strategy
counts in the batch summary (the single-pane switch is what shows a strategy in
the pane toast — the bulk path only tallies). Usage polling is
gated on open (the modal is a permanently mounted surface, and its previous
unconditional hook call polled the usage IPC every 60 s for the life of the app).

Design decisions and tradeoffs

Carry the raw history instead of asking for a summary. The encrypted Codex
carrier is the only opaque part of a compacted rollout, and OpenAI's server —
not the client — decrypts it. The plaintext it summarizes is usually still in the
same file. Stripping the carrier and carrying those records costs nothing, works
with the provider offline, and the census says it applies to 211 of 230
single-compaction rollouts (91.7 %), which keep a median 74.3 % of their
characters ahead of the compaction. The tradeoff is a larger projection, which is
what arrival compaction exists to absorb.

The other 7.8 % matter and are now disclosed. 18 of 230 rollouts have zero
characters before the compaction
— a rollout that begins at one, and a session
resumed into a fresh rollout file both look like that. There the stripped carrier
was the only account of everything prior and nothing on disk replaces it, so
raw would otherwise report "nothing was lost" in exactly the case where the
most was. switchProvider detects the shape (no non-opaque entry ahead of the
first stripped carrier, after mirroring the planner's own slice at the latest
portable compaction) and fills shrinkSummary with a line the pane toast already
knows how to show. The switch is still the right outcome — the alternative is a
source turn an exhausted source cannot spend — and keeping that earlier history
while the source is alive is precisely what the opt-in source handoff is for.

A deterministic ladder, not a model call. Both vendors ship exactly this:
Anthropic's clear_tool_uses context editing, and OpenAI's
externalAgentConfig/import, which narrates tool calls and truncates results.
Ordering rungs by what the loss costs the model means a conversation 5 % over
budget loses a few old tool outputs rather than a third of its history. The
alternative — a summarization prompt — was ruled out by the user twice over: no
speculative pre-summarization, and no custom summarization prompt as a product
path.

Developer messages are retained by default, and the planner decides. The
ladder is provider-neutral by construction and must stay that way, but somebody
has to know what the target persists. The Claude projector drops developer and
system messages outright, so retaining them for a Claude target would charge the
budget for content deleted on arrival and could refuse a switch to protect
messages the target throws away; Codex preserves the role verbatim and OpenCode
demotes it to labeled user context. So keepDeveloperMessages defaults to
targetProvider !== 'claude' in planConversationContext. That is a hard-coded
provider string in a planner that otherwise derives everything from
compactionPortability — the durable shape is a capability flag on the projector
profile, and it was not built because no projector exposes capabilities today
and inventing that surface here is out of scope. If a third target that discards
the role appears, build the flag rather than adding a second comparison.

Compaction happens on arrival, on the target's quota. The target's own
native compaction is the only summarizer, and it runs after the pane is live, so
its failure cannot fail a committed transaction. That is also why it is a
separate module and a separate IPC rather than a tail on switchProvider:
folding it in would either move pane replacement before the transcript write or
give the transaction a step whose failure must not fail it.

The source path survives as an opt-in. Some users will prefer a native
summary over a larger raw history when the source is alive. It keeps its
existing failure list, gains the two hazard checks, and its checkbox is disabled
while the source is exhausted, because that combination spends quota the
provider has already refused.

Host tests derive budgets from each fixture's own decoded size. Census caveat
1: the committed fixtures are redacted to 0.3–2.4 % of real byte totals, so a
literal 581,400-character budget in a test would assert against the redactor
rather than against the ladder. Relative budgets keep the tests reacting to real
transcript shape.

Repository plumbing

  • Submodule pointers moved to the merged package mains (9c99db00,
    96c5c146). No lockfile resync was needed, and that is checked rather
    than assumed: git diff <old>..<new> -- package.json is empty in both
    submodules (parser 2fdfc09a..9c99db00, codex-headless 4ff1fd91..96c5c146).
    file: deps embed the package tree in package-lock.json, so a submodule bump
    that changes a package's own package.json breaks npm ci in CI before tsc
    ever runs; neither of these does.
  • The vendored Codex checkout moved to upstream main (7754c04a): the design
    cites its current remote compaction, rollout persistence and external-agent
    import code, and the April checkout predates all three.
  • package-lock.json is not in this branch. A lock-only resync produced a
    512-line diff consisting entirely of nested vitest/@esbuild optional-arch
    entries; the identical diff reproduces on main with no submodule involvement,
    so it is local npm-version churn, not this branch's, and was reverted.

Linked issues

Tests and verification

Run on the branch at the full-gate commit, Node 24.14.1, macOS. These are the
gate's own numbers, not a summary of them.

Command Result
npm run typecheck exit 0 (re-run once with an explicit echo EXIT_CODE=$? to confirm the silent tsc -b really was clean)
npx tsc -p tsconfig.node.json --pretty false exit 0, no diagnostics
npx tsc -p tsconfig.web.json --pretty false exit 0, no diagnostics
npm run check:keybindings exit 0 — 42 command binding sets, 15 reserved interactions, 5 approved overlaps
npm run test:package exit 0 — full electron-vite build + verify-build-output.mjs ("Application build contains every required entry point")
npm test (all projects) 2,840 passed, 12 skipped, 7 failed of 2,859 tests; 414 of 420 files passed; 475 s

Both tsc projects are run separately and deliberately: electron-vite build
and vitest do not type-check, and the web project caught a 'shrinking' union
gap that a node-only run had missed mid-branch.

The seven failures, attributed one by one. None is a regression from this
branch, and that was checked rather than asserted — git diff fcb8d0ca..HEAD for
each failing path and its directory is empty, and every one was re-run in
isolation on both this branch and main:

  • 2 are known pre-existing: lazy-prose/index.renderer.test.tsx (a
    dynamic-import timeout under full-suite parallelism, issue bug(testing): lazy-prose renderer test times out at 5s under load #700) and
    imageAttachment.test.ts (a corpus invariant citing a local ~/.claude
    transcript that has since been deleted on this machine; it fails on main too).
  • 2 are transient, and did not reproduce when re-run:
    WorkflowViewSelector.renderer.test.tsx "bounds history detail reads…" and
    store.test.ts "starts in the command list", both 5 s timeouts.
  • 3 reproduced only under load and pass deterministically in isolation on
    both checkouts: store.test.ts × 2 (8/8 twice on branch and on main),
    store.performance.test.ts (12/12 twice on each, beforeAll nowhere near its
    10 s budget), WebSocketSessionFeed.integration.test.ts (13/13 twice on each,
    including the "backfills a previously loaded session after overflow…" case that
    had returned an empty array). All three are in src/renderer/src/app-state/ or
    src/remote-client/, which this branch does not touch at all.

Focused re-runs for the review-fix commit (its diff is host + renderer +
docs, so the full suite was not re-run):

Command Result
NODE_ENV=test npx vitest run --project unit src/main/providerSwitch 6 files, 59 passed
NODE_ENV=test npx vitest run --project renderer providerSwitchCore.renderer.test.ts useIpcSubscriptions.renderer.test.tsx 2 files, 26 passed
NODE_ENV=test npx vitest run --project renderer src/renderer/src/workspace/hook/actions 11 files, 57 passed
NODE_ENV=test npx vitest run --project unit src/renderer/.../hook/actions src/main/usage src/shared 15 files, 75 passed
npx tsc -p tsconfig.node.json --pretty false exit 0
npx tsc -p tsconfig.web.json --pretty false exit 0

New coverage worth naming: the shrink ladder and planner outcomes against
decoded Stage 0 fixtures (never invented literals); switchProvider cases
asserting compactSource is not called on the default path, that the
compaction-first fixture reports its disclosure line while the majority-shape one
reports shrinkSummary: null, and that the opt-in path aborts on a rejected
carrier before projecting; compactBeforeSwitch rejected-carrier and
api-error-after-baseline cases; compactOnArrival on both branches with a fake
SessionManager; exhaustion over the real usage payloads already in the suite;
both limitHit reducers in the injected-feed harness, including that a replayed
burst does not re-arm the guard and that a turn the provider accepts after a
reset clears it; the renderer's refusal to start a second switch on a pane whose
arrival compaction is still running; and the modal's policy state machine.

The Stage 7 live probe was NOT run — deferred to
#833 by the user's
decision on 2026-09-07. No projected transcript was resumed through a real Claude
or Codex CLI on this branch. That leaves four questions open that only the probe
answers: whether a shrunk projection's real token count matches the 2.5
characters-per-token estimate closely enough to stay under Codex's 90 %
auto-compact limit (Unknown 2); whether a live Codex tolerates a
custom_tool_call_output whose output is a placeholder or treats it as an error
(Unknown 3); whether a 1M Claude target refuses arrival compaction on this
account with "Usage credits required for 1M context" (Unknown 6); and what
OpenCode's import size limits are for shrunk envelopes (Unknown 7). Structural
projection tests prove the shapes are legal; they cannot prove a provider
includes translated history in its next model request.

Known limitations and follow-ups

  1. Unknown 1 is unrecorded. Nobody captured a Claude "Usage limit reached ·
    continuing automatically" screen, so nothing proves processActive stays true
    under the banner. isLimitIdle is therefore defensive: it can only widen the
    guard, never narrow it, so being wrong costs nothing.
  2. Restored-pane gap, deliberately shipped. A restored pane whose replayed
    tail contains a genuine rate-limit carrier reads as limit-idle, because
    turnStartedAt is null there. That is the intended reading of "no turn to
    protect", and the guard is only consulted when something else already says the
    pane is busy. If it proves wrong, the fix belongs in isLimitIdle.
  3. The 30-second arrival readiness deadline and the ladder's
    keepRecentTurns: 3 / maxInputChars: 8,000 are estimates, not
    measurements.
    The census measured no per-turn size distribution and no
    tool-call input percentiles, and rung 3 never fired on realistic data — it is
    exercised only by its own unit test with an explicit small cap.
  4. The api-error fast-fail on the Codex and OpenCode handoff waits is inert in
    production.
    Only the Claude decoder classifies opaque/api_error, so a
    Codex limit during a handoff turn still ends in the 300-second timeout. The
    code is the right shape and starts working the moment those decoders classify
    error records; its tests hand-build the entry and say so.
  5. Arrival compaction reads the resume prompt through the neutral shape.
    compactOnArrival originally imported type ResumePromptState from
    claude-code-headless, which would have been the first such import in
    src/mainPlug-and-play provider architecture: modularize and standardize all provider implementations #394 phase 2a deliberately removed them all (see the WHY block at
    the top of sessionManager.ts). It now reads AgentResumePromptState from
    @shared/types/session.ts, which is field-identical and is the shape
    SessionManager actually emits, so the module's declaration matches its real
    source. Nothing about the runtime behaviour changed.
  6. Arrival compaction has an entry guard but no retry. A second call while
    one is running returns { ok: false, message: 'Arrival compaction already running.' }; a pane that was merely slow past 30 s is reported as a failure
    and the user runs /compact by hand.
  7. ProviderId is an open string. targetProvider !== 'claude' is
    therefore a comparison against an unconstrained type; a typo produces
    developer retention rather than a compile error.
  8. Rung 1 does not re-validate the head of the conversation. Stripping a
    leading unreadable compaction can leave the retained history starting at a
    non-boundary entry; rung 4 only enforces the boundary invariant on the cuts it
    makes. Not observed in the corpus (a stripped carrier is always followed by
    the records it claimed to summarize), but not proven either.
  9. Deferred minors from the review rounds. /model sonnet is hard-coded in
    the modal's model-switch row (no renderer-side Claude model registry exists).
    The 150,000-character default reads the live entry window, so a pane whose
    oldest entries were trimmed to disk under-reports — erring toward "no
    compaction", which is the safe side. The bulk path has no per-agent pane
    toast; strategies are reported as counts in one global toast, because per-pane
    wiring would change the workspace hook signature beyond parameter
    pass-through. Codex's exhaustion derivation is unproven against real bytes (no
    local rollout carries a rate_limit_reached_type). The drop marker has no
    "prompts omitted from the index" count. src/main/providerSwitch/testing/ is a
    new directory shape for src/main; it ships in no bundle. And OpenCode's
    projector was not audited for the same developer-role defect that motivated
    the Claude rule.
  10. The Stage 7 live probe is deferred to
    #833, not met
    here.
    Fixes #821 is kept because everything else on that issue's
    acceptance list is implemented and verified, but two of its criteria —
    "a 10-agent batch completes without any 300 s wait" with the source at
    100 %, and "opt-in live probe run once and its report attached to the PR" —
    can only be met by that run. test(provider-switch): run the live probe for quota-independent switching #833 carries both verbatim, the four Unknowns
    the probe settles (2, 3, 6, 7), and the two ladder placeholders
    (keepRecentTurns: 3, maxInputChars: 8_000) it would replace with
    measurements. feat(provider-switch): switch agents off an exhausted provider without a source-side summarization turn #821 has been amended and commented to say so.

Residuals parked at the final re-review (all bounded; none can kill a live turn or lose history):

  • A return-batch run that is refused pane by pane (for example during a compact-on-arrival window) clears the remembered batch even when nothing returned (bulkProviderSwitch.ts), so the user must switch panes back by hand.
  • The compaction-first disclosure line always says "encrypted compaction dropped", although the detector also fires for a Claude incomplete or rejected first carrier.
  • That disclosure reaches the single-pane toast only; the bulk summary reports strategy counts, so a compaction-first pane switched in a batch shows "raw" without the line.
  • Clearing limitHit on turn_started means a pane whose provider auto-continued without writing a new rate-limit record reads busy for the rest of that episode and is refused (never killed) until the next 429 re-arms it.

🤖 Generated with Claude Code

https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd

Juliusolsson05 and others added 20 commits September 7, 2026 12:33
…e-side summarization

The bulk switch exists for the moment a subscription window is exhausted,
yet the transaction still asks the exhausted source for a live /compact or
handoff turn whenever a Codex session has ever compacted or a Claude
session exceeds Codex's budget. Codex keeps every pre-compaction record on
disk and never decrypts its summary locally, Claude's summary is plaintext,
and both targets absorb overflow with their own native compaction, so no
source turn is needed. The decomposition, spec and plan record the design:
raw history carry-over, a deterministic shrink ladder isolated in the
parser, optional native compaction on arrival, a structural exhaustion
signal, and the existing source path kept as an explicit opt-in with two
hazard checks.

Refs #821, #820, Juliusolsson05/agent-transcript-parser#24,
Juliusolsson05/codex-headless#46

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B882eBLjpecu4ibaSL7emu
The provider-switch design cites the current remote compaction v2,
rollout persistence policy, rate-limit snapshot fields and the
external-agent session importer; the April checkout predates all of
them. vendor/ is a read-only reference namespace and is skipped by the
submodule checkout verifier, so this pointer never affects a build.

Refs #821

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B882eBLjpecu4ibaSL7emu
…wnership boundaries

Refs #821

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
… fixtures

The two load-bearing conclusions were generalized from the smallest member of
each population, which is exactly the least-stressing example the
smallest-candidate rule can produce. Measured all 91 oversized Claude
transcripts and all 230 single-compacted Codex rollouts with a streaming
replica of estimateEntryCharacters, and restated both claims from the
distributions: clearing tool results leaves 46 of 91 (50.5 percent) still over
budget, so dropOldestTurns is a primary rung; and 211 of 230 (91.7 percent)
single-compacted rollouts keep a median 74.3 percent of their characters before
the compaction, so the committed fixture is an 8 percent minority shape.

Also corrects the identity of the rate-limit fixture's opaque entry. It is the
isMeta user record at fixture line 6, not the rate-limit record, which decoded
as an ordinary assistant message at the Task 0 commit - the defect Stage 1
exists to fix. Records the post-Stage-1 numbers alongside, since bffecd6 has
since moved that record to opaque/api_error and 181 characters out of assistant
text.

Refs #821

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
Tables for codex-sequence-compacted-history (83 entries, compaction at entry 23,
70.6 percent tool-result) and claude-sequence-oversized-turns (1,470 entries,
5.44x the budget, still 4.14x after clearing every tool result), measured
against the parser at bffecd6.

Records two facts the measurement turned up. The selection helper
estimateSemanticCharacters sees only 26 percent of the Codex rollout's planner
characters, because a custom_tool_call keeps its input in payload.input and a
custom_tool_call_output keeps its output as a list, neither of which the helper
reads; so the predicate's "at least 50 percent before the compaction" is a
property of that estimate, not of the decoded conversation, which puts 17.3
percent before it. And the oversized-turns predicate matches 13 files where the
planner-measure population was 46, because the helper also ignores tool-call
inputs - the 13 are a strict subset that is over budget on prompts and replies
alone.

Refs #821

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
…a-independent switching

Refs #821

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
…source provider

A provider switch used to depend on the source provider being able to run a
live turn: an oversized history asked it for `/compact`, and a modern Codex
rollout's encrypted `compacted` record asked it for a plaintext handoff. Both
requests fail exactly when the feature is most needed — when the source is out
of quota — and the compaction request fails only AFTER `/compact` has already
destroyed the history the switch was trying to rescue (#821).

`SwitchProviderRequest.contextPolicy` now defaults to
`{ allowSourceTurns: false, compactOnArrival: false }`. On that path the host
plans with the parser's `allowSourceTurns: false` option, which returns only
outcomes it can execute alone, and never calls `runtime.compactSource`. The
result reports how the conversation was made to fit — `native`, `raw` or
`shrunk` — plus a one-line `shrinkSummary`, and a `shrinking` progress phase
carries the same line while the switch is still running. The opt-in path is the
old transaction, unchanged, minus the per-agent native dialog when the caller
says it has already confirmed (a bulk switch confirms once for a batch).

`overflowPolicy` keeps working: `truncate` moves onto the ladder, which is a
strict upgrade over the whole-turn fitter it replaces, while `fail` keeps its
legacy branch because "refuse an oversized switch" must not silently become
"make it fit lossily".

The new host tests are driven by decoded Stage 0 fixtures rather than literals,
so they react to real transcript shape. Their budgets are derived from each
fixture's own decoded size: census caveat 1 records that the committed fixtures
are redacted to 0.3-2.4 % of real byte totals, so a literal 581,400-character
budget would assert against the redactor instead of the ladder.

Refs #821

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
…d of accepting its text

The opt-in path delivers `/compact` to the live source and then waits for a new
compaction boundary. Two things a rate-limited provider does were both read as
"keep waiting":

An api_error record landing after `/compact` was ignored, so the wait ran its
full five minutes and reported a timeout — which reads as "Agent Code is slow"
rather than "your account is out of quota and your history was just compacted".

Worse, Claude Code's own compaction only rejects summaries starting with
"API Error", so a limit hit during `/compact` can be persisted AS the summary,
behind the standard continuation preamble. That carrier looks entirely healthy
to a fingerprint-and-availability check: right kind, new fingerprint, complete.
Accepting it switched the pane onto a transcript whose history had been replaced
by "You've hit your monthly spend limit".

The compaction probe now fails on both, before any pane is replaced. The parser
supplies the judgement — `findApiErrorAfterLine` and `compactionAvailability`'s
`rejected` — so the rule lives in one place with the fixtures that recorded it.

Making the probe able to fail required one structural change: probe errors used
to be swallowed by the decode's try/catch, which exists to retry a snapshot
caught mid-append. They are now carried past that catch and rethrown, so a
transient read still retries while a definitive answer stops the wait at once.

The wait loops now name the session they watch (`TranscriptWatchTarget`) instead
of taking a `SwitchProviderRequest`, and `waitForNewCompactionOn` and
`latestSourceLine` are exported. Arrival compaction (Stage 5) runs this same
wait against the session the switch just created, which no request can name —
and sharing the loop is what keeps these two hazard checks from existing in only
one of the two copies. No document is held across an await; the fingerprint and
the baseline line now come out of one decode as two numbers.

Fixes #820

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
… fix

The shrink ladder's review round renamed a report field, added the
retainedDeveloperMessages count and the planner-set keepDeveloperMessages
option; the host code already uses the new names.

Refs #821

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
…arget's own /compact

Refs #821

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
…report API errors honestly

Review round on the Tasks 4/5 commits (b4bc70e, d71687d):

1. The renderer's ProviderSwitchRuntimeState.phase and switchAgentProvider's
   onProgress parameter now accept 'shrinking', which preload already emitted.
   Landed in the Task 6 commit (cb4361b) because the web typecheck gates it.
2. findApiErrorAfterLine matches EVERY api_error record — connection failures
   and overloads included — so the abort no longer claims a usage limit unless
   the record itself carries the evidence (error: 'rate_limit', or text the
   parser's isRateLimitText accepts). Generic wording otherwise.
3. truncatedBeforeSwitch now means "the ladder removed anything", not only
   "it dropped entries": a cleared-outputs or trimmed-inputs shrink is lossy.
4. Pin overflowPolicy: 'truncate' routing to the ladder even under
   allowSourceTurns: true — the behaviour change Task 4 made and left untested.
5. The Codex and OpenCode portable-handoff waits gained the same fast-fail
   api_error probe the compaction wait has, so a provider that answers the
   handoff turn with an error no longer burns the full 300 s. Inert until
   those decoders classify api_error records; commented as such.

Refs #821

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
…bel each agent's strategy

Refs #821

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
… its failures honestly

Fix round 1 on Task 6 (cb4361b).

I1. The shared compaction wait's messages were written for the SOURCE path,
    where nothing has been replaced yet, and arrival compaction reused them
    verbatim: "the switch was aborted before any pane was replaced" on a pane
    that IS replaced, and "The source agent exited" for the target session.
    waitForNewCompactionOn now takes a CompactionWaitPhrasing from its caller
    (required, so the lie is never the silent default); the arrival path says
    the imported history is intact and /compact can be run by hand.
I2. Everything below the guard clauses moved inside the try: a rejected
    transcript read or a throwing progress callback used to escape as a
    rejected promise on a switch that had already committed.
I3. Spec step 1: the pane is now polled until it is ready for input, with a
    bounded, documented deadline, before anything is typed at it. Readiness
    and the resume prompt are ONE wait with two exits, because a visible
    condition blocks prompt input (claudeSession derivePromptGateState +
    conditionBlocksPromptInput) — a readiness-first gate could never answer
    the prompt it is waiting for.
I4. The renderer half is covered: the arrival call happens only for a Claude
    target and only after replaceSession, its progress subscription filters on
    the NEW session id, both subscriptions are torn down, and an ok:false
    outcome reaches onArrivalFailure without failing the switch.

Also: read the condition through conditionStateByKind with the headless
ResumePromptState type; note the keystroke journalling origin; name the shrink
report's four-term sum; re-check liveness on every readiness poll; and contain
a synchronous throw out of startArrivalCompaction so it can neither fail a
committed switch nor leak its progress subscription.

Refs #821

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
The package PRs (agent-transcript-parser#25, codex-headless#47) merged; the app now references their main commits so CI fetches published history.

Refs #821

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
…nal from appended records, and test both reducers

I1: the bulk switch modal is a permanently mounted surface rendered with
open={false} for the life of the app, so its unconditional
useUsageHeaderSnapshot() call ran the mount fetch, the 60s interval and the
visibilitychange listener forever — even with the usage header off. The hook
gains an `enabled` parameter (default true, so the header indicator is
unchanged) that schedules nothing while false, and the modal passes `open`.

I2: the JSONL reducer scanned the whole burst and stamped Date.now(), so a
resumed pane — which replays its last ~200 lines through this channel — booted
with a days-old rate-limit episode dated "now", arming the switch guard's
exception on a pane that may be mid-turn, and defeated the bootstrap-burst
noChange bail on every replay. It now scans the deduplicated `appended` set,
takes `at` from the record's own timestamp exactly as lastJsonlEntryAt does,
and compares by value. The Codex semantic path keeps the event's own `ts` when
it is a wall clock (a live event is not a replay) and also compares by value.

I3: both reducers are now covered in the injected-feed harness — a Claude
carrier sets limitHit at the record's time, a replay does not re-arm it, an
ordinary api error does not arm it, a Codex usage_limit_reached sets it,
turn_stopped keeps it and turn_completed clears it.

Minors: banner no longer repeats a provider name the label already carries;
the model-switch row is withheld (with a reason) when the exhausted family is
the one /model sonnet would land on; the model-switch toast counts failures and
quotes the first message; the strategy tally counts only agents that join the
batch; scope, project and filter changes disarm the source-compaction
confirmation; the size estimate's inputs are gated on `open`; the isLive
comment names both directions in which it is inexact; pluralAgents has one
definition.

Refs #821

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
…urce compaction and arrival compaction

The evergreen design doc (docs/design/provider-switching.md) now describes the
code at HEAD: six planner outcomes with the allowSourceTurns default that
preserves the original four; the shrink ladder rung by rung with its narrowed
recent-turn protection, its net-savings floor and its object-preserving input
trim; the ladder's isolation (one consumer, no projector/decoder/host importer,
no provider names) and the single documented exception, the planner's
targetProvider !== 'claude' developer-retention decision; source-side compaction
as opt-in and disabled while the source is exhausted; a new "Arrival compaction"
section; the `rejected` availability and the Claude API-error decode rule; and
an "Exhaustion signal and the bulk modal" section. The warning keeps its "no
fallback silently truncates" rule and says why the ladder is not a
counter-example. Estimates are labelled as estimates: the 30 s arrival
readiness deadline, the ladder's keepRecentTurns/maxInputChars placeholders, and
Unknown 1's missing recording.

The spec (docs/superpowers/specs/...-design.md) keeps its original text and
carries a new "As built (2026-09-07)" note listing ten deviations with one-line
reasons, rather than being rewritten to match the implementation. Four places
that would otherwise be wrong about the code are corrected in place: the flat
recent-turn sentence, the promptIndexChars/promptIndexLength name collision, the
missing keepDeveloperMessages/retainedDeveloperMessages, the relative reset text
and the usage IPC not returning the exhaustion.

The decomposition records a Resolution 2026-09-07 line on each of the eight
Unknowns — 1 unrecorded (with the restored-pane gap it implies), 2 and 3
unmeasured until the live probe, 4 handled both ways in code but unobserved, 5
does not manifest on disk (one of seven transcripts has a boundary after the
error and its carrier is a genuine summary; the guard ships regardless), 6 and 7
unknown, 8 resolved with hook/index.ts needing no change — and adds a "What
Stage 0 changed" paragraph for the two census findings that reversed the
ladder's assumptions.

Refs #821

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
…umed turns alive, and refuse poisoned opt-in summaries

The whole-branch review found five important defects and two must-fix
ledger items. All of them land here.

F1 — `raw` hid the compaction-first Codex shape. The census measured 18 of
230 single-compaction rollouts (7.8 %) with ZERO characters before the
compaction: a rollout that begins at one, or a session resumed into a fresh
file. Stripping the carrier there uncovers nothing, so the switch reported
`raw` with a null summary — "nothing was lost" in the one shape where the
summary of everything prior just went away. `switchProvider` now detects it
(no non-opaque entry ahead of the first stripped carrier, after mirroring
the planner's own slice at the latest portable compaction) and fills
`shrinkSummary`, which the pane toast already shows for any non-`native`
strategy. Both fixture cases assert their side of the contract.

F2 — `Fixes #821` overclaimed. Two of its acceptance criteria need the live
probe the user chose to skip. They move verbatim to #833, together with the
four Unknowns the probe settles and the ladder's two placeholder thresholds.
#821 is amended and commented; the PR body keeps `Fixes #821` and names the
deferral under Known limitations.

F3 — a resumed-after-reset Claude turn could be killed. `limitHit` cleared
only on `turn_completed`, but a pane that auto-continues after its window
resets keeps its original `turnStartedAt` (the phase machine stamps that
field only when null), so the stale timestamp stayed older than
`limitHit.at` and `isLimitIdle` read true for the whole of a live answer.
The reducer now also clears on `turn_started` — a turn the provider accepted
is the earliest honest proof the episode is over, and a fresh 429 re-arms
with a newer timestamp. `isLimitIdle` gains a note on the two clocks its
comparison mixes and why a miscompare only refuses, never kills.

F4 — the opt-in path could project a rate-limit carrier. With
`allowSourceTurns: true` the planner returns `ready` with a `rejected`
carrier still inside, and the Codex projector demotes any non-empty summary
to a developer handoff without consulting availability, so the limit text
reached the target framed as prior context. The transaction now aborts
before planning. The durable fix belongs in the parser and is filed as
agent-transcript-parser#26, referenced from the design doc's Warning.

F5 — docs and PR body claimed a per-agent pane toast on the bulk path. The
bulk path only tallies counts; the pane toast is the single-pane switch.

Must-fix: census `:266` mixed two measures in one row (2,428,914 → 4.18×,
not 2,408,198 → 4.14×), and the redaction range is 4.4–19.7× per the table
beside it, not 4–95×. `compactOnArrival` no longer imports a type from
`claude-code-headless` — #394 phase 2a removed those from `src/main`, and
`AgentResumePromptState` is field-identical and is what SessionManager
actually emits.

Minors: the nonexistent "ResumePromptModal's `moveSelection`" citation is
replaced by `ResumePromptParser.ts` (the `❯` marker, option 1 = index 0) in
code and docs; the composer's lock sentence is phase-aware so an arrival
compaction no longer claims a lock on a finished switch;
`switchAgentProvider` refuses a second switch while `runtime.providerSwitch`
is set, which is the arrival-compaction window `providerSwitchesInFlight`
cannot see; and the design doc qualifies "byte for byte" with the one
default-path behaviour #820 changed.

Refs #821

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd
@Juliusolsson05
Juliusolsson05 merged commit 3256e06 into main Sep 7, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the feat/quota-independent-provider-switch branch September 7, 2026 23:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant