feat(provider-switch): switch agents off an exhausted provider without a source-side summarization turn - #834
Merged
Conversation
…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
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
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
…t-provider-switch
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 localtranscripts and 7.15 GB, no sampling) says how common they are:
type=compactedwith a provider-encrypted item, which no client can read, sothe planner returned
requires-portable-handoffand asked the live Codexsession 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.)
requires-compaction, and the host delivered/compactto the source — whichdestroys 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 limithit during
/compactcan 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 totrue,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
portableand be sliced at, discardingevery 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-
truepath too, and the only behaviour a pre-existingcaller can observe changing.) With
falsethe outcomes are
ready,existing-compaction,raw-history(the unreadablecarrier is stripped and the plaintext records it claimed to replace are
carried) and
shrunk, or a thrownConversationUnfittableError. Six outcomestotal, and the two that cost a live source turn are unreachable on the new
path.
operations/shrink.ts, a deterministic ladder with one consumer (theplanner): 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).compactionAvailabilityreturnsrejectedfor a Claude carriercontaining a rate-limit line at any line start (the match is line-start, not
startsWith, because the persisted record is wrapped in a ~150-charactercontinuation preamble; it is gated on the Claude provider). Claude assistant
records with
isApiErrorMessage: truedecode toopaque/nativeType: 'api_error'instead of assistant text.codex-headless (merged as
#47, main
96c5c146) — a 429 whose body saysusage_limit_reachedis published as its ownapi_errorerrorType carryingresetsAt,limitIdandlimitName, instead offalling through to
retryable; rate-limit response headers are allowlisted ontothe proxy
responseevent.Host transaction —
SwitchProviderRequest.contextPolicy { allowSourceTurns, compactOnArrival }, defaulting to{ false, false }. Onthat path
runtime.compactSourceis never reachable. The result reportsstrategy: 'native' | 'raw' | 'shrunk'plus a one-lineshrinkSummary, and anew
shrinkingprogress phase carries the same line while the switch runs.overflowPolicy: 'truncate'now routes to the ladder (a strict upgrade over thewhole-turn fitter it replaces, which refused outright when an encrypted Codex
carrier was in the way);
failkeeps its legacy branch unchanged. The opt-insource path is otherwise the old transaction, plus three fast-fails: a
rejectedcarrier persisted by the source's own/compact, anapi_errorrecord after the
/compactbaseline line, and — new in the review pass — anabort 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
readywith it still inside, andthe 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-switchIPC overproviderSwitch/compactOnArrival.ts, with its own in-flight lock keyed on thenew session id. One 30-second wait with two exits (a visible
claude.resume-promptis answered withselectedIndexUps + Enter; a readyinput with no prompt gets
/compact), then the same compaction wait pointed atthe target. Failure is
{ ok: false, message }with arrival-specific wording andis 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 signal —
UsageLimitRow.scope(
all-models|model-family|unknown), classified in the normalizers:Claude
session/weekly_all→ all-models,weekly_scoped→ model-family;the Codex main
rate_limitwindows → all-models,additional_rate_limitsentries → model-family.
src/shared/usage/exhaustion.tsderives{ exhausted, scope, resetsAt, label }purely from a snapshot at ≥ 100 %, withall-models reported ahead of model-family. Live per-pane signal
SessionRuntime.limitHit, set from an appended Clauderate_limitrecord usingthe 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 Codexusage_limit_reachedsemantic error; cleared onturn_completedand onturn_started, deliberately not onturn_stopped. Theturn_startedclear isa review fix: a pane that auto-continues once its window resets keeps its
original
turnStartedAt(the phase machine stamps that field only when it isnull), so the stale timestamp stays older than
limitHit.atand the pane read aslimit-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.
isLimitIdlewidens the switch guard with it,on the belief that both providers keep
processActivetrue while a window isexhausted — 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 previousunconditional 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
rawwould otherwise report "nothing was lost" in exactly the case where themost was.
switchProviderdetects the shape (no non-opaque entry ahead of thefirst stripped carrier, after mirroring the planner's own slice at the latest
portable compaction) and fills
shrinkSummarywith a line the pane toast alreadyknows 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_usescontext editing, and OpenAI'sexternalAgentConfig/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
keepDeveloperMessagesdefaults totargetProvider !== 'claude'inplanConversationContext. That is a hard-codedprovider string in a planner that otherwise derives everything from
compactionPortability— the durable shape is a capability flag on the projectorprofile, 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
9c99db00,96c5c146). No lockfile resync was needed, and that is checked ratherthan assumed:
git diff <old>..<new> -- package.jsonis empty in bothsubmodules (parser
2fdfc09a..9c99db00, codex-headless4ff1fd91..96c5c146).file:deps embed the package tree inpackage-lock.json, so a submodule bumpthat changes a package's own
package.jsonbreaksnpm ciin CI before tscever runs; neither of these does.
7754c04a): the designcites its current remote compaction, rollout persistence and external-agent
import code, and the April checkout predates all three.
package-lock.jsonis not in this branch. A lock-only resync produced a512-line diff consisting entirely of nested
vitest/@esbuildoptional-archentries; the identical diff reproduces on
mainwith no submodule involvement,so it is local npm-version churn, not this branch's, and was reverted.
Linked issues
Known limitations 10). feat(provider-switch): switch agents off an exhausted provider without a source-side summarization turn #821's body carries an "Amended 2026-09-07" note saying
the same thing, and a comment on it explains why.
live probe, deferred out of this PR by the user's decision on 2026-09-07.
— open follow-up found in review: the projectors and
fitConversationToCharacterBudget'spreviousSummaryare availability-blind,and
conversationAfterLatestPortableCompactionreturns the whole conversationwhen the latest carrier is
rejected/incompletewhile an earlier portableone exists. Defended host-side by this PR; the durable fix is in the parser.
9c99db00(closes parser#24)96c5c146(closes codex-headless#46)/responsesreply never reaches classification, so a bodyless 429 publishes noapi_errorat all and is reaped as a timeout.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.
npm run typecheckecho EXIT_CODE=$?to confirm the silenttsc -breally was clean)npx tsc -p tsconfig.node.json --pretty falsenpx tsc -p tsconfig.web.json --pretty falsenpm run check:keybindingsnpm run test:packageelectron-vite build+verify-build-output.mjs("Application build contains every required entry point")npm test(all projects)Both
tscprojects are run separately and deliberately:electron-vite buildand
vitestdo not type-check, and the web project caught a'shrinking'uniongap 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..HEADforeach failing path and its directory is empty, and every one was re-run in
isolation on both this branch and
main:lazy-prose/index.renderer.test.tsx(adynamic-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~/.claudetranscript that has since been deleted on this machine; it fails on
maintoo).WorkflowViewSelector.renderer.test.tsx"bounds history detail reads…" andstore.test.ts"starts in the command list", both 5 s timeouts.both checkouts:
store.test.ts× 2 (8/8 twice on branch and on main),store.performance.test.ts(12/12 twice on each,beforeAllnowhere near its10 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/orsrc/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):
NODE_ENV=test npx vitest run --project unit src/main/providerSwitchNODE_ENV=test npx vitest run --project renderer providerSwitchCore.renderer.test.ts useIpcSubscriptions.renderer.test.tsxNODE_ENV=test npx vitest run --project renderer src/renderer/src/workspace/hook/actionsNODE_ENV=test npx vitest run --project unit src/renderer/.../hook/actions src/main/usage src/sharednpx tsc -p tsconfig.node.json --pretty falsenpx tsc -p tsconfig.web.json --pretty falseNew coverage worth naming: the shrink ladder and planner outcomes against
decoded Stage 0 fixtures (never invented literals);
switchProvidercasesasserting
compactSourceis not called on the default path, that thecompaction-first fixture reports its disclosure line while the majority-shape one
reports
shrinkSummary: null, and that the opt-in path aborts on arejectedcarrier before projecting;
compactBeforeSwitchrejected-carrier andapi-error-after-baseline cases;
compactOnArrivalon both branches with a fakeSessionManager;exhaustionover the real usage payloads already in the suite;both
limitHitreducers in the injected-feed harness, including that a replayedburst 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_outputwhose 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
continuing automatically" screen, so nothing proves
processActivestays trueunder the banner.
isLimitIdleis therefore defensive: it can only widen theguard, never narrow it, so being wrong costs nothing.
tail contains a genuine rate-limit carrier reads as limit-idle, because
turnStartedAtis null there. That is the intended reading of "no turn toprotect", and the guard is only consulted when something else already says the
pane is busy. If it proves wrong, the fix belongs in
isLimitIdle.keepRecentTurns: 3/maxInputChars: 8,000are estimates, notmeasurements. 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.
production. Only the Claude decoder classifies
opaque/api_error, so aCodex 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.
compactOnArrivaloriginally importedtype ResumePromptStatefromclaude-code-headless, which would have been the first such import insrc/main— Plug-and-play provider architecture: modularize and standardize all provider implementations #394 phase 2a deliberately removed them all (see the WHY block atthe top of
sessionManager.ts). It now readsAgentResumePromptStatefrom@shared/types/session.ts, which is field-identical and is the shapeSessionManageractually emits, so the module's declaration matches its realsource. Nothing about the runtime behaviour changed.
one is running returns
{ ok: false, message: 'Arrival compaction already running.' }; a pane that was merely slow past 30 s is reported as a failureand the user runs
/compactby hand.ProviderIdis an openstring.targetProvider !== 'claude'istherefore a comparison against an unconstrained type; a typo produces
developer retention rather than a compile error.
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.
/model sonnetis hard-coded inthe 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 anew directory shape for
src/main; it ships in no bundle. And OpenCode'sprojector was not audited for the same developer-role defect that motivated
the Claude rule.
#833, not met
here.
Fixes #821is kept because everything else on that issue'sacceptance 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 withmeasurements. 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):
bulkProviderSwitch.ts), so the user must switch panes back by hand.incompleteorrejectedfirst carrier.limitHitonturn_startedmeans 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