Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8269ada
docs(provider-switch): plan quota-independent switching without sourc…
Juliusolsson05 Sep 6, 2026
7754c04
chore(vendor): move vendored Codex source to upstream main
Juliusolsson05 Sep 6, 2026
6816674
docs(provider-switch): record approval, the #810 landing and lifted o…
Juliusolsson05 Sep 7, 2026
a48a1c0
docs(provider-switch): record transcript census for the shrink ladder
Juliusolsson05 Sep 7, 2026
3099251
docs(provider-switch): state ladder conclusions from populations, not…
Juliusolsson05 Sep 7, 2026
18f1972
docs(provider-switch): add the majority-shape fixtures to the census
Juliusolsson05 Sep 7, 2026
4e01fc4
build(deps): point at the parser and codex-headless branches for quot…
Juliusolsson05 Sep 7, 2026
b4bc70e
feat(provider-switch): default to a transaction that never needs the …
Juliusolsson05 Sep 7, 2026
d71687d
fix(provider-switch): abort source compaction on a usage limit instea…
Juliusolsson05 Sep 7, 2026
6ac10ad
build(deps): point at the parser's developer-retention and protection…
Juliusolsson05 Sep 7, 2026
cb4361b
feat(provider-switch): compact imported history on arrival with the t…
Juliusolsson05 Sep 7, 2026
1c73bfd
feat(usage): scope limit rows and derive a structural exhaustion signal
Juliusolsson05 Sep 7, 2026
d44c0da
fix(provider-switch): accept the shrinking phase in the renderer and …
Juliusolsson05 Sep 7, 2026
1b70b66
feat(workspace): read exhaustion in the bulk switch, ask once, and la…
Juliusolsson05 Sep 7, 2026
4ddb558
fix(provider-switch): gate arrival compaction on readiness and report…
Juliusolsson05 Sep 7, 2026
a12b4cf
build(deps): point at the merged parser and codex-headless mains
Juliusolsson05 Sep 7, 2026
bcb82bb
Merge remote-tracking branch 'origin/main' into feat/quota-independen…
Juliusolsson05 Sep 7, 2026
f604e61
fix(workspace): gate usage polling on the modal, derive the limit sig…
Juliusolsson05 Sep 7, 2026
c32b42c
docs(provider-switch): describe quota-independent outcomes, opt-in so…
Juliusolsson05 Sep 7, 2026
cc0e908
fix(provider-switch): disclose compaction-first raw carries, keep res…
Juliusolsson05 Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
473 changes: 473 additions & 0 deletions docs/decomposition/evidence/provider-switch/census.md

Large diffs are not rendered by default.

366 changes: 366 additions & 0 deletions docs/decomposition/quota-independent-provider-switch.md

Large diffs are not rendered by default.

555 changes: 504 additions & 51 deletions docs/design/provider-switching.md

Large diffs are not rendered by default.

1,976 changes: 1,976 additions & 0 deletions docs/superpowers/plans/2026-09-05-quota-independent-provider-switch.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/agent-transcript-parser
Submodule agent-transcript-parser updated 25 files
+42 −0 fixtures/evidence/observed-sequences/claude-sequence-oversized-turns/manifest.json
+1,461 −0 fixtures/evidence/observed-sequences/claude-sequence-oversized-turns/source.jsonl
+42 −0 fixtures/evidence/observed-sequences/claude-sequence-oversized/manifest.json
+67 −0 fixtures/evidence/observed-sequences/claude-sequence-oversized/source.jsonl
+42 −0 fixtures/evidence/observed-sequences/claude-sequence-rate-limit/manifest.json
+71 −0 fixtures/evidence/observed-sequences/claude-sequence-rate-limit/source.jsonl
+42 −0 fixtures/evidence/observed-sequences/codex-sequence-compacted-history/manifest.json
+83 −0 fixtures/evidence/observed-sequences/codex-sequence-compacted-history/source.jsonl
+42 −0 fixtures/evidence/observed-sequences/codex-sequence-compacted-multi/manifest.json
+154 −0 fixtures/evidence/observed-sequences/codex-sequence-compacted-multi/source.jsonl
+42 −0 fixtures/evidence/observed-sequences/codex-sequence-compacted-once/manifest.json
+52 −0 fixtures/evidence/observed-sequences/codex-sequence-compacted-once/source.jsonl
+10 −0 src/claude/conversation/decode.ts
+1 −0 src/index.ts
+102 −2 src/operations/compaction.ts
+159 −52 src/operations/contextBudget.ts
+87 −0 src/operations/estimate.ts
+804 −0 src/operations/shrink.ts
+234 −3 testing/corpus/extractObservedSequences.ts
+46 −3 testing/corpus/observedFixtures.corpus.test.ts
+203 −0 testing/engine/compaction.test.ts
+164 −0 testing/engine/contextBudget.test.ts
+110 −0 testing/engine/fixtureConversations.ts
+70 −0 testing/engine/nativeResumeProjection.test.ts
+462 −0 testing/engine/shrink.test.ts
54 changes: 53 additions & 1 deletion src/main/ipc/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import type { MessageBoxOptions } from 'electron'
import type { SessionManager } from '@main/sessionManager.js'

import { switchProvider } from '@main/providerSwitch/switchProvider.js'
import type { SwitchContextPolicy } from '@main/providerSwitch/switchProvider.js'
import { compactSourceBeforeSwitch } from '@main/providerSwitch/compactBeforeSwitch.js'
import { compactOnArrival } from '@main/providerSwitch/compactOnArrival.js'
import type { CompactOnArrivalRequest } from '@main/providerSwitch/compactOnArrival.js'
import { duplicateSession } from '@main/providerSwitch/duplicateSession.js'
import {
listRewindPrompts,
Expand Down Expand Up @@ -45,6 +48,8 @@ export function registerProviderIpc(manager: SessionManager): void {
sourceCwd?: string
targetCwd?: string
sourceSessionId?: string
contextPolicy?: Partial<SwitchContextPolicy>
sourceCompactionConfirmed?: boolean
},
) => {
const lockId = params.sourceSessionId ?? `${params.sourceKind}:${params.sourceProviderSessionId}`
Expand All @@ -55,7 +60,19 @@ export function registerProviderIpc(manager: SessionManager): void {
try {
return await switchProvider(params, {
compactSource: async (request, plan) => {
if (plan.kind === 'requires-compaction') {
// WHY the confirmation can arrive already given: this dialog is
// per-agent, and the bulk switch confirms ONCE for a batch before
// fanning out one request per agent. Seventeen modal dialogs in a
// row is not consent, it is a thing users click through. The gate
// stays here rather than moving into switchProvider because the
// dialog needs the requesting window, which only this handler has.
//
// Nothing else changed for the opt-in path: a caller that does not
// set the flag still gets the native confirmation it always got,
// and this callback is unreachable at all under the default policy
// — switchProvider never invokes compactSource when
// allowSourceTurns is false.
if (plan.kind === 'requires-compaction' && !params.sourceCompactionConfirmed) {
const window = BrowserWindow.fromWebContents(_evt.sender)
const options: MessageBoxOptions = {
type: 'warning',
Expand Down Expand Up @@ -100,6 +117,41 @@ export function registerProviderIpc(manager: SessionManager): void {
},
)

// Arrival compaction — the second half of a quota-independent switch, run on
// the pane the switch just created (see providerSwitch/compactOnArrival.ts).
//
// WHY a lock keyed on the NEW session id, separate from `switchesInFlight`
// above: that lock is keyed on the SOURCE and is released the moment the
// transaction returns, which is before the renderer has even called
// `replaceSession`. Two arrival compactions on one pane would send `/compact`
// twice and then race each other's wait for "a compaction newer than the
// baseline" — the second would accept the first one's carrier and report
// success for work it did not do.
//
// WHY this never throws across IPC: the pane is already live with its full
// history. Every failure comes back as `{ ok: false, message }` for the
// caller to show as a toast; see the module header.
const arrivalsInFlight = new Set<string>()
ipcMain.handle('session:compact-after-switch', async (_evt, params: CompactOnArrivalRequest) => {
if (arrivalsInFlight.has(params.sessionId)) {
return { ok: false, message: 'Arrival compaction already running.' }
}
arrivalsInFlight.add(params.sessionId)
try {
return await compactOnArrival(manager, params, progress => {
// Same channel as the switch transaction's progress, addressed to the
// new session id. The renderer subscribes per session id, so one
// channel carrying both halves keeps the pane's banner continuous
// across the replacement instead of blinking between two mechanisms.
if (!_evt.sender.isDestroyed()) {
_evt.sender.send('session:provider-switch-progress', progress)
}
})
} finally {
arrivalsInFlight.delete(params.sessionId)
}
})

ipcMain.handle(
'session:duplicate',
async (
Expand Down
165 changes: 163 additions & 2 deletions src/main/providerSwitch/compactBeforeSwitch.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ConversationDocument } from 'agent-transcript-parser'
import type { ConversationDocument, ConversationEntry } from 'agent-transcript-parser'

const mocks = vi.hoisted(() => ({
read: vi.fn(),
Expand All @@ -24,7 +24,15 @@ vi.mock('@main/providerSwitch/transcriptEngine.js', () => ({
// worth paying for. Tests drive that gate through this mock; the default
// (rejecting stat) means "unknown", which keeps the decode-every-cooldown
// behaviour the pre-existing contracts below were written against.
vi.mock('node:fs/promises', () => ({ stat: mocks.stat }))
//
// Only `stat` is replaced. The rest of the module has to stay real because the
// Stage 0 fixture loader reads its `source.jsonl` files with `readFile`, and a
// whole-module factory would leave that export undefined — which is a confusing
// failure to debug from inside an unrelated wait-loop test.
vi.mock('node:fs/promises', async importOriginal => ({
...await importOriginal<typeof import('node:fs/promises')>(),
stat: mocks.stat,
}))

// WHY the poll delay advances fake time instead of sleeping: the contracts
// below are about WHEN the implementation decodes (unchanged file → no
Expand All @@ -39,9 +47,31 @@ vi.mock('node:timers/promises', () => ({
}))

import { compactSourceBeforeSwitch } from './compactBeforeSwitch.js'
import { loadFixtureConversation } from './testing/fixtureConversations.js'

const T0 = 1_700_000_000_000

// The exact wrapper Claude Code puts around every persisted compaction summary
// (vendor/claude-code-src/full/services/compact/prompt.ts getCompactUserSummaryMessage).
// It matters here because it is what pushes the limit text ~150 characters into
// the carrier, which is why the parser's rate-limit guard matches at any line
// start rather than at position 0.
const CLAUDE_CONTINUATION_PREAMBLE = 'This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.'

// WHY this text is assembled here instead of read out of
// `claude-sequence-rate-limit`, which is the fixture that recorded the hazard:
// redaction replaces every private scalar with the string "fixture text", so
// the fixture's own limit message decodes as "fixture text" and would not be
// rejected by anything. The census (docs/decomposition/evidence/provider-switch/
// census.md, "what does not survive redaction") keeps the observed TEMPLATE for
// this reason, and this is that template behind the real preamble — the shape
// Task 1 pinned `compactionAvailability` against. The fixture is still used
// below for the api_error case, where the classification survives redaction.
const RATE_LIMIT_CARRIER = [
CLAUDE_CONTINUATION_PREAMBLE,
"You've hit your monthly spend limit · raise it at claude.ai/settings/usage?from=cc_cli_limit_message · your session limit resets <time> (<timezone>)",
].join('\n\n')

describe('compactSourceBeforeSwitch', () => {
beforeEach(() => {
vi.useFakeTimers({ now: T0 })
Expand Down Expand Up @@ -282,6 +312,112 @@ describe('compactSourceBeforeSwitch', () => {
expect(Date.now() - start).toBeGreaterThanOrEqual(300_000)
})

// #820. Both cases are the same disaster in two shapes: `/compact` was
// already delivered, so the source's history is on its way to being replaced,
// and the provider answered with a usage limit instead of a summary. The wait
// must fail while the pane is still on the source — every alternative
// (accepting the limit text as a summary, or timing out) ends with a switch
// that carried nothing and a source that can no longer show what was lost.

it('fails immediately when the compaction carrier is a rate-limit message', async () => {
mocks.read
.mockResolvedValueOnce(conversation([]))
.mockResolvedValueOnce(conversation([{
...compaction(RATE_LIMIT_CARRIER, 900),
summarySource: 'carrier' as const,
}]))
const manager = claudeManager()

await expect(compactSourceBeforeSwitch(manager as never, claudeRequest(), requiresCompactionPlan()))
.rejects.toThrow(/usage-limit message where its compaction summary should be/)
// Two decodes, not a five-minute wait: the carrier is a definitive answer,
// so there is nothing to keep polling for.
expect(mocks.read).toHaveBeenCalledTimes(2)
})

it('fails immediately when an API error lands after /compact was sent', async () => {
// Here the fixture carries the evidence: `isApiErrorMessage: true` is a
// boolean, so Claude decode still classifies these records as
// `opaque`/`api_error` after redaction even though their text is gone.
//
// WHY this asserts the GENERIC wording: redaction is exactly why. The
// record's `error` field and its assistant text both decode to "fixture
// text", so nothing here proves a usage limit, and claiming one would send
// a user whose connection dropped to a billing page. The case below covers
// the limit wording, with the observed template as its evidence.
const rateLimited = await loadFixtureConversation('claude-sequence-rate-limit', 'claude')
const apiErrors = rateLimited.entries
.filter((entry): entry is Extract<ConversationEntry, { kind: 'opaque' }> => (
entry.kind === 'opaque' && entry.nativeType === 'api_error'
))
// Re-addressed past the baseline the wait captured, which is what
// "landed after /compact" means to `findApiErrorAfterLine`.
.map(entry => ({ ...entry, source: { ...entry.source, line: 901 } }))
expect(apiErrors.length).toBeGreaterThan(0)
mocks.read
.mockResolvedValueOnce(conversation([]))
.mockResolvedValueOnce(conversation(apiErrors))
const manager = claudeManager()

await expect(compactSourceBeforeSwitch(manager as never, claudeRequest(), requiresCompactionPlan()))
.rejects.toThrow(/reported an API error instead of compacting/)
})

it('names the usage limit when the api_error record carries the evidence', async () => {
// The same abort, one rung more specific. The record shape is the observed
// one (an assistant record with `isApiErrorMessage: true`, decoded to
// `opaque`/`api_error`); the TEXT is the census's observed limit template,
// supplied here for the same reason RATE_LIMIT_CARRIER is — redaction
// replaces it with "fixture text" in the committed fixture, so the fixture
// cannot exercise the branch that reads it.
mocks.read
.mockResolvedValueOnce(conversation([]))
.mockResolvedValueOnce(conversation([apiError(
"You've hit your monthly spend limit · raise it at claude.ai/settings/usage?from=cc_cli_limit_message",
901,
)]))
const manager = claudeManager()

await expect(compactSourceBeforeSwitch(manager as never, claudeRequest(), requiresCompactionPlan()))
.rejects.toThrow(/reported a usage limit instead of compacting/)
})

// The handoff turn is an ordinary turn on the same quota `/compact` just
// spent, and it runs AFTER the source history was replaced — so a limit there
// is the worst moment to sit silent for five minutes. Both cases below prove
// the probe is wired into the handoff waits, not only the compaction wait.
//
// HONEST LIMIT, stated once for both: only the Claude decoder classifies
// `opaque`/`api_error` today, so these entries are hand-built rather than
// decoded from a Codex/OpenCode fixture, and in production the guard cannot
// fire for those providers until their decoders classify errors
// (codex-headless#46 is the other half of that work). These tests pin the
// wiring and the message; they are not evidence that a Codex rollout contains
// such a record.

it('fails fast when an API error lands during the Codex portable handoff', async () => {
mocks.read
.mockResolvedValueOnce(conversation([codexCompaction(12)], 'codex'))
.mockResolvedValueOnce(conversation([codexCompaction(12), apiError('fixture text', 20)], 'codex'))
const manager = codexManager()

await expect(compactSourceBeforeSwitch(manager as never, codexRequest(), requiresPortableHandoffPlan()))
.rejects.toThrow(/reported an API error instead of a portable handoff/)
// Two decodes, not a 300 s wait.
expect(mocks.read).toHaveBeenCalledTimes(2)
})

it('fails fast when an API error lands during the OpenCode portable handoff', async () => {
mocks.read
.mockResolvedValueOnce(conversation([], 'opencode'))
.mockResolvedValueOnce(conversation([apiError('fixture text', 5)], 'opencode'))
const manager = opencodeManager()

await expect(compactSourceBeforeSwitch(manager as never, opencodeRequest(), requiresCompactionPlan()))
.rejects.toThrow(/reported an API error instead of a portable handoff/)
expect(mocks.read).toHaveBeenCalledTimes(2)
})

it('re-locates the transcript when its pinned path stops resolving', async () => {
mocks.read
.mockResolvedValueOnce(conversation([]))
Expand Down Expand Up @@ -448,6 +584,31 @@ function assistant(text: string, line: number) {
}
}

// The decoded shape of a Claude `isApiErrorMessage: true` assistant record:
// `opaque`/`api_error`, with the original record kept on `source.raw` — which
// is where the abort message looks for evidence that the error was a usage
// limit (see `describeApiErrorAbort`). `error` stays the redacted-looking
// "fixture text" unless a case overrides it, so the generic wording is the
// default here exactly as it is for the committed fixture.
function apiError(text: string, line: number) {
return {
kind: 'opaque' as const,
nativeType: 'api_error',
timestamp: null,
source: {
provider: 'claude',
line,
raw: {
type: 'assistant',
isApiErrorMessage: true,
error: 'fixture text',
message: { role: 'assistant', content: [{ type: 'text', text }] },
},
evidence: [],
},
}
}

function compaction(summary: string, line: number) {
return {
kind: 'compaction' as const,
Expand Down
Loading