From 8100b5eea5660fa1f881bf2bd868fc74377997ae Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:02:49 -0700 Subject: [PATCH 01/22] fix(claude): end a Claude start that never answers initialize after 120 s --- .../claude-agent-sdk-control-requests.ts | 2 +- .../claude-structured-session-acquisition.ts | 2 + .../claude-structured-session-startup.test.ts | 43 ++++++++++++++ .../claude-structured-session-startup.ts | 57 +++++++++++++++---- ...tured-startup-fault-is-not-an-exit.test.ts | 31 ++++++++++ 5 files changed, 122 insertions(+), 13 deletions(-) diff --git a/src/main/claude/claude-agent-sdk-control-requests.ts b/src/main/claude/claude-agent-sdk-control-requests.ts index d33131471021..97845e531985 100644 --- a/src/main/claude/claude-agent-sdk-control-requests.ts +++ b/src/main/claude/claude-agent-sdk-control-requests.ts @@ -104,7 +104,7 @@ export type ClaudeControlSurface = { ) => Promise stopTask: (taskId: string, options?: ClaudeControlOptions) => Promise supportedModels: (options?: ClaudeControlOptions) => Promise - /** Untimed: a slow start is still a start, and the child's exit closes the query under it. */ + /** Untimed here: startup's own deadline bounds it, and the child's exit closes the query. */ initializationResult: () => Promise getSettings: (options?: ClaudeControlOptions) => Promise /** The `/context` breakdown; older CLIs reject the request and the caller shows nothing. */ diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 404787620e95..a3d203461f48 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -22,6 +22,7 @@ import { restoredClaudeStructuredSessionOptions } from './claude-structured-opti import { createClaudeSessionJournalTranslator } from './claude-structured-journal-translation' import { observeClaudeFastModeFacts } from './claude-structured-session-options' import { + claudeStartupDeadlineMs, createClaudeInitProof, readClaudeStartupFacts, settleClaudeSessionStartup @@ -286,6 +287,7 @@ export async function acquireClaudeSession({ resumesTranscript: launch.resumesTranscript, inputOptions: input.options, requestTimeoutMs: deps.requestTimeoutMs, + deadlineMs: claudeStartupDeadlineMs(), emit }), isCurrent: () => sessions.get(sessionId) === session, diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index ceb9e9cdf6d6..51a5808e6789 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -8,6 +8,7 @@ import { USER_MESSAGE } from './claude-structured-session-test-support' import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' +import { CLAUDE_STARTUP_DEADLINE_MS } from './claude-structured-session-startup' type LateSettlement = Parameters< NonNullable @@ -194,6 +195,48 @@ describe('Claude structured session publishes before the CLI answers initialize' }) }) + it('lands a start whose CLI answers initialize just inside the startup deadline', async () => { + const claude = fakeClaude({ initDelayMs: CLAUDE_STARTUP_DEADLINE_MS - 1 }) + const { adapter, events } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + + await vi.advanceTimersByTimeAsync(CLAUDE_STARTUP_DEADLINE_MS) + await adapter.awaitStarted('session-1') + + expect(events.some((event) => event.type === 'started')).toBe(true) + expect(events.some((event) => event.type === 'ended')).toBe(false) + expect(claude.connections[0].closeCount).toBe(0) + await adapter.closeAll() + }) + + // A CLI alive but silent would otherwise hold every message sent to the chat forever. + it('ends a start whose CLI never answers initialize at the deadline, and ignores a late answer', async () => { + const claude = fakeClaude({ initDelayMs: CLAUDE_STARTUP_DEADLINE_MS + SLOW_INIT_MS }) + const { adapter, events } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + + await vi.advanceTimersByTimeAsync(CLAUDE_STARTUP_DEADLINE_MS - 1) + expect(events.some((event) => event.type === 'ended')).toBe(false) + expect(claude.connections[0].closeCount).toBe(0) + await vi.advanceTimersByTimeAsync(1) + await adapter.awaitStarted('session-1') + await adapter.drainObservedExits() + + // Orca ended it, so the chat says Claude couldn't start, not that it stopped on its own. + expect(events.find((event) => event.type === 'ended')).toMatchObject({ + reason: 'claude did not answer initialize within 120s', + cause: 'unexpected-exit', + failure: { kind: 'startFailed' }, + startupUnproven: true + }) + expect(claude.connections[0].closeCount).toBe(1) + + await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) + expect(events.some((event) => event.type === 'started' || event.type === 'options')).toBe(false) + expect(claude.connections[0].calls.map(({ subtype }) => subtype)).not.toContain('get_settings') + expect(claude.connections[0].sent).toEqual([]) + }) + it('ends an unauthenticated start with sign-in guidance', async () => { const claude = fakeClaude({ initAccount: { apiProvider: 'firstParty', tokenSource: 'none' } }) const { adapter, events } = startingAdapter(claude) diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index ce14a64dd2cf..bdfca39c2660 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -1,6 +1,7 @@ // What Claude reports at initialize, read after the session is already published. None of it // gates the create: a slow start is still a start, and every way it can fail (exit, auth, -// a foreign session id) faults the published session through its exit path. +// a foreign session id, no answer within the startup deadline) faults the published session +// through its exit path. import type { StructuredAgentSessionAcquireInput, @@ -58,7 +59,34 @@ export type ClaudeStartupFacts = { prepared: ReturnType } -/** Settles on the CLI's answers or on its exit; there is no startup timer. */ +/** How long the CLI has to answer initialize. Generous for a slow machine or a first run; a CLI + * that stays alive but never answers would otherwise hold the chat's messages forever. */ +export const CLAUDE_STARTUP_DEADLINE_MS = 120_000 + +/** The e2e rig shortens the deadline to capture its failure; production always uses the default. */ +export function claudeStartupDeadlineMs(): number { + const configured = Number(process.env.ORCA_E2E_CLAUDE_STARTUP_DEADLINE_MS) + return Number.isFinite(configured) && configured >= 1 && configured <= CLAUDE_STARTUP_DEADLINE_MS + ? configured + : CLAUDE_STARTUP_DEADLINE_MS +} + +/** Startup's answers, or a rejection once the deadline passes; a late answer is then ignored. */ +function withinClaudeStartupDeadline(answers: Promise, deadlineMs: number): Promise { + let timer: ReturnType | undefined + const expired = new Promise((_resolve, reject) => { + timer = setTimeout( + () => + reject( + new Error(`claude did not answer initialize within ${Math.round(deadlineMs / 1000)}s`) + ), + deadlineMs + ) + }) + return Promise.race([answers, expired]).finally(() => clearTimeout(timer)) +} + +/** Settles on the CLI's answers, on its exit, or at the startup deadline. */ export async function readClaudeStartupFacts(input: { connection: ClaudeStreamJsonConnection initProof: ClaudeInitProof @@ -67,18 +95,23 @@ export async function readClaudeStartupFacts(input: { resumesTranscript: boolean inputOptions: StructuredAgentSessionAcquireInput['options'] requestTimeoutMs: number | undefined + deadlineMs: number emit: (event: ClaudeStructuredSessionEvent) => void }): Promise { - const [initialization, init] = await Promise.all([ - input.connection.initializationResult().then((result) => { - const authError = claudeInitializationAuthError(result) - if (authError) { - throw authError - } - return result - }), - input.initProof.promise - ]) + // The CLI's first answer has no request deadline of its own; the reads after it do. + const [initialization, init] = await withinClaudeStartupDeadline( + Promise.all([ + input.connection.initializationResult().then((result) => { + const authError = claudeInitializationAuthError(result) + if (authError) { + throw authError + } + return result + }), + input.initProof.promise + ]), + input.deadlineMs + ) if (input.connection.closed) { throw new Error('claude session closed before startup completed') } diff --git a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts index 863cbaeaebf9..711a78ceff0b 100644 --- a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts +++ b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts @@ -23,6 +23,7 @@ let operations = 0 afterEach(async () => { vi.restoreAllMocks() + vi.unstubAllEnvs() await claude.dispose() claude = createScriptedClaudeRuntime([SESSION]) }) @@ -116,6 +117,36 @@ describe('a Claude start that Orca fails while the CLI is still running', () => expect(claude.child(SESSION).calls).not.toContain('send') }) + it('rejects a held message as a start that could not happen when the CLI never answers initialize', async () => { + vi.stubEnv('ORCA_E2E_CLAUDE_STARTUP_DEADLINE_MS', '50') + claude.behave(SESSION, { initHangs: true }) + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const held = await send(host, 'hello') + await released(host) + + await vi.waitFor(async () => + expect(await submission(host, held)).toMatchObject({ + dispatchState: 'rejected', + reason: expect.stringMatching(/^Claude couldn't start\./), + rejection: { kind: 'startFailed' } + }) + ) + expect(await failureRows(host)).toEqual([ + { text: expect.stringMatching(/^Claude couldn't start\./), kind: 'startFailed' } + ]) + expect(claude.child(SESSION).connection.closed).toBe(true) + + // The CLI answering after the deadline neither starts the chat nor takes the message. + claude.child(SESSION).answerInit() + await waitForStructuredAgentSessionRecovery() + expect(claude.child(SESSION).calls).not.toContain('get_settings') + expect(claude.child(SESSION).calls).not.toContain('send') + expect(claude.children(SESSION)).toHaveLength(1) + }) + it('still says Claude stopped when the CLI exits on its own before its start lands', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() From a9144f4c43233ab604adb0d9cb8a9ec8a4b03367 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:09:07 -0700 Subject: [PATCH 02/22] Read the Claude startup deadline inside startup; fix a stale test comment --- src/main/claude/claude-structured-session-acquisition.ts | 2 -- src/main/claude/claude-structured-session-startup.ts | 5 ++--- ...ude-structured-startup-unanswered-control-request.test.ts | 5 +++-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index a3d203461f48..404787620e95 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -22,7 +22,6 @@ import { restoredClaudeStructuredSessionOptions } from './claude-structured-opti import { createClaudeSessionJournalTranslator } from './claude-structured-journal-translation' import { observeClaudeFastModeFacts } from './claude-structured-session-options' import { - claudeStartupDeadlineMs, createClaudeInitProof, readClaudeStartupFacts, settleClaudeSessionStartup @@ -287,7 +286,6 @@ export async function acquireClaudeSession({ resumesTranscript: launch.resumesTranscript, inputOptions: input.options, requestTimeoutMs: deps.requestTimeoutMs, - deadlineMs: claudeStartupDeadlineMs(), emit }), isCurrent: () => sessions.get(sessionId) === session, diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index bdfca39c2660..fea4f848f0e4 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -64,7 +64,7 @@ export type ClaudeStartupFacts = { export const CLAUDE_STARTUP_DEADLINE_MS = 120_000 /** The e2e rig shortens the deadline to capture its failure; production always uses the default. */ -export function claudeStartupDeadlineMs(): number { +function claudeStartupDeadlineMs(): number { const configured = Number(process.env.ORCA_E2E_CLAUDE_STARTUP_DEADLINE_MS) return Number.isFinite(configured) && configured >= 1 && configured <= CLAUDE_STARTUP_DEADLINE_MS ? configured @@ -95,7 +95,6 @@ export async function readClaudeStartupFacts(input: { resumesTranscript: boolean inputOptions: StructuredAgentSessionAcquireInput['options'] requestTimeoutMs: number | undefined - deadlineMs: number emit: (event: ClaudeStructuredSessionEvent) => void }): Promise { // The CLI's first answer has no request deadline of its own; the reads after it do. @@ -110,7 +109,7 @@ export async function readClaudeStartupFacts(input: { }), input.initProof.promise ]), - input.deadlineMs + claudeStartupDeadlineMs() ) if (input.connection.closed) { throw new Error('claude session closed before startup completed') diff --git a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts index 56ba00618836..2be1755199e5 100644 --- a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts +++ b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts @@ -1,5 +1,6 @@ -// A Claude start has no deadline of its own, but each option write the restore replays, and -// startup's own settings read, is a control request under the ordinary request deadline. A CLI +// Only a Claude start's initialize answer runs under the startup deadline; each option write the +// restore replays, and startup's own settings read, is a control request under the ordinary +// request deadline. A CLI // that answers initialize and then never answers one of those used to fault the whole session // when that deadline fired: a start that was merely slow died with the deadline's error as its // cause. Now the unanswered request is skipped and startup lands on the CLI's own values, while From 37254a1a669af1b0bf64fdef201e65cbb65bb700 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:42:17 -0700 Subject: [PATCH 03/22] fix(claude): start a Claude chat on its initialize answer, not on a frame only a SessionStart hook sends Startup waited for system/init or a SessionStart hook frame as well as the initialize answer. Before the first turn only a SessionStart hook sends one, and Orca adds that hook only through its optional status hooks, so with them off the first message was held forever. Startup now lands on the initialize answer; a start frame already seen is still checked, and one naming another session ends a started session. The deadline drops to 90 s so it fires inside the host's 120 s start wait. --- .../claude-stream-json-connection.test.ts | 4 +- .../claude/claude-structured-init-proof.ts | 9 +- .../claude-structured-session-acquisition.ts | 4 +- .../claude-structured-session-startup.test.ts | 36 +++++- .../claude-structured-session-startup.ts | 108 ++++++++++++------ ...e-structured-send-held-for-startup.test.ts | 15 +++ ...ed-claude-scripted-runtime-test-support.ts | 8 +- 7 files changed, 144 insertions(+), 40 deletions(-) diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 082ed4c12ce7..4e68f621df04 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -577,11 +577,11 @@ describe('Claude stream-json connection', () => { const init = { providerSessionId: SESSION_ID, uuid: null, model: null, message: {} } // With no ambient auth, every true below can only have come from the CLI's settings. - expect(claudeAuthDiagnostic(init, null)).toMatchObject({ + expect(claudeAuthDiagnostic(null, init, null)).toMatchObject({ baseUrlConfigured: false, authTokenConfigured: false }) - const diagnostic = claudeAuthDiagnostic(init, await connection.getSettings()) + const diagnostic = claudeAuthDiagnostic(null, init, await connection.getSettings()) expect(diagnostic).toMatchObject({ baseUrlConfigured: true, authTokenConfigured: true, diff --git a/src/main/claude/claude-structured-init-proof.ts b/src/main/claude/claude-structured-init-proof.ts index ae2ee5896a8b..d1a066493311 100644 --- a/src/main/claude/claude-structured-init-proof.ts +++ b/src/main/claude/claude-structured-init-proof.ts @@ -81,11 +81,16 @@ export function claudeInitializationAuthError( } export function claudeAuthDiagnostic( - init: ClaudeInitObservation, + initialization: unknown, + init: ClaudeInitObservation | null, settings: unknown ): ClaudeAuthDiagnostic { const env = isRecord(settings) && isRecord(settings.env) ? settings.env : {} - const apiKeySource = readClaudeFrameString(init.message, 'apiKeySource') + const account = + isRecord(initialization) && isRecord(initialization.account) ? initialization.account : {} + const apiKeySource = + readClaudeFrameString(account, 'apiKeySource') ?? + (init ? readClaudeFrameString(init.message, 'apiKeySource') : null) const configured = (key: string): boolean => (typeof env[key] === 'string' && (env[key] as string).trim().length > 0) || Boolean(process.env[key]?.trim()) diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 404787620e95..645c5db1354d 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -91,10 +91,10 @@ export async function acquireClaudeSession({ const onMessage = (message: Record): void => { const init = readClaudeInit(message) if (readClaudeFrameString(message, 'session_id') !== expectedProviderSessionId) { - // An init proof for another (or unnamed) provider must fail acquisition + // An init proof for another (or unnamed) provider must fail the start or end the session // promptly, while ordinary foreign frames stay quarantined silently. if (init || (message.type === 'system' && message.subtype === 'init')) { - initProof.reject(new Error('claude provider session expected')) + initProof.refuse() } return } diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index 51a5808e6789..88e26fd86ff7 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -224,7 +224,7 @@ describe('Claude structured session publishes before the CLI answers initialize' // Orca ended it, so the chat says Claude couldn't start, not that it stopped on its own. expect(events.find((event) => event.type === 'ended')).toMatchObject({ - reason: 'claude did not answer initialize within 120s', + reason: `claude did not answer initialize within ${CLAUDE_STARTUP_DEADLINE_MS / 1000}s`, cause: 'unexpected-exit', failure: { kind: 'startFailed' }, startupUnproven: true @@ -237,6 +237,40 @@ describe('Claude structured session publishes before the CLI answers initialize' expect(claude.connections[0].sent).toEqual([]) }) + // Only a SessionStart hook sends a start frame before the first turn; without one, the + // initialize answer is the whole start. + it('lands a start whose CLI sends no start frame before its first turn', async () => { + const claude = fakeClaude({ initProof: 'none' }) + const { adapter, events } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + await adapter.awaitStarted('session-1') + + expect(events.some((event) => event.type === 'started')).toBe(true) + await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) + expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) + await adapter.closeAll() + }) + + it('ends a started session whose first frame names another provider session', async () => { + const claude = fakeClaude({ initProof: 'none' }) + const { adapter, events } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + await adapter.awaitStarted('session-1') + + claude.connections[0].handlers.onMessage?.({ + type: 'system', + subtype: 'init', + session_id: 'foreign-session' + }) + await adapter.drainObservedExits() + + expect(events.find((event) => event.type === 'ended')).toMatchObject({ + reason: 'claude provider session expected', + cause: 'unexpected-exit' + }) + expect(claude.connections[0].closeCount).toBe(1) + }) + it('ends an unauthenticated start with sign-in guidance', async () => { const claude = fakeClaude({ initAccount: { apiProvider: 'firstParty', tokenSource: 'none' } }) const { adapter, events } = startingAdapter(claude) diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index fea4f848f0e4..b33a4718fe9a 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -1,7 +1,7 @@ // What Claude reports at initialize, read after the session is already published. None of it // gates the create: a slow start is still a start, and every way it can fail (exit, auth, -// a foreign session id, no answer within the startup deadline) faults the published session -// through its exit path. +// a foreign session id, no initialize answer within the startup deadline) faults the published +// session through its exit path. import type { StructuredAgentSessionAcquireInput, @@ -28,23 +28,62 @@ import { readClaudeSettingsEffort } from './claude-structured-session-options' import { failClaudeStartup } from './claude-structured-session-startup-state' +import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../native-chat/agent-session-wire/structured-agent-session-send-settlement' import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' +/** The CLI's own frame naming the session it runs (system/init or a SessionStart hook). Only a + * SessionStart hook sends one before the first turn, so startup takes it when it came and never + * waits for it. */ export type ClaudeInitProof = { promise: Promise resolve: (init: ClaudeInitObservation) => void reject: (error: Error) => void + /** A frame named another provider session. */ + refuse: () => void + /** The proof seen so far, or null; throws when it was refused or the child failed first. */ + seen: () => ClaudeInitObservation | null + /** Set once startup has read the proof: a later refusal ends the session. */ + onRefusal: ((error: Error) => void) | null } export function createClaudeInitProof(): ClaudeInitProof { - let resolve = (_init: ClaudeInitObservation): void => {} - let reject = (_error: Error): void => {} - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise - reject = rejectPromise + let resolvePromise = (_init: ClaudeInitObservation): void => {} + let rejectPromise = (_error: Error): void => {} + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject }) void promise.catch(() => {}) - return { promise, resolve, reject } + let outcome: { init: ClaudeInitObservation } | { error: Error } | null = null + const reject = (error: Error): void => { + outcome ??= { error } + rejectPromise(error) + } + const proof: ClaudeInitProof = { + promise, + resolve: (init) => { + outcome ??= { init } + resolvePromise(init) + }, + reject, + refuse: () => { + // A session already proven by its own frame keeps that proof. + if (outcome && 'init' in outcome) { + return + } + const error = new Error('claude provider session expected') + reject(error) + proof.onRefusal?.(error) + }, + seen: () => { + if (outcome && 'error' in outcome) { + throw outcome.error + } + return outcome?.init ?? null + }, + onRefusal: null + } + return proof } export type StructuredAgentSessionStartedOptions = Pick< @@ -53,15 +92,17 @@ export type StructuredAgentSessionStartedOptions = Pick< > export type ClaudeStartupFacts = { - init: ClaudeInitObservation + init: ClaudeInitObservation | null + initProof: ClaudeInitProof initialization: unknown settings: unknown prepared: ReturnType } /** How long the CLI has to answer initialize. Generous for a slow machine or a first run; a CLI - * that stays alive but never answers would otherwise hold the chat's messages forever. */ -export const CLAUDE_STARTUP_DEADLINE_MS = 120_000 + * that stays alive but never answers would otherwise hold the chat's messages forever. Inside the + * host's start wait, so a command waiting on this start hears its failure, not a timeout. */ +export const CLAUDE_STARTUP_DEADLINE_MS = STRUCTURED_AGENT_SESSION_START_WAIT_MS - 30_000 /** The e2e rig shortens the deadline to capture its failure; production always uses the default. */ function claudeStartupDeadlineMs(): number { @@ -86,7 +127,7 @@ function withinClaudeStartupDeadline(answers: Promise, deadlineMs: number) return Promise.race([answers, expired]).finally(() => clearTimeout(timer)) } -/** Settles on the CLI's answers, on its exit, or at the startup deadline. */ +/** Settles on the CLI's initialize answer, on its exit, or at the startup deadline. */ export async function readClaudeStartupFacts(input: { connection: ClaudeStreamJsonConnection initProof: ClaudeInitProof @@ -98,17 +139,14 @@ export async function readClaudeStartupFacts(input: { emit: (event: ClaudeStructuredSessionEvent) => void }): Promise { // The CLI's first answer has no request deadline of its own; the reads after it do. - const [initialization, init] = await withinClaudeStartupDeadline( - Promise.all([ - input.connection.initializationResult().then((result) => { - const authError = claudeInitializationAuthError(result) - if (authError) { - throw authError - } - return result - }), - input.initProof.promise - ]), + const initialization = await withinClaudeStartupDeadline( + input.connection.initializationResult().then((result) => { + const authError = claudeInitializationAuthError(result) + if (authError) { + throw authError + } + return result + }), claudeStartupDeadlineMs() ) if (input.connection.closed) { @@ -119,22 +157,21 @@ export async function readClaudeStartupFacts(input: { sessionId: input.sessionId, models: readClaudeModels(initialization) }) - if (init.providerSessionId !== input.providerSessionId) { - throw new Error( - `claude proved session ${init.providerSessionId}, expected ${input.providerSessionId}` - ) - } + input.initProof.seen() const settings = await readClaudeStructuredSessionSettings( input.connection, input.requestTimeoutMs ) + // Read again: a frame naming another session may have come during the settings read. + const init = input.initProof.seen() input.emit({ type: 'auth-diagnostic', sessionId: input.sessionId, - diagnostic: claudeAuthDiagnostic(init, settings) + diagnostic: claudeAuthDiagnostic(initialization, init, settings) }) return { init, + initProof: input.initProof, initialization, settings, prepared: prepareClaudeStructuredSessionAcquisitionOptions({ @@ -151,7 +188,7 @@ function applyClaudeStartupFacts(session: ClaudeSession, facts: ClaudeStartupFac const effort = readClaudeSettingsEffort(settings) const published = claudeStructuredSessionPublicationOptions(prepared) // A turn's own init frame may already have reported the running model. - if (init.model && session.reportedOptions.model === undefined) { + if (init?.model && session.reportedOptions.model === undefined) { session.reportedOptions.model = init.model session.reportedModelMutation = session.optionMutationSequence } @@ -170,10 +207,10 @@ function applyClaudeStartupFacts(session: ClaudeSession, facts: ClaudeStartupFac session.fastModeState ??= published.fastModeState session.fastModeDisabledReason ??= published.fastModeDisabledReason session.options = prepared.options - session.capabilities = readClaudeCapabilities(session.capabilities, initialization, init.message) + session.capabilities = readClaudeCapabilities(session.capabilities, initialization, init?.message) // A catalog frame that streamed in after publish is newer than the initialize answer. if (session.commands.commands === undefined) { - session.commands = new ClaudeSlashCommandCatalog(init.message, initialization) + session.commands = new ClaudeSlashCommandCatalog(init?.message, initialization) } session.events?.publish() } @@ -216,6 +253,13 @@ export async function settleClaudeSessionStartup(input: { if (superseded()) { return } + // From here no read of the proof is pending, so a frame naming another session ends it. + facts.initProof.onRefusal = (error) => { + failClaudeStartup(session, error) + if (input.isCurrent()) { + input.fault(error) + } + } applyClaudeStartupFacts(session, facts) await restoreClaudeStructuredSessionOptions(session, input.requestTimeoutMs) if (!superseded()) { diff --git a/src/main/runtime/claude-structured-send-held-for-startup.test.ts b/src/main/runtime/claude-structured-send-held-for-startup.test.ts index efb8d3cde16a..d5f7c67a1e4f 100644 --- a/src/main/runtime/claude-structured-send-held-for-startup.test.ts +++ b/src/main/runtime/claude-structured-send-held-for-startup.test.ts @@ -143,6 +143,21 @@ describe('a send while the first Claude start is still answering initialize', () expect(await statusRows(host)).toEqual([]) }) + // Only a SessionStart hook makes the CLI name its session before the first turn, and that hook + // comes from status hooks a user can turn off; the initialize answer alone starts the chat. + it('is written once the CLI answers initialize, with no start frame before the first turn', async () => { + claude.behave(SESSION, { initHangs: true, sendsNoStartFrame: true }) + const host = await claude.install() + await host.attach(CALLER, claude.attachParams(SESSION, null)) + + await send(host, 'hello') + claude.child(SESSION).answerInit() + + await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + expect(claude.children(SESSION)).toHaveLength(1) + expect(await statusRows(host)).toEqual([]) + }) + it('is rejected with the diagnostic when the CLI dies first, and restarts nothing', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index 7a69cbe9c86d..78f5d686952c 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -48,6 +48,8 @@ export type ScriptedClaudeBehavior = { optionWritesFail?: boolean /** The init frame names another provider session than the one launched. */ initNamesForeignSession?: boolean + /** No start frame before the first turn, as with no SessionStart hook configured. */ + sendsNoStartFrame?: boolean } export type ScriptedClaudeChild = { @@ -123,7 +125,10 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { exitVerdict: { root: 'live', tree: 'unverifiable' }, initializationResult: () => { const initialized = { models: [{ value: 'sonnet', displayName: 'Sonnet' }] } - const announce = (): void => + const announce = (): void => { + if (behavior.sendsNoStartFrame) { + return + } handlers.onMessage?.({ type: 'system', subtype: 'init', @@ -131,6 +136,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { model: 'claude-sonnet-5', apiKeySource: 'none' }) + } if (behavior.initHangs) { return new Promise((resolve, reject) => { failInit = reject From 9a48934670a409185a45b88d371b58169afd95ee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:00:19 -0700 Subject: [PATCH 04/22] fix(claude): time the Claude start by silence, and fail it at once on another session's frame Claude answers initialize only after its SessionStart hooks finish, so a total-time deadline would fail every start behind a slow hook. Each start frame now restarts the clock. A frame naming another session fails a start still waiting on initialize at once, as before. --- .../claude-structured-session-startup.test.ts | 48 +++++++++++++++++++ .../claude-structured-session-startup.ts | 39 +++++++++++---- .../claude-structured-session-test-support.ts | 10 ++-- 3 files changed, 85 insertions(+), 12 deletions(-) diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index 88e26fd86ff7..17e4deecb9e1 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -5,6 +5,7 @@ import { adapterAtPublishFor, fakeClaude, identityFor, + PROVIDER_SESSION_ID, USER_MESSAGE } from './claude-structured-session-test-support' import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' @@ -237,6 +238,53 @@ describe('Claude structured session publishes before the CLI answers initialize' expect(claude.connections[0].sent).toEqual([]) }) + // Claude answers initialize only once its SessionStart hooks finish; their frames show it is alive. + it('restarts the deadline on each start frame, so a slow SessionStart hook is not cut off', async () => { + const claude = fakeClaude({ + initDelayMs: CLAUDE_STARTUP_DEADLINE_MS + SLOW_INIT_MS, + initProof: 'none' + }) + const { adapter, events } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + + await vi.advanceTimersByTimeAsync(CLAUDE_STARTUP_DEADLINE_MS - 1) + claude.connections[0].handlers.onMessage?.({ + type: 'system', + subtype: 'hook_started', + hook_name: 'SessionStart:startup', + session_id: PROVIDER_SESSION_ID + }) + await vi.advanceTimersByTimeAsync(SLOW_INIT_MS + 1) + await adapter.awaitStarted('session-1') + + expect(events.some((event) => event.type === 'started')).toBe(true) + expect(events.some((event) => event.type === 'ended')).toBe(false) + expect(claude.connections[0].closeCount).toBe(0) + await adapter.closeAll() + }) + + it('fails a start at once when a frame names another session before initialize answers', async () => { + const claude = fakeClaude({ initDelayMs: CLAUDE_STARTUP_DEADLINE_MS * 2, initProof: 'none' }) + const { adapter, events } = startingAdapter(claude) + await adapter.acquire(ACQUIRE) + + claude.connections[0].handlers.onMessage?.({ + type: 'system', + subtype: 'hook_started', + hook_name: 'SessionStart:startup', + session_id: 'foreign-session' + }) + await vi.advanceTimersByTimeAsync(0) + await adapter.awaitStarted('session-1') + await adapter.drainObservedExits() + + expect(events.find((event) => event.type === 'ended')).toMatchObject({ + reason: 'claude provider session expected', + failure: { kind: 'startFailed' }, + startupUnproven: true + }) + }) + // Only a SessionStart hook sends a start frame before the first turn; without one, the // initialize answer is the whole start. it('lands a start whose CLI sends no start frame before its first turn', async () => { diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index b33a4718fe9a..7effe2465fb7 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -44,6 +44,8 @@ export type ClaudeInitProof = { seen: () => ClaudeInitObservation | null /** Set once startup has read the proof: a later refusal ends the session. */ onRefusal: ((error: Error) => void) | null + /** Each proof frame, while startup waits on initialize. */ + onFrame: (() => void) | null } export function createClaudeInitProof(): ClaudeInitProof { @@ -64,6 +66,7 @@ export function createClaudeInitProof(): ClaudeInitProof { resolve: (init) => { outcome ??= { init } resolvePromise(init) + proof.onFrame?.() }, reject, refuse: () => { @@ -81,7 +84,8 @@ export function createClaudeInitProof(): ClaudeInitProof { } return outcome?.init ?? null }, - onRefusal: null + onRefusal: null, + onFrame: null } return proof } @@ -99,9 +103,9 @@ export type ClaudeStartupFacts = { prepared: ReturnType } -/** How long the CLI has to answer initialize. Generous for a slow machine or a first run; a CLI - * that stays alive but never answers would otherwise hold the chat's messages forever. Inside the - * host's start wait, so a command waiting on this start hears its failure, not a timeout. */ +/** How long the CLI may go silent before answering initialize. Generous for a slow machine or a + * first run; a CLI that stays alive but never answers would otherwise hold the chat's messages + * forever. Inside the host's start wait, so a command waiting on this start hears its failure. */ export const CLAUDE_STARTUP_DEADLINE_MS = STRUCTURED_AGENT_SESSION_START_WAIT_MS - 30_000 /** The e2e rig shortens the deadline to capture its failure; production always uses the default. */ @@ -112,19 +116,37 @@ function claudeStartupDeadlineMs(): number { : CLAUDE_STARTUP_DEADLINE_MS } -/** Startup's answers, or a rejection once the deadline passes; a late answer is then ignored. */ -function withinClaudeStartupDeadline(answers: Promise, deadlineMs: number): Promise { +/** The initialize answer, or a rejection once the CLI has said nothing for `deadlineMs`; a late + * answer is then ignored. Claude answers only after its SessionStart hooks finish, and their frames + * restart the clock, so a slow hook that reports itself is not cut off. A refused proof fails it + * at once. */ +function withinClaudeStartupDeadline( + answer: Promise, + proof: ClaudeInitProof, + deadlineMs: number +): Promise { let timer: ReturnType | undefined + let expire = (_error: Error): void => {} const expired = new Promise((_resolve, reject) => { + expire = reject + }) + const arm = (): void => { + clearTimeout(timer) timer = setTimeout( () => - reject( + expire( new Error(`claude did not answer initialize within ${Math.round(deadlineMs / 1000)}s`) ), deadlineMs ) + } + arm() + proof.onFrame = arm + const refused = proof.promise.then(() => new Promise(() => {})) + return Promise.race([answer, refused, expired]).finally(() => { + clearTimeout(timer) + proof.onFrame = null }) - return Promise.race([answers, expired]).finally(() => clearTimeout(timer)) } /** Settles on the CLI's initialize answer, on its exit, or at the startup deadline. */ @@ -147,6 +169,7 @@ export async function readClaudeStartupFacts(input: { } return result }), + input.initProof, claudeStartupDeadlineMs() ) if (input.connection.closed) { diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index dd7ad1e8d8c5..48d5fcaa70be 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -51,8 +51,9 @@ export function fakeClaude( initSessionId?: string initUuid?: string initModel?: string - /** 'session-start' (default) mirrors live: a SessionStart hook frame proves the - * session and system/init arrives only when the first command starts a cycle. + /** 'session-start' (default) mirrors live with a SessionStart hook installed: its + * frame proves the session and system/init arrives only when the first command starts a + * cycle; 'none' mirrors live without one. * 'init' emits init at startup — an UNMEASURED shape, opt-in only. */ initProof?: 'init' | 'session-start' | 'none' initAccount?: unknown @@ -126,8 +127,9 @@ export function fakeClaude( // sessions prove startup with a SessionStart hook frame instead. emitCycleInit() } else if (options.initProof !== 'none') { - // The live proof order (measured through Orca's adapter): SessionStart - // hook frames arrive first; system/init only when a cycle starts. + // The live order with a SessionStart hook installed (Orca's status hooks): + // its frames arrive first; system/init only when a cycle starts. Without + // one, nothing arrives before the first turn ('none'). handlers.onMessage?.({ type: 'system', subtype: 'hook_started', From 94de572411a32d9ab3c9c620b149bc2c075966ca Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:02:22 -0700 Subject: [PATCH 05/22] test(claude): a real Claude chat starts and answers with every hook disabled --- .../claude/claude-structured-real-cli.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index af6719938bee..97ca8a351473 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -490,6 +490,54 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { 150_000 ) + // With no SessionStart hook (Orca's status hooks off), the CLI names no session before the + // first turn; the chat must start on the initialize answer and take the message. + it.skipIf(!realClaudeAuthenticated)( + 'starts and answers with every hook disabled, so no start frame precedes the turn', + async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const cwd = await mkdtemp(join(tmpdir(), 'orca-no-hooks-')) + await mkdir(join(cwd, '.claude'), { recursive: true }) + await writeFile( + join(cwd, '.claude', 'settings.json'), + JSON.stringify({ disableAllHooks: true }) + ) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = realAdapter(providerSessionId, claudeConfigDir, events, cwd) + const frames = (): Record[] => + events.flatMap((event) => (event.type === 'message' ? [event.message] : [])) + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-no-hooks' + }) + expect(frames().filter((frame) => frame.type === 'system')).toEqual([]) + await expect( + adapter.dispatch({ + sessionId: 'real-cli-handshake', + clientMessageId: 'real-cli-no-hooks-1', + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Reply with the single word ok.' }] + }, + fence: 1 + }) + ).resolves.toEqual({ state: 'admitted' }) + await vi.waitFor( + () => expect(frames()).toContainEqual(expect.objectContaining({ type: 'result' })), + { timeout: 120_000, interval: 200 } + ) + } finally { + await adapter.closeAll() + await rm(cwd, { recursive: true, force: true }) + } + }, + 150_000 + ) + it('turns a real silent unauthenticated startup into sign-in guidance', async () => { const claudeConfigDir = await mkdtemp(join(tmpdir(), 'orca-claude-no-auth-')) const providerSessionId = randomUUID() From 91f41ad0c358ac6b1b0f13a2a47a1cfaf845ce45 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:16:06 -0700 Subject: [PATCH 06/22] Say what the start-frame re-arm covers, and check only start frames in the hook-less real test --- src/main/claude/claude-structured-real-cli.test.ts | 3 ++- .../claude/claude-structured-session-startup.test.ts | 2 +- src/main/claude/claude-structured-session-startup.ts | 12 +++++++----- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index 97ca8a351473..72fcb3036b12 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -8,6 +8,7 @@ import * as claudeConnection from './claude-stream-json-connection' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' +import { readClaudeInit } from './claude-structured-init-proof' import { realClaudeAuthenticated, realClaudeAuthStatus, @@ -513,7 +514,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { fence: 1, spawnToken: 'real-cli-no-hooks' }) - expect(frames().filter((frame) => frame.type === 'system')).toEqual([]) + expect(frames().filter((frame) => readClaudeInit(frame) !== null)).toEqual([]) await expect( adapter.dispatch({ sessionId: 'real-cli-handshake', diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index 17e4deecb9e1..71625d7ffd18 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -239,7 +239,7 @@ describe('Claude structured session publishes before the CLI answers initialize' }) // Claude answers initialize only once its SessionStart hooks finish; their frames show it is alive. - it('restarts the deadline on each start frame, so a slow SessionStart hook is not cut off', async () => { + it('restarts the deadline on each start frame, so hooks that run in turn are not cut off', async () => { const claude = fakeClaude({ initDelayMs: CLAUDE_STARTUP_DEADLINE_MS + SLOW_INIT_MS, initProof: 'none' diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index 7effe2465fb7..4187de3a1b5d 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -105,7 +105,8 @@ export type ClaudeStartupFacts = { /** How long the CLI may go silent before answering initialize. Generous for a slow machine or a * first run; a CLI that stays alive but never answers would otherwise hold the chat's messages - * forever. Inside the host's start wait, so a command waiting on this start hears its failure. */ + * forever. A CLI silent from launch fails inside the host's start wait, so a command waiting on + * this start hears its failure rather than "not started yet". */ export const CLAUDE_STARTUP_DEADLINE_MS = STRUCTURED_AGENT_SESSION_START_WAIT_MS - 30_000 /** The e2e rig shortens the deadline to capture its failure; production always uses the default. */ @@ -116,10 +117,11 @@ function claudeStartupDeadlineMs(): number { : CLAUDE_STARTUP_DEADLINE_MS } -/** The initialize answer, or a rejection once the CLI has said nothing for `deadlineMs`; a late - * answer is then ignored. Claude answers only after its SessionStart hooks finish, and their frames - * restart the clock, so a slow hook that reports itself is not cut off. A refused proof fails it - * at once. */ +/** The initialize answer, or a rejection once the CLI has sent no start frame for `deadlineMs`; a + * late answer is then ignored. Claude answers only after its SessionStart hooks finish, and each + * hook's start and end frames buy another `deadlineMs`, so hooks that run in turn are not cut off; + * one hook running past the deadline (Claude's own hook timeout is 60 s by default) still is. A + * refused proof fails it at once. */ function withinClaudeStartupDeadline( answer: Promise, proof: ClaudeInitProof, From 6b5f55cee6493edfc3547c88936a5105c0941c5a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:20:00 -0700 Subject: [PATCH 07/22] fix(native-chat): a Claude chat starts with its saved options and takes its first message at once Saved model, effort, Fast and permission mode are passed as launch options, checked against the account's cached model catalog, instead of restored by control requests after initialize. With nothing left to restore, the host no longer holds a message until the CLI answers initialize, and the 90 s startup deadline is gone. A Stop on a start that never answers ends the child and settles what it was handed as stopped. A failed result that repeats the turn's own API error reply writes no second row. --- .../claude-adapter-capture-replay.test.ts | 5 +- .../claude/claude-command-lifecycle.test.ts | 5 +- .../claude/claude-fold-steer-receipt.test.ts | 5 +- src/main/claude/claude-message-journaling.ts | 8 + src/main/claude/claude-open-turn.ts | 14 ++ src/main/claude/claude-result-journaling.ts | 5 +- .../claude-structured-acquisition-launch.ts | 25 ++- ...-structured-effort-default-at-rest.test.ts | 5 +- .../claude-structured-launch-resolution.ts | 23 +++ src/main/claude/claude-structured-options.ts | 72 ++------- .../claude-structured-prompt-ownership.ts | 3 +- .../claude-structured-real-cli-fold.test.ts | 3 +- .../claude/claude-structured-real-cli.test.ts | 3 +- ...-structured-session-acquisition-options.ts | 19 +-- .../claude-structured-session-acquisition.ts | 12 +- .../claude-structured-session-adapter.ts | 17 +-- .../claude-structured-session-options.ts | 26 ++-- ...claude-structured-session-recovery.test.ts | 5 +- ...claude-structured-session-startup-state.ts | 11 +- .../claude-structured-session-startup.test.ts | 29 ++-- .../claude-structured-session-startup.ts | 105 +++---------- .../claude-structured-session-test-support.ts | 11 +- .../claude/claude-structured-spawn-options.ts | 143 ++++++++++++++++++ ...claude-subagent-permission-request.test.ts | 5 +- ...structured-agent-session-adapter-router.ts | 3 - .../structured-agent-session-adapter.ts | 13 +- .../structured-agent-session-chat-stop.ts | 5 +- .../structured-agent-session-child-exit.ts | 3 + ...-agent-session-claude-compact-stop.test.ts | 5 +- ...-agent-session-claude-echo-working.test.ts | 5 +- ...d-agent-session-claude-queued-stop.test.ts | 5 +- ...t-session-claude-stop-ends-session.test.ts | 5 +- ...ssion-claude-stop-exit-ends-record.test.ts | 5 +- ...agent-session-claude-stop-turn-end.test.ts | 5 +- .../structured-agent-session-command-turn.ts | 4 +- ...gent-session-dead-generation-settlement.ts | 19 ++- .../structured-agent-session-delivery-loop.ts | 22 +-- ...ed-agent-session-owed-work-release.test.ts | 11 +- ...red-agent-session-provider-started.test.ts | 11 +- .../structured-agent-session-turns.ts | 6 +- 40 files changed, 389 insertions(+), 297 deletions(-) create mode 100644 src/main/claude/claude-structured-spawn-options.ts diff --git a/src/main/claude/claude-adapter-capture-replay.test.ts b/src/main/claude/claude-adapter-capture-replay.test.ts index 8e877a135a83..14cc590e2b2f 100644 --- a/src/main/claude/claude-adapter-capture-replay.test.ts +++ b/src/main/claude/claude-adapter-capture-replay.test.ts @@ -24,7 +24,8 @@ import type { import { fakeClaude, identityFor, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from './claude-structured-session-test-support' type CapturedEvent = @@ -153,7 +154,7 @@ async function replayCapture(name: string): Promise { if (event.kind === 'dispatch') { if (!startedAwaited) { // The proof frames have been delivered by now; startup can settle. - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') startedAwaited = true } const body: AgentJournalMessageItem = { diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index f4182a601747..88aa6d4a12fb 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -23,7 +23,8 @@ import { fakeClaude, identityFor, PROVIDER_SESSION_ID, - type FakeConnection + type FakeConnection, + claudeStartupSettled } from './claude-structured-session-test-support' type CapturedEvent = @@ -195,7 +196,7 @@ async function replayCapture( proofDelivered = true } else if (event.kind === 'dispatch') { if (proofDelivered) { - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') } await expect( adapter.dispatch({ diff --git a/src/main/claude/claude-fold-steer-receipt.test.ts b/src/main/claude/claude-fold-steer-receipt.test.ts index 4726dc55d823..bc15a52cecd5 100644 --- a/src/main/claude/claude-fold-steer-receipt.test.ts +++ b/src/main/claude/claude-fold-steer-receipt.test.ts @@ -23,7 +23,8 @@ import type { ClaudeStructuredLaunch } from './claude-structured-launch-resoluti import { fakeClaude, identityFor, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from './claude-structured-session-test-support' import { assistantText, @@ -100,7 +101,7 @@ async function riggedAdapter( } const adapter = new ClaudeStructuredSessionAdapter(deps) await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') return { adapter, claude, diff --git a/src/main/claude/claude-message-journaling.ts b/src/main/claude/claude-message-journaling.ts index 9d75db1fca9f..9648c14796a4 100644 --- a/src/main/claude/claude-message-journaling.ts +++ b/src/main/claude/claude-message-journaling.ts @@ -114,6 +114,14 @@ export function journalClaudeMessage( ctx.turn.ensureOpen(message, source, observedAt) ctx.sink.appendItem(identity, body, stamp(identity, body)) changed = true + if (source.assistant && !envelope.parentToolUseId && message.is_api_error_message === true) { + ctx.turn.observeApiErrorReply( + body.blocks + .flatMap((block) => (block.type === 'text' ? [block.text] : [])) + .join('\n') + .trim() + ) + } } for (const tool of claudeToolUses(outputEnvelope)) { ctx.turn.ensureOpen(message, source, observedAt) diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index 1cf0a8d048cc..4570de76220b 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -41,6 +41,8 @@ export class ClaudeOpenTurn { * cycle's init (a background task finishing), so membership is read from the * cycle's work, not from when the turn opened. */ private cycleWorkObserved = false + /** The API error the open turn's own reply already said; its failed result repeats it. */ + private apiErrorText: string | null = null private readonly opener: ( frame: Record, source: ClaudeTurnSource | null, @@ -86,6 +88,15 @@ export class ClaudeOpenTurn { return this.current !== null } + get spokenApiError(): string | null { + return this.current ? this.apiErrorText : null + } + + /** The open turn's reply was the CLI's own API error message. */ + observeApiErrorReply(text: string): void { + this.apiErrorText = text + } + /** Whether a turn is open inside a provider request cycle that has already * done work — the state in which the CLI folds an arriving send into it. A * cycle's first send is its opener, never a fold. */ @@ -118,6 +129,7 @@ export class ClaudeOpenTurn { }) } this.current = turn + this.apiErrorText = null this.publish(turn) this.deps.sink.setActivity?.(null) } @@ -135,6 +147,7 @@ export class ClaudeOpenTurn { }) } this.current = turn + this.apiErrorText = null this.deps.sink.setActivity?.(null) } @@ -172,6 +185,7 @@ export class ClaudeOpenTurn { this.publish(this.current, end, contextUsage) this.current = null } + this.apiErrorText = null this.deps.sink.setActivity?.(null) } diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index 1473886b9eac..fa6d4290b63f 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -60,6 +60,7 @@ export function journalClaudeResult( // person's cancellation is theirs to decide as the end is written (`turnEndAfterStop`). const turnId = settlesTurn ? turn.id : null const leftToStop = turnId !== null && sink.journalStopDecidesTurn?.(turnId, observedAt) === true + const spokenApiError = settlesTurn ? turn.spokenApiError : null if (settlesTurn) { prompts.retryPendingCancellations() turn.suppressReopenOnFailure(message.is_error === true) @@ -75,7 +76,9 @@ export function journalClaudeResult( } const kind = claudeProviderFrameKind(message) const failure = claudeResultFailure(message, leftToStop) - if (failure || !isSettledClaudeResultKind(kind)) { + // The turn's reply already said this error; a second row would say it twice. + const repeatsReply = failure?.text != null && failure.text === spokenApiError + if ((failure && !repeatsReply) || !isSettledClaudeResultKind(kind)) { providerFallback.append( kind, message, diff --git a/src/main/claude/claude-structured-acquisition-launch.ts b/src/main/claude/claude-structured-acquisition-launch.ts index 4de526c7c4db..365cd6cc06d0 100644 --- a/src/main/claude/claude-structured-acquisition-launch.ts +++ b/src/main/claude/claude-structured-acquisition-launch.ts @@ -7,6 +7,8 @@ import { import { stopAgentSessionProviderRoot } from '../native-chat/agent-session-wire/structured-agent-session-provider-exit-proof' import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation' import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' +import { claudeStructuredSpawnOptions } from './claude-structured-spawn-options' +import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' import { cancelClaudeAcquisitionAttempt, type ClaudeAcquisitionAttempt, @@ -21,6 +23,11 @@ import { closeClaudePublishedSessionForDeps } from './claude-structured-session-close' +/** The launch with the chat's saved options in its spawn options. */ +export type ClaudeAcquisitionLaunch = ClaudeStructuredLaunch & { + savedOptions: { options: Map; skipped: readonly string[] } +} + export async function resolveClaudeAcquisitionLaunch(args: { input: StructuredAgentSessionAcquireInput deps: ClaudeStructuredSessionAdapterDeps @@ -30,7 +37,7 @@ export async function resolveClaudeAcquisitionLaunch(args: { callbacks: ClaudeAcquireCallbacks previous: ClaudeAcquisitionAttempt | undefined attempt: ClaudeAcquisitionAttempt -}): Promise { +}): Promise { const { input, deps, sessions, acquisitions, exits, callbacks, previous, attempt } = args const sessionId = input.identity.sessionId return withAgentSessionCreatePhase('auth_settle', input.recordPhase, async () => { @@ -87,6 +94,20 @@ export async function resolveClaudeAcquisitionLaunch(args: { : new AgentSessionPreSpawnError(error) }) acquisitions.assertCurrent(sessionId, attempt) - return launch + const access = agentModelCatalogSessionAccess( + deps.modelCatalog, + 'claude', + launch.claudeConfigDir + ) + const spawn = claudeStructuredSpawnOptions({ + launch, + saved: input.options, + catalog: access?.store.get(access.fingerprint) ?? null + }) + return { + ...launch, + options: spawn.sdkOptions, + savedOptions: { options: spawn.options, skipped: spawn.skipped } + } }) } diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 0228b1171683..c7744ed089e9 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -24,7 +24,8 @@ import { PROVIDER_SESSION_ID, fakeClaude, identityFor, - recordingJournalSink + recordingJournalSink, + claudeStartupSettled } from './claude-structured-session-test-support' const SESSION = 'session-1' @@ -119,7 +120,7 @@ async function startChild( events: recordingJournalSink(), ...(options ? { options } : {}) }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) return adapter } diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 979b5ae5963e..9ee598256609 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -49,6 +49,7 @@ export type ClaudeStructuredSdkOptions = Pick< | 'effort' | 'permissionMode' | 'allowDangerouslySkipPermissions' + | 'settings' | 'sessionId' | 'resume' > @@ -92,6 +93,28 @@ export function claudeStructuredPermissionOptions( return mode === 'bypassPermissions' ? { extraArgs: { 'dangerously-skip-permissions': null } } : {} } +/** Whether `options` launch with the Agent Permissions bypass. */ +export function claudeStructuredOptionsBypassPermissions( + options: ClaudeStructuredSdkOptions +): boolean { + return options.extraArgs?.['dangerously-skip-permissions'] !== undefined +} + +/** `options` launched in `mode` instead of the mode they carry. */ +export function claudeStructuredOptionsWithPermissionMode( + options: ClaudeStructuredSdkOptions, + mode: PermissionMode +): ClaudeStructuredSdkOptions { + const { 'dangerously-skip-permissions': _bypass, ...extraArgs } = options.extraArgs ?? {} + const { permissionMode: _mode, ...rest } = options + return { + ...rest, + // The bypass flag starts the child in bypass; any other mode is named outright. + ...(mode === 'bypassPermissions' ? {} : { permissionMode: mode }), + extraArgs: { ...extraArgs, ...claudeStructuredPermissionOptions(mode).extraArgs } + } +} + export type ClaudeStructuredLaunch = { /** Always Orca's resolved user CLI: the SDK's bundled binaries are excluded from the install. */ pathToClaudeCodeExecutable: string diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index 2f0b7ac4b707..464ef640acf0 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -1,10 +1,6 @@ import type { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk' import { ClaudeControlRequestError } from './claude-stream-json-connection' -import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-requests' -import { - AgentSessionOptionRejectedError, - isAgentSessionOptionRejectedError -} from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { claudeCatalogAdmitsModel, claudeModelEffortLevels, @@ -48,7 +44,7 @@ export function isClaudeStructuredOptionKey(key: string): boolean { return CLAUDE_STRUCTURED_OPTION_KEYS.has(key) } -/** A client's write; the startup restore writes through `setClaudeStructuredOption` directly. */ +/** A client's write to a live session. */ export function setClaudeStructuredSessionOption( session: ClaudeSession, input: { key: string; value: string }, @@ -69,9 +65,7 @@ export function setClaudeStructuredSessionOption( export async function setClaudeStructuredOption( session: ClaudeSession, input: { key: string; value: string }, - timeoutMs: number | undefined, - /** What the key held before a restore cleared the map; a live write reads the map. */ - heldBeforeRestore?: string + timeoutMs: number | undefined ): Promise>> { const fastMode = input.key === 'fastMode' @@ -99,19 +93,17 @@ export async function setClaudeStructuredOption( // One read answers every catalog question this write asks, so the guards below // cannot each pay a round trip for the same list nor disagree about the model. // Two writes ask nothing of it and so read nothing: an effort write with no current - // model has nothing to look up, and turning Fast off needs no support evidence — - // which is every restore replaying a stored `false`. + // model has nothing to look up, and turning Fast off needs no support evidence. const needsCatalog = input.key === 'model' || (input.key === 'fastMode' && fastMode === true) || (input.key === 'effort' && readClaudeCurrentModel(session).id !== undefined) const listed = needsCatalog ? await readClaudeListedModels(session, timeoutMs) : [] // The child stores an effort its model has no control for and keeps it across - // every later model switch and restore, so refuse before the write rather than - // read the acceptance back as adoption. Refused here, restore drops the stale - // value instead of replaying it onto a model that cannot use it. + // every later model switch, so refuse before the write rather than read the + // acceptance back as adoption. if (input.key === 'effort') { - const { modelId, levels } = claudeModelEffortLevels(session, listed) + const { modelId, levels } = claudeModelEffortLevels(listed, readClaudeCurrentModel(session).id) if (levels && !levels.has(input.value)) { throw new AgentSessionOptionRejectedError( `claude model ${modelId} does not accept effort ${input.value}` @@ -122,7 +114,7 @@ export async function setClaudeStructuredOption( if (typeof fastMode !== 'boolean') { throw new AgentSessionOptionRejectedError('claude fast mode must be encoded as true or false') } - const support = claudeModelFastModeSupport(session, listed) + const support = claudeModelFastModeSupport(listed, readClaudeCurrentModel(session).id) // A catalog that identified nothing is not evidence against this model, the same // rule the admit-check below applies — otherwise a CLI that cannot answer has Fast // refused on every model. A catalog that did list the model and stayed silent @@ -144,19 +136,18 @@ export async function setClaudeStructuredOption( } // set_model resolves for a model the provider never lists and the session then // fails every turn with zero tokens, so the acceptance proves nothing and only - // the catalog does. Restore replays a pick the provider may since have retired, - // which reaches here with no user error at all. + // the catalog does. if (input.key === 'model' && !claudeCatalogAdmitsModel(listed, input.value)) { throw new AgentSessionOptionRejectedError(`claude does not list a model named ${input.value}`) } const modelFastModeSupport = input.key === 'model' && session.options.get('fastMode') === 'true' - ? claudeModelFastModeSupport(session, listed, input.value) + ? claudeModelFastModeSupport(listed, input.value) : null const modelWasConfirmed = readClaudeCurrentModel(session).confirmed const mutationSequence = ++session.optionMutationSequence // Read with the fence bump, so a write that lands after an earlier one's bookkeeping compares against it. - const held = heldBeforeRestore ?? session.options.get(input.key) + const held = session.options.get(input.key) // Only a model write can stale the model report — an effort or permission-mode // write does not change what the child is running. Leaving the stamp behind // would drop the session back to the written model and refuse, on the next @@ -247,44 +238,3 @@ export async function setClaudeStructuredOption( } return Object.fromEntries(session.options) } - -export async function restoreClaudeStructuredSessionOptions( - session: ClaudeSession, - timeoutMs: number | undefined -): Promise { - // Any write that was already in flight belongs to the previous acquisition - // state and must not repopulate this map after restore starts. - session.optionMutationSequence += 1 - // The fence bump is not a write, so the report the session already holds is still - // current as of this instant; leaving the stamp behind would make every restored - // session read as unconfirmed until its next turn. - session.reportedModelMutation = session.optionMutationSequence - const options = [...session.options.entries()] - session.options.clear() - for (const [key, value] of options) { - try { - await setClaudeStructuredOption(session, { key, value }, timeoutMs, value) - } catch (error) { - // A write the CLI never answered must not fault a start that is otherwise fine. Silence is - // not a refusal, so the choice stays wanted, unconfirmed, and the next start retries it. - if (error instanceof ClaudeControlRequestTimeoutError) { - console.warn( - `[claude-structured] restore of ${key} for ${session.providerSessionId} was not answered in time; keeping it unconfirmed` - ) - session.options.set(key, value) - session.confirmedOptions.delete(key) - continue - } - if (!isAgentSessionOptionRejectedError(error)) { - throw error - } - // A stale or unavailable preference must not poison every future acquire; - // the provider's current value remains authoritative and is re-persisted. - session.restoreSkippedOptions.add(key) - // The journal's window was measured under the value this child did not take. - if (CONTEXT_WINDOW_KEYS.has(key)) { - session.translator?.modelMayHaveChanged() - } - } - } -} diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index 07e3960c6ed6..a7d1c41c8bfe 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -63,7 +63,8 @@ export async function cancelClaudeStructuredTurn(input: { const timeoutMs = Math.min(input.timeoutMs ?? CLAUDE_STOP_GRACE_MS, CLAUDE_STOP_GRACE_MS) const session = requireSession(sessions, request.sessionId) const acquisitionGeneration = session.acquisitionGeneration - // Before startup lands nothing was written, so there is nothing to interrupt. + // A CLI still starting answers no interrupt: Stop ends its child next (`stopEndsSession`), and + // that end settles every message it was handed and never echoed as stopped. if (request.prompt || session.startup.state === 'pending') { return { cancelled: false } } diff --git a/src/main/claude/claude-structured-real-cli-fold.test.ts b/src/main/claude/claude-structured-real-cli-fold.test.ts index 13e4ee5e6720..6894af091e8f 100644 --- a/src/main/claude/claude-structured-real-cli-fold.test.ts +++ b/src/main/claude/claude-structured-real-cli-fold.test.ts @@ -27,6 +27,7 @@ import { realClaudeCommand, realClaudeLaunchHome } from './claude-real-cli-availability-test-support' +import { claudeStartupSettled } from './claude-structured-session-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'real-cli-fold' @@ -108,7 +109,7 @@ describe.skipIf(!realClaudeAvailable)('Claude structured real CLI fold', () => { spawnToken: 'real-cli-fold', events: sink }) - await adapter.awaitStarted(SESSION_ID) + await claudeStartupSettled(adapter, SESSION_ID) // Startup proved from the SessionStart hook frame, with no init yet. expect( events.some( diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index 263879f79b69..99bc5460e1de 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -22,6 +22,7 @@ import { type ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state' +import { claudeStartupSettled } from './claude-structured-session-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const command = realClaudeCommand @@ -56,7 +57,7 @@ function realAdapter( const acquire = adapter.acquire adapter.acquire = async (input) => { const acquisition = await acquire(input) - await adapter.awaitStarted(input.identity.sessionId) + await claudeStartupSettled(adapter, input.identity.sessionId) return acquisition } return adapter diff --git a/src/main/claude/claude-structured-session-acquisition-options.ts b/src/main/claude/claude-structured-session-acquisition-options.ts index 7557419dd08b..fff9cb4dfc3c 100644 --- a/src/main/claude/claude-structured-session-acquisition-options.ts +++ b/src/main/claude/claude-structured-session-acquisition-options.ts @@ -3,7 +3,6 @@ import { readClaudeSettingsFastMode, readClaudeSettingsFastModePerSessionOptIn } from './claude-structured-session-options' -import { restoredClaudeStructuredSessionOptions } from './claude-structured-options' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' export async function readClaudeStructuredSessionSettings( @@ -16,22 +15,12 @@ export async function readClaudeStructuredSessionSettings( export function prepareClaudeStructuredSessionAcquisitionOptions(args: { settings: unknown initialization: unknown - inputOptions: Readonly> | undefined - /** A fresh CLI session has not carried a per-session Fast opt-in over from anywhere. */ - resumesTranscript: boolean }) { - const fastMode = readClaudeSettingsFastMode(args.settings) - const fastModePerSessionOptIn = readClaudeSettingsFastModePerSessionOptIn(args.settings) - const fastModeFacts = readClaudeFastModeFacts(args.initialization) - const options = restoredClaudeStructuredSessionOptions(args.inputOptions) - if ( - !args.resumesTranscript && - fastModePerSessionOptIn === true && - options.get('fastMode') === 'true' - ) { - options.delete('fastMode') + return { + fastMode: readClaudeSettingsFastMode(args.settings), + fastModePerSessionOptIn: readClaudeSettingsFastModePerSessionOptIn(args.settings), + fastModeFacts: readClaudeFastModeFacts(args.initialization) } - return { fastMode, fastModePerSessionOptIn, fastModeFacts, options } } export function claudeStructuredSessionPublicationOptions(input: { diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 7ce6b7a6ad53..a5c94760c2f1 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -18,7 +18,7 @@ import { import { claudeConfigDirEnvPatch } from './claude-config-dir-pin' import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity' import { ClaudePromptRegistry } from './claude-structured-prompt-replies' -import { restoredClaudeStructuredSessionOptions } from './claude-structured-options' +import { adoptClaudeStructuredSpawnOptions } from './claude-structured-spawn-options' import { createClaudeSessionJournalTranslator } from './claude-structured-journal-translation' import { observeClaudeFastModeFacts } from './claude-structured-session-options' import { @@ -251,12 +251,13 @@ export async function acquireClaudeSession({ ...(unbindReadingControl ? { unbindReadingControl } : {}), process, acquisitionGeneration: mintClaudeAcquisitionGeneration(deps), - options: restoredClaudeStructuredSessionOptions(input.options), + options: launch.savedOptions.options, ...(deps.mintLinkId ? { linkId: deps.mintLinkId() } : {}), observedAt: deps.now?.() ?? Date.now() }) const session = publication.session liveSession = session + adoptClaudeStructuredSpawnOptions(session, launch.savedOptions) const catalogAccess = agentModelCatalogSessionAccess( deps.modelCatalog, 'claude', @@ -283,13 +284,10 @@ export async function acquireClaudeSession({ initProof, sessionId, providerSessionId: launch.providerSessionId, - resumesTranscript: launch.resumesTranscript, - inputOptions: input.options, requestTimeoutMs: deps.requestTimeoutMs, emit }), isCurrent: () => sessions.get(sessionId) === session, - requestTimeoutMs: deps.requestTimeoutMs, fault: (error) => callbacks.handleExit(sessionId, attempt, error), onStarted: (options) => emit({ @@ -308,8 +306,8 @@ export async function acquireClaudeSession({ exits.get(sessionId)?.error ?? new Error('claude session ended before acquisition returned') ) } - // The start applies its facts and restores saved options only after publish, so the child - // is `starting` until `started` says otherwise. + // The start reads its facts only after publish, so the child is `starting` until `started` + // says otherwise; it already takes input. return { ...publication.acquisition, providerChildPhase: 'starting' } } catch (error) { unbindReadingControl?.() diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 0e3176a1813c..81d438f04a7d 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -1,4 +1,3 @@ -import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import { dispatchClaudeCommand } from './claude-structured-command-dispatch' import type { AgentSessionAcquisition, @@ -13,10 +12,7 @@ import { acquireClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' import { setClaudeStructuredSessionOption } from './claude-structured-options' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' -import { - claudeStartupFailureFact, - claudeStartupSettledWithin -} from './claude-structured-session-startup-state' +import { claudeStartupSettledWithin } from './claude-structured-session-startup-state' import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' import { ClaudeAcquisitionRegistry, @@ -134,17 +130,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda * apart without guessing at wall-clock. */ drainObservedExits = (): Promise => drainClaudeObservedExits(this.exits) - /** Resolves once a published session's startup has landed, faulted, or been ended by a close; - * with the reason when it did not land. */ - awaitStarted = async (sessionId: string): Promise => { - const session = this.sessions.get(sessionId) - if (!session) { - return - } - await session.startup.settled - return claudeStartupFailureFact(session) ?? undefined - } - /** Restart reconciliation reads the transcript a resume replays; these maps track liveness. */ providerHistoryWindow: NonNullable = ( input diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 082f45726fe5..06cfea6bbdad 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -122,14 +122,6 @@ export function readClaudeCurrentModel(session: ClaudeSession): { } } -/** - * The effort levels the session's current model advertises, with the catalog id - * that matched so a refusal names the model the pill shows. Levels are null when - * nothing identified the model: `apply_flag_settings` accepts and stores any - * level for a model with no effort control, so the catalog is the only evidence - * of a refusal — and an absent or unlisted one is not evidence, or a live CLI - * that predates `list_models` would have every effort refused under it. - */ /** One catalog read serves a whole option write. The admit check, the effort guard * and the Fast guard all ask about the same list; each taking its own read made a * single model write pay for two `list_models` round trips and let two guards answer @@ -143,11 +135,18 @@ export async function readClaudeListedModels( return catalog ? listedModels({ models: catalog }) : [] } +/** + * The effort levels the session's current model advertises, with the catalog id + * that matched so a refusal names the model the pill shows. Levels are null when + * nothing identified the model: `apply_flag_settings` accepts and stores any + * level for a model with no effort control, so the catalog is the only evidence + * of a refusal — and an absent or unlisted one is not evidence, or a live CLI + * that predates `list_models` would have every effort refused under it. + */ export function claudeModelEffortLevels( - session: ClaudeSession, - models: readonly ListedModel[] + models: readonly ListedModel[], + modelId: string | undefined ): { modelId: string | undefined; levels: ReadonlySet | null } { - const modelId = readClaudeCurrentModel(session).id const matched = modelId ? models.find((model) => model.id === modelId || model.resolvedModel === modelId) : undefined @@ -157,12 +156,11 @@ export function claudeModelEffortLevels( } } +/** Fast support for `reportedModelId`; none means the listing's default. */ export function claudeModelFastModeSupport( - session: ClaudeSession, models: readonly ListedModel[], - requestedModel?: string + reportedModelId: string | undefined ): { modelId: string | undefined; supported: boolean | null } { - const reportedModelId = requestedModel ?? readClaudeCurrentModel(session).id const modelId = reportedModelId ?? models.find((model) => model.isDefault)?.id const matched = modelId ? matchListedModel(models, modelId) : undefined return { diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts index 86598780b456..e84ff7dbe5fc 100644 --- a/src/main/claude/claude-structured-session-recovery.test.ts +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -10,7 +10,8 @@ import { fakeClaude, identityFor, PROVIDER_SESSION_ID, - tick + tick, + claudeStartupSettled } from './claude-structured-session-test-support' import { invokeCanUseTool } from './claude-can-use-tool-test-support' import { @@ -349,7 +350,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { spawnToken: 'spawn-9', events: journalSink }) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') const first = claude.connections[0] const oldPrompt = invokeCanUseTool(first, 'Bash', 'permission-retained', 'tool-retained') const oldSession = ( diff --git a/src/main/claude/claude-structured-session-startup-state.ts b/src/main/claude/claude-structured-session-startup-state.ts index cf44bd984abd..a57aa2e8fa84 100644 --- a/src/main/claude/claude-structured-session-startup-state.ts +++ b/src/main/claude/claude-structured-session-startup-state.ts @@ -1,7 +1,7 @@ -// Where a Claude start stands. A session is published once its child is spawned, before the CLI -// has answered initialize. Nothing is written to it until startup lands (init facts read and saved -// options restored): the host's delivery loop waits on `settled` before it hands a message over, -// so a first turn never runs under defaults the restore was about to replace. +// Where a Claude start stands. A session is published once its child is spawned, launched with the +// chat's saved options, before the CLI has answered initialize. Messages are written to it at once +// (the CLI queues them behind its start); only an option write, a control request initialize must +// answer first, waits for startup to land. import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' @@ -11,8 +11,7 @@ export type ClaudeSessionStartup = { state: 'pending' | 'proven' | 'failed' failure: Error | null /** Resolves once startup has landed or faulted, or the child exited or was closed; never - * rejects. A close must end it: the delivery loop waits here, and a start Stop cut short - * would otherwise hold that loop forever. */ + * rejects. A close must end it: an option write waits here. */ settled: Promise end: () => void } diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index 71625d7ffd18..55ef841e05b1 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -6,7 +6,8 @@ import { fakeClaude, identityFor, PROVIDER_SESSION_ID, - USER_MESSAGE + USER_MESSAGE, + claudeStartupSettled } from './claude-structured-session-test-support' import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' import { CLAUDE_STARTUP_DEADLINE_MS } from './claude-structured-session-startup' @@ -57,7 +58,7 @@ describe('Claude structured session publishes before the CLI answers initialize' expect(adapter.readCommands('session-1')).toBeUndefined() await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') expect(events.find((event) => event.type === 'options')).toMatchObject({ models: [{ value: 'claude-sonnet' }] @@ -79,7 +80,7 @@ describe('Claude structured session publishes before the CLI answers initialize' expect(events.some((event) => event.type === 'started')).toBe(false) await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') const startedAt = events.findIndex((event) => event.type === 'started') expect(events[startedAt]).toEqual({ @@ -141,7 +142,7 @@ describe('Claude structured session publishes before the CLI answers initialize' const { adapter } = startingAdapter(claude) await adapter.acquire(ACQUIRE) let started = false - const waited = adapter.awaitStarted('session-1').then(() => { + const waited = claudeStartupSettled(adapter, 'session-1').then(() => { started = true }) @@ -164,7 +165,7 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.acquire(ACQUIRE) await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') await adapter.drainObservedExits() expect(events.find((event) => event.type === 'ended')).toMatchObject({ @@ -186,7 +187,7 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.acquire(ACQUIRE) await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') await adapter.drainObservedExits() // A failed start is released on the same evidence a failed create is. @@ -202,7 +203,7 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.acquire(ACQUIRE) await vi.advanceTimersByTimeAsync(CLAUDE_STARTUP_DEADLINE_MS) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') expect(events.some((event) => event.type === 'started')).toBe(true) expect(events.some((event) => event.type === 'ended')).toBe(false) @@ -220,7 +221,7 @@ describe('Claude structured session publishes before the CLI answers initialize' expect(events.some((event) => event.type === 'ended')).toBe(false) expect(claude.connections[0].closeCount).toBe(0) await vi.advanceTimersByTimeAsync(1) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') await adapter.drainObservedExits() // Orca ended it, so the chat says Claude couldn't start, not that it stopped on its own. @@ -255,7 +256,7 @@ describe('Claude structured session publishes before the CLI answers initialize' session_id: PROVIDER_SESSION_ID }) await vi.advanceTimersByTimeAsync(SLOW_INIT_MS + 1) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') expect(events.some((event) => event.type === 'started')).toBe(true) expect(events.some((event) => event.type === 'ended')).toBe(false) @@ -275,7 +276,7 @@ describe('Claude structured session publishes before the CLI answers initialize' session_id: 'foreign-session' }) await vi.advanceTimersByTimeAsync(0) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') await adapter.drainObservedExits() expect(events.find((event) => event.type === 'ended')).toMatchObject({ @@ -291,7 +292,7 @@ describe('Claude structured session publishes before the CLI answers initialize' const claude = fakeClaude({ initProof: 'none' }) const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') expect(events.some((event) => event.type === 'started')).toBe(true) await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) @@ -303,7 +304,7 @@ describe('Claude structured session publishes before the CLI answers initialize' const claude = fakeClaude({ initProof: 'none' }) const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') claude.connections[0].handlers.onMessage?.({ type: 'system', @@ -323,7 +324,7 @@ describe('Claude structured session publishes before the CLI answers initialize' const claude = fakeClaude({ initAccount: { apiProvider: 'firstParty', tokenSource: 'none' } }) const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) - await adapter.awaitStarted('session-1') + await claudeStartupSettled(adapter, 'session-1') await adapter.drainObservedExits() expect(events.find((event) => event.type === 'ended')).toMatchObject({ @@ -339,7 +340,7 @@ describe('Claude structured session publishes before the CLI answers initialize' const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) let ended = false - const waited = adapter.awaitStarted('session-1').then(() => { + const waited = claudeStartupSettled(adapter, 'session-1').then(() => { ended = true }) diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index 4187de3a1b5d..f9392f9e04bb 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -1,12 +1,9 @@ -// What Claude reports at initialize, read after the session is already published. None of it -// gates the create: a slow start is still a start, and every way it can fail (exit, auth, -// a foreign session id, no initialize answer within the startup deadline) faults the published -// session through its exit path. +// What Claude reports at initialize, read in the background once the session is published. None of +// it gates the create or a message: the child was launched with the chat's saved options and takes +// input at once. Every way the start can fail (exit, auth, a foreign session id) faults the +// published session through its exit path; a CLI that never answers is ended by a Stop or a close. -import type { - StructuredAgentSessionAcquireInput, - StructuredAgentSessionStartedEvent -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionStartedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' import { @@ -16,7 +13,6 @@ import { readClaudeModels, type ClaudeInitObservation } from './claude-structured-init-proof' -import { restoreClaudeStructuredSessionOptions } from './claude-structured-options' import { claudeStructuredSessionPublicationOptions, prepareClaudeStructuredSessionAcquisitionOptions, @@ -28,7 +24,6 @@ import { readClaudeSettingsEffort } from './claude-structured-session-options' import { failClaudeStartup } from './claude-structured-session-startup-state' -import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../native-chat/agent-session-wire/structured-agent-session-send-settlement' import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' /** The CLI's own frame naming the session it runs (system/init or a SessionStart hook). Only a @@ -44,8 +39,6 @@ export type ClaudeInitProof = { seen: () => ClaudeInitObservation | null /** Set once startup has read the proof: a later refusal ends the session. */ onRefusal: ((error: Error) => void) | null - /** Each proof frame, while startup waits on initialize. */ - onFrame: (() => void) | null } export function createClaudeInitProof(): ClaudeInitProof { @@ -66,7 +59,6 @@ export function createClaudeInitProof(): ClaudeInitProof { resolve: (init) => { outcome ??= { init } resolvePromise(init) - proof.onFrame?.() }, reject, refuse: () => { @@ -84,8 +76,7 @@ export function createClaudeInitProof(): ClaudeInitProof { } return outcome?.init ?? null }, - onRefusal: null, - onFrame: null + onRefusal: null } return proof } @@ -103,67 +94,17 @@ export type ClaudeStartupFacts = { prepared: ReturnType } -/** How long the CLI may go silent before answering initialize. Generous for a slow machine or a - * first run; a CLI that stays alive but never answers would otherwise hold the chat's messages - * forever. A CLI silent from launch fails inside the host's start wait, so a command waiting on - * this start hears its failure rather than "not started yet". */ -export const CLAUDE_STARTUP_DEADLINE_MS = STRUCTURED_AGENT_SESSION_START_WAIT_MS - 30_000 - -/** The e2e rig shortens the deadline to capture its failure; production always uses the default. */ -function claudeStartupDeadlineMs(): number { - const configured = Number(process.env.ORCA_E2E_CLAUDE_STARTUP_DEADLINE_MS) - return Number.isFinite(configured) && configured >= 1 && configured <= CLAUDE_STARTUP_DEADLINE_MS - ? configured - : CLAUDE_STARTUP_DEADLINE_MS -} - -/** The initialize answer, or a rejection once the CLI has sent no start frame for `deadlineMs`; a - * late answer is then ignored. Claude answers only after its SessionStart hooks finish, and each - * hook's start and end frames buy another `deadlineMs`, so hooks that run in turn are not cut off; - * one hook running past the deadline (Claude's own hook timeout is 60 s by default) still is. A - * refused proof fails it at once. */ -function withinClaudeStartupDeadline( - answer: Promise, - proof: ClaudeInitProof, - deadlineMs: number -): Promise { - let timer: ReturnType | undefined - let expire = (_error: Error): void => {} - const expired = new Promise((_resolve, reject) => { - expire = reject - }) - const arm = (): void => { - clearTimeout(timer) - timer = setTimeout( - () => - expire( - new Error(`claude did not answer initialize within ${Math.round(deadlineMs / 1000)}s`) - ), - deadlineMs - ) - } - arm() - proof.onFrame = arm - const refused = proof.promise.then(() => new Promise(() => {})) - return Promise.race([answer, refused, expired]).finally(() => { - clearTimeout(timer) - proof.onFrame = null - }) -} - -/** Settles on the CLI's initialize answer, on its exit, or at the startup deadline. */ +/** Settles on the CLI's initialize answer, or on its exit or a refused proof. */ export async function readClaudeStartupFacts(input: { connection: ClaudeStreamJsonConnection initProof: ClaudeInitProof sessionId: string providerSessionId: string - resumesTranscript: boolean - inputOptions: StructuredAgentSessionAcquireInput['options'] requestTimeoutMs: number | undefined emit: (event: ClaudeStructuredSessionEvent) => void }): Promise { // The CLI's first answer has no request deadline of its own; the reads after it do. - const initialization = await withinClaudeStartupDeadline( + const initialization = await Promise.race([ input.connection.initializationResult().then((result) => { const authError = claudeInitializationAuthError(result) if (authError) { @@ -171,9 +112,8 @@ export async function readClaudeStartupFacts(input: { } return result }), - input.initProof, - claudeStartupDeadlineMs() - ) + input.initProof.promise.then(() => new Promise(() => {})) + ]) if (input.connection.closed) { throw new Error('claude session closed before startup completed') } @@ -199,12 +139,7 @@ export async function readClaudeStartupFacts(input: { initProof: input.initProof, initialization, settings, - prepared: prepareClaudeStructuredSessionAcquisitionOptions({ - settings, - initialization, - inputOptions: input.inputOptions, - resumesTranscript: input.resumesTranscript - }) + prepared: prepareClaudeStructuredSessionAcquisitionOptions({ settings, initialization }) } } @@ -218,20 +153,26 @@ function applyClaudeStartupFacts(session: ClaudeSession, facts: ClaudeStartupFac session.reportedModelMutation = session.optionMutationSequence } observeClaudeSettingsApplied(session, settings) + // The readback vouches for a value the child was launched with only when it reports that value. + const agrees = (key: string, reported: string): boolean => + !session.options.has(key) || session.options.get(key) === reported if (effort) { session.reportedOptions.effort = effort - session.confirmedOptions.add('effort') + if (agrees('effort', effort)) { + session.confirmedOptions.add('effort') + } } if (published.fastMode !== null) { session.reportedOptions.fastMode = published.fastMode - session.confirmedOptions.add('fastMode') + if (agrees('fastMode', String(published.fastMode))) { + session.confirmedOptions.add('fastMode') + } } if (published.fastModePerSessionOptIn !== null) { session.fastModePerSessionOptIn = published.fastModePerSessionOptIn } session.fastModeState ??= published.fastModeState session.fastModeDisabledReason ??= published.fastModeDisabledReason - session.options = prepared.options session.capabilities = readClaudeCapabilities(session.capabilities, initialization, init?.message) // A catalog frame that streamed in after publish is newer than the initialize answer. if (session.commands.commands === undefined) { @@ -254,13 +195,12 @@ function claudeStartedReportedOptions( return persisted } -/** Applies startup facts to the published session and restores saved options; only then does the - * session take input. Any failure faults the session so the user sees why it never started. */ +/** Applies startup facts to the published session, which already takes input. Any failure faults + * the session so the user sees why it never started. */ export async function settleClaudeSessionStartup(input: { session: ClaudeSession facts: Promise isCurrent: () => boolean - requestTimeoutMs: number | undefined fault: (error: Error) => void /** Startup has proven; `options` is what the child now reports, snapshotted from memory. */ onStarted: (options: StructuredAgentSessionStartedOptions) => void @@ -286,7 +226,6 @@ export async function settleClaudeSessionStartup(input: { } } applyClaudeStartupFacts(session, facts) - await restoreClaudeStructuredSessionOptions(session, input.requestTimeoutMs) if (!superseded()) { input.onStarted({ // `list_models` is answered from this same initialize result, so nothing is re-read. diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index 9ac3cf087890..7220f88728df 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -241,6 +241,15 @@ export function fakeClaude( return { connections, openConnection, routes } } +/** Resolves once the session's background startup read has landed, faulted, or been ended. */ +export async function claudeStartupSettled( + adapter: ClaudeStructuredSessionAdapter, + sessionId: string +): Promise { + // Element access reaches the adapter's private map, which no production caller needs. + await adapter['sessions'].get(sessionId)?.startup.settled +} + /** Acquisition resolves only once startup has landed, as suites written before * publish-first expect; `adapterAtPublishFor` observes the published window itself. */ export function adapterFor( @@ -250,7 +259,7 @@ export function adapterFor( const acquire = adapter.acquire adapter.acquire = async (input) => { const acquisition = await acquire(input) - await adapter.awaitStarted(input.identity.sessionId) + await claudeStartupSettled(adapter, input.identity.sessionId) return acquisition } return adapter diff --git a/src/main/claude/claude-structured-spawn-options.ts b/src/main/claude/claude-structured-spawn-options.ts new file mode 100644 index 000000000000..34728e3c69de --- /dev/null +++ b/src/main/claude/claude-structured-spawn-options.ts @@ -0,0 +1,143 @@ +// A chat's saved options as the Claude child's launch options. The child starts already running +// them, so its first message can be written at once instead of waiting for initialize to answer and +// a control request to apply each one. + +import type { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk' +import type { AgentModelCatalogEntry } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' +import { + claudeStructuredOptionsBypassPermissions, + claudeStructuredOptionsWithPermissionMode, + type ClaudeStructuredLaunch, + type ClaudeStructuredSdkOptions +} from './claude-structured-launch-resolution' +import type { ListedModel } from './claude-structured-model-catalog' +import type { ClaudeSession } from './claude-structured-session-state' +import { restoredClaudeStructuredSessionOptions } from './claude-structured-options' +import { + claudeCatalogAdmitsModel, + claudeModelEffortLevels, + claudeModelFastModeSupport +} from './claude-structured-session-options' + +const EFFORT_LEVELS: ReadonlySet = new Set([ + 'low', + 'medium', + 'high', + 'xhigh', + 'max' +]) +const PERMISSION_MODES: ReadonlySet = new Set([ + 'default', + 'acceptEdits', + 'bypassPermissions', + 'plan', + 'dontAsk', + 'auto' +]) + +function isEffortLevel(value: string): value is EffortLevel { + return EFFORT_LEVELS.has(value) +} + +function isPermissionMode(value: string): value is PermissionMode { + return PERMISSION_MODES.has(value) +} + +export type ClaudeStructuredSpawnOptions = { + sdkOptions: ClaudeStructuredSdkOptions + /** What the child was launched with, as the session's options. */ + options: Map + /** Saved options left out: the provider's own value wins and is re-persisted. */ + skipped: readonly string[] +} + +/** + * Checked against the account's cached catalog only, since nothing has been asked yet: a value + * the cache rules out is left out, and with no cache entry every value passes. A value the CLI + * itself would refuse at launch never passes: the start would fail on every reopen. + */ +export function claudeStructuredSpawnOptions(input: { + launch: Pick + saved: Readonly> | undefined + catalog: AgentModelCatalogEntry | null +}): ClaudeStructuredSpawnOptions { + const saved = restoredClaudeStructuredSessionOptions(input.saved) + const listed: ListedModel[] = (input.catalog?.models ?? []).map((model) => ({ + ...model, + resolvedModel: null + })) + const options = new Map() + const skipped: string[] = [] + let sdkOptions: ClaudeStructuredSdkOptions = { ...input.launch.options } + const model = saved.get('model') + if (model !== undefined) { + if (claudeCatalogAdmitsModel(listed, model)) { + options.set('model', model) + sdkOptions.model = model + } else { + skipped.push('model') + } + } + const effort = saved.get('effort') + if (effort !== undefined) { + // With no saved model the CLI picks one this launch cannot name, so there is nothing to check. + const { levels } = claudeModelEffortLevels(listed, options.get('model')) + if (isEffortLevel(effort) && (!levels || levels.has(effort))) { + options.set('effort', effort) + sdkOptions.effort = effort + } else { + skipped.push('effort') + } + } + const fastMode = saved.get('fastMode') + if (fastMode !== undefined) { + const decoded = decodeStructuredAgentSessionOptionValue('fastMode', fastMode) + const support = claudeModelFastModeSupport(listed, options.get('model')) + if (decoded === true && !input.launch.resumesTranscript) { + // A fresh CLI session may be one whose settings opt in to Fast per session, which only the + // CLI can say; it starts with Fast off rather than carry an opt-in it never made. + } else if ( + typeof decoded !== 'boolean' || + (decoded && listed.length > 0 && support.supported !== true) + ) { + skipped.push('fastMode') + } else { + options.set('fastMode', fastMode) + sdkOptions.settings = { fastMode: decoded } + } + } + const permissionMode = saved.get('permissionMode') + if (permissionMode !== undefined) { + // Bypass is the Agent Permissions setting's to grant; a saved pick never widens it. + if ( + isPermissionMode(permissionMode) && + (permissionMode !== 'bypassPermissions' || + claudeStructuredOptionsBypassPermissions(input.launch.options)) + ) { + options.set('permissionMode', permissionMode) + sdkOptions = claudeStructuredOptionsWithPermissionMode(sdkOptions, permissionMode) + } else { + skipped.push('permissionMode') + } + } + return { sdkOptions, options, skipped } +} + +/** The published session takes on what its child was launched with. */ +export function adoptClaudeStructuredSpawnOptions( + session: Pick, + spawn: Pick +): void { + for (const key of spawn.skipped) { + session.restoreSkippedOptions.add(key) + } + const model = spawn.options.get('model') + if (model !== undefined) { + session.translator?.modelWritten(model) + } + // The journal's window was measured under a value this child did not take. + if (spawn.skipped.includes('model') || spawn.skipped.includes('permissionMode')) { + session.translator?.modelMayHaveChanged() + } +} diff --git a/src/main/claude/claude-subagent-permission-request.test.ts b/src/main/claude/claude-subagent-permission-request.test.ts index 956d89becd2a..c3db6a4011b6 100644 --- a/src/main/claude/claude-subagent-permission-request.test.ts +++ b/src/main/claude/claude-subagent-permission-request.test.ts @@ -36,7 +36,8 @@ import type { ClaudeStructuredSessionEvent } from './claude-structured-session-s import { fakeClaude, identityFor, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from './claude-structured-session-test-support' const SESSION = 'session-1' @@ -222,7 +223,7 @@ async function pipeline() { spawnToken: 'spawn-9', events: deferred.sink }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) const settle = async (): Promise => { for (let round = 0; round < 3; round += 1) { expect(await deferred.drained()).toEqual({ ok: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 5edc3793d026..d1dbf847cb08 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -1,4 +1,3 @@ -import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { StructuredAgentSessionAtRestCommands } from './structured-agent-session-at-rest-commands' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { @@ -149,8 +148,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi awaitOptionWritable = (sessionId: string): Promise => this.liveOwnerOrNull(sessionId)?.awaitOptionWritable?.(sessionId) ?? Promise.resolve() - awaitStarted = (sessionId: string): Promise => - this.liveOwnerOrNull(sessionId)?.awaitStarted?.(sessionId) ?? Promise.resolve() readOptions = (input: { sessionId: string; fence: number }) => { const reader = this.owner(input.sessionId).readOptions diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 9975701e7047..56bd770f7a5f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -203,8 +203,8 @@ export type StructuredAgentSessionEndedEvent = { startupUnproven?: true } -/** The child a publish-first acquire handed over has now proven its start: startup facts applied - * and saved options restored. What it reports from here on is fact, not a catalog guess. */ +/** The child a publish-first acquire handed over has now proven its start: startup facts applied. + * What it reports from here on is fact, not a catalog guess. */ export type StructuredAgentSessionStartedEvent = { type: 'started' sessionId: string @@ -213,7 +213,7 @@ export type StructuredAgentSessionStartedEvent = { /** What the child proved, snapshotted by the adapter from what startup already read. The host * handles this inside the session's serialized step, so it must not ask the CLI. */ reportedOptions: AgentSessionOptionsResult['current'] - /** Saved options the restore could not apply; the host drops them rather than persist them. */ + /** Saved options the child could not take; the host drops them rather than persist them. */ restoreSkippedOptions: readonly string[] } @@ -222,7 +222,8 @@ export type StructuredAgentSessionLifecycleEvent = | StructuredAgentSessionStartedEvent /** Whether the provider child behind an acquisition has proven its start. A publish-first - * acquire hands over a `starting` child and the `started` lifecycle event flips it. */ + * acquire hands over a `starting` child, which already takes input, and the `started` lifecycle + * event flips it. */ export type StructuredAgentSessionProviderChildPhase = 'starting' | 'ready' export type StructuredAgentSessionAcquireInput = { @@ -361,10 +362,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & ): Promise>> /** Resolves once a live session can take an option write, or after a bound; never rejects. */ awaitOptionWritable?(sessionId: string): Promise - /** Resolves once a session published before it proved its start has proven it, failed, or been - * closed; at once for any other. A start that did not land resolves with the chat's words for - * why. Never rejects. */ - awaitStarted?(sessionId: string): Promise readOptions?(input: { sessionId: string; fence: number }): Promise /** Option keys skipped after a provider rejected their persisted restore value. */ readOptionRestoreFailures?(sessionId: string): readonly string[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index 834d4f569fa8..339c1572aec7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -101,8 +101,9 @@ export function mutateWithChatStop( return { ok: true, value: { ...named, cancelled: await withdrew } } } if (child?.phase === 'starting') { - // A start that may never land is the one thing here Stop has to end; the chat stays. - // The event is issued first and lands behind the withdrawal, in the journal's queue order. + // A start that may never land takes no interrupt, so Stop ends it; the chat stays. Its end + // settles what the child was handed as stopped (`stoppedDuringStartup`). The event is + // issued first and lands behind the withdrawal, in the journal's queue order. const effect = tookEffect() try { await stopChild() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index c606a73e310f..2fcbf0ebb4e3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -166,6 +166,9 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< ...(!expected && exit.failure ? { exitFailure: exit.failure } : {}), ...(!expected && exitedDuringStartup && child.generation ? { exitedDuringStartup: { generation: child.generation } } + : {}), + ...(expected && exitedDuringStartup && close?.cause === 'user-stop' + ? { stoppedDuringStartup: true as const } : {}) }) if (!settled.ok) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts index bbdc2b50b2fd..750a40cfc552 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts @@ -12,7 +12,8 @@ import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-s import { fakeClaude, PROVIDER_SESSION_ID, - type FakeConnection + type FakeConnection, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -90,7 +91,7 @@ beforeEach(async () => { }) ) expect(attached).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) }) afterEach(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts index ac1111ac629e..6df042f23bf1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts @@ -12,7 +12,8 @@ import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/struct import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -82,7 +83,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index 5e4ac75b0662..f96ee1f0c370 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -14,7 +14,8 @@ import { projectStructuredAgentSessionStatus } from '../../../shared/structured- import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -98,7 +99,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index 198c2418cf93..c0a8d01d1300 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -24,7 +24,8 @@ import type { ClaudeStructuredSessionEvent } from '../../claude/claude-structure import { fakeClaude, PROVIDER_SESSION_ID, - type FakeConnection + type FakeConnection, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import { invokeCanUseTool } from '../../claude/claude-can-use-tool-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -129,7 +130,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts index cfc2c27ca6be..c580688b7b2d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts @@ -13,7 +13,8 @@ import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-s import { fakeClaude, PROVIDER_SESSION_ID, - type FakeConnection + type FakeConnection, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -100,7 +101,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts index 9e10a753f07e..d2aa9a7e7e48 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts @@ -14,7 +14,8 @@ import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-s import { fakeClaude, PROVIDER_SESSION_ID, - type FakeConnection + type FakeConnection, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -85,7 +86,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts index b1654ff70790..c9811c66f3c8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -208,8 +208,8 @@ export async function handOverStructuredAgentSessionCommand( command: { clientMessageId, ...turn, running } }) } catch (error) { - // A child that had not proven its start took nothing, so the command provably did not run. Any - // other throw is a lost reply: the command may have run. + // A child still starting throws only for a start that failed before the write, so the command + // provably did not run. Any other throw is a lost reply: the command may have run. const unsent = ctx.providerChildPhase?.() === 'starting' ? { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 639b34cb7955..6eda352decb0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -122,6 +122,8 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { /** The provider never finished starting: the start that failed, keyed by the child's * generation. Its row is the one the delivery loop writes for the same start. */ exitedDuringStartup?: { generation: string | null } + /** A person's Stop ended a child that never finished starting: what it was handed is stopped. */ + stoppedDuringStartup?: true }): Promise { try { const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal) @@ -129,18 +131,21 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { if (!showUnexpectedExitOutcome && !hasUnfinishedWork) { return { ok: true } } - // A queued message is the delivery loop's to settle: it was never handed to this child. A - // child that never proved its start accepted nothing either — input is written only after it - // initializes — so every send it was handed is rejected with the child's own diagnostic. A - // proven child's handed-over sends stay in doubt. + // A queued message is the delivery loop's to settle: it was never handed to this child. A send + // a child still starting was handed and never echoed did not run, and its root is gone: it is + // rejected, with the child's own diagnostic or as the Stop that ended it. A proven child's + // handed-over sends stay in doubt. const startupFailure = input.exitedDuringStartup ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) : null - if (!startupFailure) { + const stopped = input.stoppedDuringStartup ? agentSessionFailureFact('cancelled') : null + const unrun = + startupFailure ?? (stopped && agentSessionFailureWords(stopped, { surface: 'rejection' })) + if (!unrun) { await withdrawCodexSendsNoTurnOpenedFor(input.journal, input.fence) } - await (startupFailure - ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) + await (unrun + ? input.journal.rejectPendingSubmissions(input.fence, unrun) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index b9601ed1a8de..d43ed026e5ce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -13,10 +13,7 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' -import { - agentSessionFailureFact, - type SubmissionRejectionFact -} from '../../../shared/agent-session-failure' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords, type AgentSessionFailureWordsContext @@ -119,11 +116,8 @@ export class StructuredAgentSessionDeliveryLoop { ) return } - // A child published before it proved its start takes no input yet; waited for outside - // the queue so a Stop can reach it meanwhile. - const failure = await this.deps.adapter.awaitStarted?.(sessionId) const handed = await this.deps.serialize(sessionId, () => - this.handOver(sessionId, prepared.awaited, failure || null) + this.handOver(sessionId, prepared.awaited) ) if (handed === 'stop') { return @@ -196,8 +190,7 @@ export class StructuredAgentSessionDeliveryLoop { private async handOver( sessionId: string, - awaited: StructuredAgentSessionProviderChildIdentity | null, - startFailure: SubmissionRejectionFact | null + awaited: StructuredAgentSessionProviderChildIdentity | null ): Promise { const session = this.deps.sessions.get(sessionId) if (!session || this.disposed) { @@ -210,10 +203,9 @@ export class StructuredAgentSessionDeliveryLoop { child && awaited && child.generation === awaited.generation && child.fence === awaited.fence ? child : null - // The host's `starting` trails the adapter's `started` by one serialized step, so for the child - // waited on, the adapter's own answer decides whether its start landed. - if (!awaitedChild || (awaitedChild.phase === 'starting' && startFailure !== null)) { - // The child waited on is gone, replaced by another, or settled its start without proving it. + // A child still starting takes input: the provider queues it behind its own start. + if (!awaitedChild) { + // The child started for this message is gone or replaced by another. const ended = awaitedChild ? undefined : session.lastEndedChild const endedFailure = ended ? structuredAgentSessionEndedChildFailure(ended) : undefined // A user's Stop or close is not a failure: the next step starts, or waits on, a child for @@ -225,7 +217,7 @@ export class StructuredAgentSessionDeliveryLoop { startKey: awaited?.generation ?? null, cause: endedFailure ?? // Gone with no end observed: nothing says the provider stopped. - { failure: startFailure ?? agentSessionFailureFact('startFailed') } + { failure: agentSessionFailureFact('startFailed') } }) } const next = oldestQueuedSubmission(session) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index 8b2586a9ccba..dcb7e0ee6f2a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -17,7 +17,8 @@ import { AgentHookServer, _internals } from '../../agent-hooks/server' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -185,7 +186,7 @@ describe('a Claude chat whose CLI is still starting', () => { expect(await dispatchState(held)).toBe('pending') landInit() - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await vi.waitFor( () => expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]), @@ -208,7 +209,7 @@ describe('a Claude chat whose CLI is still starting', () => { // Startup lands just before the window closes; the handover's publish starts it again. landInit() - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) await untilSent(connection) expect(connection.sent).toEqual([expect.objectContaining({ type: 'user' })]) @@ -227,7 +228,7 @@ describe('a Claude chat whose CLI is still starting', () => { it('stops the agent and closes the conversation once its turn has finished and it idled', async () => { await attachStarting() landInit() - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) const answered = await send('answered') await vi.waitFor(async () => expect(await dispatchState(answered)).toBe('accepted')) claude.connections[0].handlers.onMessage?.({ @@ -265,7 +266,7 @@ describe('a chat whose settled lead still has background work running', () => { async function settleTurnLeavingTask(taskType: string): Promise { await attachStarting() landInit() - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) const fanOut = await send('fan out') await vi.waitFor(async () => expect(await dispatchState(fanOut)).toBe('accepted')) frame({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index 2922c3fdb2ec..d6be06105864 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -10,7 +10,8 @@ import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, - PROVIDER_SESSION_ID + PROVIDER_SESSION_ID, + claudeStartupSettled } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -116,7 +117,7 @@ describe('a publish-first Claude create whose init is slow', () => { expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() expect(lastPhase()).toBe('starting') - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('claude-opus-9') @@ -130,7 +131,7 @@ describe('a publish-first Claude create whose init is slow', () => { ).resolves.toMatchObject({ ok: true }) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') @@ -140,7 +141,7 @@ describe('a publish-first Claude create whose init is slow', () => { it('keeps the picked model across a resume whose new child starts on its own default', async () => { const params = claudeParams() await host.attach(CALLER, { ...params, options: { model: 'opus' } }) - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) await host.close(SESSION, 'evict') const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -154,7 +155,7 @@ describe('a publish-first Claude create whose init is slow', () => { expect(store.getRecord(SESSION)?.options?.model).toBe('opus') expect(lastPhase()).toBe('starting') - await adapter.awaitStarted(SESSION) + await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index a291943fc0fd..f6dfeedd0eae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -81,9 +81,9 @@ function invalid( } /** A thrown adapter error is indistinguishable from a lost reply, so it settles as `unknown` - * rather than as a rejection — unless the child had not proven its start. Such a child has - * accepted nothing (input is written only after it initializes), so a dispatch it could not - * take is provably unwritten and is rejected with the cause the adapter gave. */ + * rather than as a rejection — unless the child had not proven its start. A dispatch to such a + * child throws only when the start failed before the write (a write's own failure is an outcome, + * not a throw), so it is provably unwritten and is rejected with the cause the adapter gave. */ async function dispatchSafely( ctx: AgentSessionHandoverContext, clientMessageId: string, From 222792bc6c84e7504d42525e1f7074ffaed662db Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:20:12 -0700 Subject: [PATCH 08/22] fix(native-chat): a host stop of a Claude start fails the message it was handed, with one row With no start-hold the delivery loop no longer sees a host stop of a start it waited on. The child's end now rejects what it handed over with the host-stopped words and writes the one row, as an exit of its own would; an idle start the host stops still goes quietly. --- .../structured-agent-session-child-exit.ts | 29 ++++++++++++++----- ...red-agent-session-conversation-lifetime.ts | 5 ++-- ...de-structured-send-during-startup.test.ts} | 0 3 files changed, 24 insertions(+), 10 deletions(-) rename src/main/runtime/{claude-structured-send-held-for-startup.test.ts => claude-structured-send-during-startup.test.ts} (100%) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index 2fcbf0ebb4e3..11b5750eb359 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -3,7 +3,10 @@ // every step after it is bookkeeping: each is attempted and reported, none keeps the child on // record, and the record ends in `finally`. `expected` changes only what the chat is told. -import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' +import { + agentSessionFailureFact, + type SubmissionRejectionFact +} from '../../../shared/agent-session-failure' import { PROVIDER_EXIT_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -106,6 +109,14 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< // The host's own phase decides, so a provider that omits the flag still gets a start that // failed told as one: the row says so. const exitedDuringStartup = exit.startupUnproven === true || child.phase === 'starting' + // A close during startup settles what the child was handed, never echoed, by who asked: a + // person's Stop stops it; the host's stop fails the start, as an exit of its own would. + const startClose = expected && exitedDuringStartup ? close?.cause : undefined + const startFailure = expected + ? startClose === 'host-stop' + ? agentSessionFailureFact('hostStopped') + : undefined + : exit.failure const endChild = (): void => { endProviderChild(session, { generation: child.generation, @@ -139,6 +150,10 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< logExitFailure(context, sessionId, 'exit-lifecycle-barrier', error) } const unfinishedWork = captureUnfinishedStructuredAgentSessionWork(session.journal) + // The host's stop fails only a start something was handed to; an idle one goes quietly. + const startFailed = + exitedDuringStartup && + (!expected || (startClose === 'host-stop' && unfinishedWork.hadUnsettledSubmissions)) // Folded before the fallback's end is built, so the end reads it (`turnEndAfterStop`). await close?.recorded const generation = child.generation ?? 'unknown' @@ -156,20 +171,18 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< failureTextContext: structuredAgentSessionFailureWordsContext(record, session.journal), // A failed start always says why: no response was running to carry the reason. showUnexpectedExitOutcome: - !expected && - (exitedDuringStartup || + startFailed || + (!expected && unfinishedStructuredAgentSessionWorkWasInterrupted( unfinishedWork, session.journal, observedAt )), - ...(!expected && exit.failure ? { exitFailure: exit.failure } : {}), - ...(!expected && exitedDuringStartup && child.generation + ...(startFailure ? { exitFailure: startFailure } : {}), + ...(startFailed && child.generation ? { exitedDuringStartup: { generation: child.generation } } : {}), - ...(expected && exitedDuringStartup && close?.cause === 'user-stop' - ? { stoppedDuringStartup: true as const } - : {}) + ...(startClose === 'user-stop' ? { stoppedDuringStartup: true as const } : {}) }) if (!settled.ok) { logExitFailure(context, sessionId, 'exit-settlement', settled.error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 3a35b4b138ae..5ed499f029d7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -90,8 +90,9 @@ export function createStructuredAgentSessionConversationLifetime(host: { providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true, // The host puts an idle agent to rest: a turn it cuts short is news, not the user's Stop. stopAgent: (sessionId) => stopAgent(sessionId, { cause: 'evict', resting: true }), - // A host stop: the delivery loop waiting on this child writes the one error row and rejects - // what is queued with it, both worded from the hostStopped fact. + // A host stop: the child's end rejects what it was handed and writes the one error row, and + // the delivery loop rejects what is still queued under the same row, all worded from the + // hostStopped fact. stopStartingAgent: (sessionId) => stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause: 'host-stop' diff --git a/src/main/runtime/claude-structured-send-held-for-startup.test.ts b/src/main/runtime/claude-structured-send-during-startup.test.ts similarity index 100% rename from src/main/runtime/claude-structured-send-held-for-startup.test.ts rename to src/main/runtime/claude-structured-send-during-startup.test.ts From f96208b76fae645efbc464006b91428b4895ac8e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:20:12 -0700 Subject: [PATCH 09/22] test(native-chat): a Claude chat's first message is written before initialize answers Rewrites the tests that encoded the start-hold, the startup deadline and the option restore to the new contract, and adds: saved options at launch (catalog checks, bypass, fresh-session Fast), a message written before initialize answers (adapter and runtime), Stop on a start that never answers (stopped, child closed, nothing working), and an API error said once. --- .../claude-api-error-reply-journaling.test.ts | 115 ++++++++ ...claude-structured-effort-reporting.test.ts | 30 +- .../claude-structured-model-preflight.test.ts | 31 +-- ...-structured-options-context-window.test.ts | 44 +-- .../claude/claude-structured-options.test.ts | 54 +--- .../claude-structured-session-adapter.test.ts | 142 ++-------- .../claude-structured-session-startup.test.ts | 152 +++++----- .../claude-structured-spawn-options.test.ts | 260 ++++++++++++++++++ ...-agent-session-accept-then-deliver.test.ts | 107 +++---- ...ent-session-claude-hung-start-stop.test.ts | 155 +++++++++++ ...d-agent-session-claude-queued-stop.test.ts | 25 +- ...t-session-claude-stop-ends-session.test.ts | 5 +- ...d-agent-session-conversation-close.test.ts | 30 +- ...ed-agent-session-conversation-stop.test.ts | 36 ++- ...gent-session-delivery-hold.test-fixture.ts | 35 +++ ...tructured-agent-session-idle-sweep.test.ts | 26 +- ...ssion-main-agent-working-agreement.test.ts | 1 - ...ed-agent-session-owed-work-release.test.ts | 30 +- ...gent-session-provider-child-record.test.ts | 233 ++++++++-------- ...session-queued-message-rig.test-fixture.ts | 5 - ...ured-agent-session-queued-messages.test.ts | 18 +- ...ed-agent-session-queued-pause-lift.test.ts | 16 +- ...ent-session-queued-withdrawn-draft.test.ts | 34 ++- ...agent-session-start-failure-writer.test.ts | 108 ++++---- ...d-agent-session-stop-event-binding.test.ts | 10 +- ...d-agent-session-stop-event-entries.test.ts | 14 +- ...tructured-agent-session-stop-event.test.ts | 25 +- ...structured-conversation-compaction.test.ts | 25 +- ...ude-structured-send-during-startup.test.ts | 49 ++-- ...-send-restart-dies-before-dispatch.test.ts | 9 +- ...tured-startup-fault-is-not-an-exit.test.ts | 57 +--- ...startup-unanswered-control-request.test.ts | 55 ++-- ...ed-claude-scripted-runtime-test-support.ts | 3 +- 33 files changed, 1117 insertions(+), 822 deletions(-) create mode 100644 src/main/claude/claude-api-error-reply-journaling.test.ts create mode 100644 src/main/claude/claude-structured-spawn-options.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-delivery-hold.test-fixture.ts diff --git a/src/main/claude/claude-api-error-reply-journaling.test.ts b/src/main/claude/claude-api-error-reply-journaling.test.ts new file mode 100644 index 000000000000..0f3d5b1592ee --- /dev/null +++ b/src/main/claude/claude-api-error-reply-journaling.test.ts @@ -0,0 +1,115 @@ +// The CLI answers an API error (a retired model, a lost sign-in) with a synthetic assistant reply, +// then a failed result repeating its text: captured in the auth-failed lifecycle capture, Claude +// Code 2.1.280. The chat says it once. + +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +const MODEL_ERROR = + "There's an issue with the selected model (claude-retired-1). It may not exist or you may not have access to it." + +function journaled() { + const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => items.push({ identity, body }), + appendTombstone: () => {}, + publish: vi.fn() + } + return { items, translator: createClaudeJournalTranslator({ sink }) } +} + +function frame(message: Record, startsTurn = false) { + return { + type: 'message' as const, + sessionId: 'orca-session', + ...(startsTurn ? { startsTurn: true as const } : {}), + message: { session_id: 'claude-session', parent_tool_use_id: null, ...message } + } +} + +function sendEcho(text: string) { + return frame( + { type: 'user', uuid: 'user-1', message: { role: 'user', content: [{ type: 'text', text }] } }, + true + ) +} + +function apiErrorReply(text: string) { + return frame({ + type: 'assistant', + uuid: 'assistant-1', + error: 'model_not_found', + is_api_error_message: true, + message: { model: '', role: 'assistant', content: [{ type: 'text', text }] } + }) +} + +function failedResult(text: string) { + return frame({ + type: 'result', + subtype: 'success', + uuid: 'result-1', + is_error: true, + result: text, + terminal_reason: 'api_error' + }) +} + +function statusRows(items: { body: AgentJournalItemBody }[]): string[] { + return items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) +} + +describe('a Claude API error the turn already replied with', () => { + it('is said once, as the reply, and the turn reads failed', () => { + const { items, translator } = journaled() + + translator.handle(sendEcho('hello')) + translator.handle(apiErrorReply(MODEL_ERROR)) + translator.handle(failedResult(MODEL_ERROR)) + + expect(items.filter((item) => JSON.stringify(item.body).includes('selected model'))).toEqual([ + expect.objectContaining({ + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: MODEL_ERROR }] } + }) + ]) + expect(statusRows(items)).toEqual([]) + expect(items.findLast((item) => item.identity.provider === 'legacy')?.body).toMatchObject({ + kind: 'turn', + state: 'completed', + outcome: 'failure' + }) + }) + + it('still gets its error row when the failed result says something the reply did not', () => { + const { items, translator } = journaled() + + translator.handle(sendEcho('hello')) + translator.handle(apiErrorReply('Not logged in')) + translator.handle(failedResult('API Error: 529 upstream overloaded')) + + expect(statusRows(items)).toEqual(['API Error: 529 upstream overloaded']) + }) + + it('does not hide the error row of a later turn', () => { + const { items, translator } = journaled() + + translator.handle(sendEcho('hello')) + translator.handle(apiErrorReply(MODEL_ERROR)) + translator.handle(failedResult(MODEL_ERROR)) + translator.handle({ + ...sendEcho('again'), + message: { ...sendEcho('again').message, uuid: 'user-2' } + }) + translator.handle({ + ...failedResult(MODEL_ERROR), + message: { ...failedResult(MODEL_ERROR).message, uuid: 'result-2' } + }) + + expect(statusRows(items)).toEqual([MODEL_ERROR]) + }) +}) diff --git a/src/main/claude/claude-structured-effort-reporting.test.ts b/src/main/claude/claude-structured-effort-reporting.test.ts index 2c319c86ee72..3305a40d77a3 100644 --- a/src/main/claude/claude-structured-effort-reporting.test.ts +++ b/src/main/claude/claude-structured-effort-reporting.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest' import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' -import { - restoreClaudeStructuredSessionOptions, - setClaudeStructuredOption -} from './claude-structured-options' +import { setClaudeStructuredOption } from './claude-structured-options' import { readClaudeSettingsEffort } from './claude-structured-session-options' import type { ClaudeSession } from './claude-structured-session-state' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' @@ -238,29 +235,4 @@ describe('Claude effort against the model that must run it', () => { setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) ).rejects.toBeInstanceOf(AgentSessionOptionRejectedError) }) - - it('keeps a disagreeing effort through restore instead of skipping it', async () => { - const calls: string[] = [] - const { session } = sessionWith('high', calls, { model: 'sonnet', catalog: [SONNET] }) - session.options.set('effort', 'low') - - await restoreClaudeStructuredSessionOptions(session, undefined) - - expect(session.options.get('effort')).toBe('low') - expect(session.restoreSkippedOptions.has('effort')).toBe(false) - expect(session.confirmedOptions.has('effort')).toBe(false) - }) - - it('drops a stale effort on restore instead of replaying it onto the new model', async () => { - const calls: string[] = [] - const { session } = sessionWith('high', calls, { model: 'sonnet', catalog: [HAIKU, SONNET] }) - session.options.set('model', 'haiku') - session.options.set('effort', 'high') - - await restoreClaudeStructuredSessionOptions(session, undefined) - - expect(session.options.has('effort')).toBe(false) - expect(session.restoreSkippedOptions.has('effort')).toBe(true) - expect(calls.filter((call) => call.startsWith('apply:'))).toEqual([]) - }) }) diff --git a/src/main/claude/claude-structured-model-preflight.test.ts b/src/main/claude/claude-structured-model-preflight.test.ts index 930947e112db..a44ca0232b87 100644 --- a/src/main/claude/claude-structured-model-preflight.test.ts +++ b/src/main/claude/claude-structured-model-preflight.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest' import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' -import { - restoreClaudeStructuredSessionOptions, - setClaudeStructuredOption -} from './claude-structured-options' +import { setClaudeStructuredOption } from './claude-structured-options' import type { ClaudeSession } from './claude-structured-session-state' /** Verbatim row shapes from Claude Code 2.1.260's list_models response. */ @@ -58,18 +55,6 @@ describe('Claude model pre-flight against the catalog the CLI listed', () => { expect(session.options.has('model')).toBe(false) }) - it('refuses an unlisted model replayed by restore, and skips it', async () => { - // Needs no user error: a model valid when it was persisted can be retired. - const { session, calls } = sessionWith([DEFAULT_ROW, SONNET]) - session.options.set('model', 'claude-opus-4-retired') - - await restoreClaudeStructuredSessionOptions(session, undefined) - - expect(calls).toEqual(['list_models']) - expect(session.options.has('model')).toBe(false) - expect([...session.restoreSkippedOptions]).toEqual(['model']) - }) - it('applies a model the provider lists', async () => { const { session, calls } = sessionWith([DEFAULT_ROW, SONNET, HAIKU]) @@ -89,8 +74,7 @@ describe('Claude model pre-flight against the catalog the CLI listed', () => { }) it('refuses nothing when list_models is unavailable', async () => { - // A CLI predating list_models would otherwise have every model refused, and - // restore swallows the rejection, so the user's pick would vanish silently. + // A CLI predating list_models would otherwise have every model refused. const { session, calls } = sessionWith('unavailable') await expect( @@ -118,15 +102,4 @@ describe('Claude model pre-flight against the catalog the CLI listed', () => { ).resolves.toEqual({ model: 'sonnet' }) expect(calls).toEqual(['list_models', 'set_model:sonnet']) }) - - it('leaves a restored model the provider lists in place', async () => { - const { session, calls } = sessionWith([DEFAULT_ROW, SONNET]) - session.options.set('model', 'sonnet') - - await restoreClaudeStructuredSessionOptions(session, undefined) - - expect(calls).toEqual(['list_models', 'set_model:sonnet']) - expect(session.options.get('model')).toBe('sonnet') - expect([...session.restoreSkippedOptions]).toEqual([]) - }) }) diff --git a/src/main/claude/claude-structured-options-context-window.test.ts b/src/main/claude/claude-structured-options-context-window.test.ts index 452009b36895..e15d701c34bd 100644 --- a/src/main/claude/claude-structured-options-context-window.test.ts +++ b/src/main/claude/claude-structured-options-context-window.test.ts @@ -4,10 +4,8 @@ import { selectStructuredAgentContextUsage } from '../../shared/structured-agent import { assistantFrame, journal, userFrame } from './claude-context-usage-test-support' import { sessionFor } from './claude-structured-dispatch-test-support' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' -import { - restoreClaudeStructuredSessionOptions, - setClaudeStructuredOption -} from './claude-structured-options' +import { setClaudeStructuredOption } from './claude-structured-options' +import { adoptClaudeStructuredSpawnOptions } from './claude-structured-spawn-options' import type { ClaudeSession } from './claude-structured-session-state' function ringSession(catalog: unknown[] = []) { @@ -62,27 +60,32 @@ describe('the context ring after a session option write', () => { expect(s.modelMayHaveChanged).not.toHaveBeenCalled() }) - it('keeps the ring through a restore that changes nothing', async () => { + it('keeps the ring through a launch that took the saved values', () => { const s = ringSession() - s.session.options.set('model', 'opusplan') - s.session.options.set('permissionMode', 'plan') - await restoreClaudeStructuredSessionOptions(s.session, undefined) - expect(s.setModel).toHaveBeenCalledWith('opusplan', { timeoutMs: undefined }) + adoptClaudeStructuredSpawnOptions(s.session, { + options: new Map([ + ['model', 'opusplan'], + ['permissionMode', 'plan'] + ]), + skipped: [] + }) + expect(s.setModel).not.toHaveBeenCalled() expect(s.modelMayHaveChanged).not.toHaveBeenCalled() }) - it('asks for the new window when a restore cannot put the stored model back', async () => { - const s = ringSession([{ value: 'sonnet', displayName: 'Sonnet' }]) - s.session.options.set('model', 'retired-model') - await restoreClaudeStructuredSessionOptions(s.session, undefined) + it('asks for the new window when the launch left the stored model out', () => { + const s = ringSession() + adoptClaudeStructuredSpawnOptions(s.session, { options: new Map(), skipped: ['model'] }) expect(s.session.restoreSkippedOptions).toEqual(new Set(['model'])) expect(s.modelMayHaveChanged).toHaveBeenCalledTimes(1) }) - it('sizes a new session from the model its restore applied', async () => { - const s = ringSession([{ value: 'opus[1m]', displayName: 'Opus (1M)' }]) - s.session.options.set('model', 'opus[1m]') - await restoreClaudeStructuredSessionOptions(s.session, undefined) + it('sizes a new session from the model it was launched with', () => { + const s = ringSession() + adoptClaudeStructuredSpawnOptions(s.session, { + options: new Map([['model', 'opus[1m]']]), + skipped: [] + }) expect(s.respond('turn-a', 1_000)).toMatchObject({ windowTokens: 1_000_000, percentage: 10 }) }) @@ -94,15 +97,14 @@ describe('the context ring after a session option write', () => { expect(s.respond('turn-b', 2_000)).toMatchObject({ windowTokens: 1_000_000, percentage: 10 }) }) - it('implies no window for a model the child refused or a restore could not put back', async () => { + it('implies no window for a model the child refused or the launch left out', async () => { const refused = ringSession() refused.setModel.mockRejectedValueOnce(new ClaudeControlRequestError('set_model', 'refused')) await expect(refused.write('model', 'opus[1m]')).rejects.toThrow() expect(refused.respond('turn-a', 1_000)).toBeNull() - const skipped = ringSession([{ value: 'sonnet', displayName: 'Sonnet' }]) - skipped.session.options.set('model', 'retired-model[1m]') - await restoreClaudeStructuredSessionOptions(skipped.session, undefined) + const skipped = ringSession() + adoptClaudeStructuredSpawnOptions(skipped.session, { options: new Map(), skipped: ['model'] }) expect(skipped.respond('turn-a', 1_000)).toBeNull() }) diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index e5b7a7ea8a77..ff63e7108462 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -1,19 +1,12 @@ import { describe, expect, it, vi } from 'vitest' -import { - restoreClaudeStructuredSessionOptions, - setClaudeStructuredOption -} from './claude-structured-options' +import { setClaudeStructuredOption } from './claude-structured-options' import type { ClaudeSession } from './claude-structured-session-state' -import { - ClaudeControlRequestError, - ClaudeControlRequestTimeoutError -} from './claude-agent-sdk-control-requests' +import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-requests' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' import { createClaudeSessionStartup } from './claude-structured-session-startup-state' import { - claudeStructuredSessionOptionsFrom, observeClaudeFastModeFacts, readClaudeStructuredSessionOptions } from './claude-structured-session-options' @@ -167,17 +160,6 @@ describe('Claude structured Fast mode', () => { expect(reads).toBe(1) }) - it('restores explicit Fast off when model support is unknown', async () => { - const { session, applyFlagSettings } = fastModeSession(undefined) - session.options.set('fastMode', 'false') - - await restoreClaudeStructuredSessionOptions(session, undefined) - - expect(session.options.get('fastMode')).toBe('false') - expect(session.restoreSkippedOptions.has('fastMode')).toBe(false) - expect(applyFlagSettings).toHaveBeenCalledWith({ fastMode: false }, { timeoutMs: undefined }) - }) - it('resolves the running CLI default model before applying Fast', async () => { const { session, applyFlagSettings } = fastModeSession(true) session.options.delete('model') @@ -439,37 +421,7 @@ describe('Claude Fast mode reported by the session frame alone', () => { }) }) -describe('Claude structured option restore under the request deadline', () => { - it('keeps a saved choice the CLI never answered as wanted but unconfirmed, and drops a refused one', async () => { - const session = sessionFor(async () => { - throw new ClaudeControlRequestTimeoutError('set_model') - }) - session.connection.applyFlagSettings = async () => { - throw new ClaudeControlRequestTimeoutError('apply_flag_settings') - } - session.connection.setPermissionMode = async () => { - throw new ClaudeControlRequestError('set_permission_mode', 'unknown mode') - } - // Startup already read the CLI's own model and effort, and vouched for them. - session.reportedOptions = { model: 'claude-sonnet-5', effort: 'medium' } - session.confirmedOptions.add('effort') - session.options = new Map([ - ['model', 'sonnet'], - ['effort', 'high'], - ['permissionMode', 'plan'] - ]) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await expect(restoreClaudeStructuredSessionOptions(session, 10)).resolves.toBeUndefined() - - expect(Object.fromEntries(session.options)).toEqual({ model: 'sonnet', effort: 'high' }) - expect([...session.restoreSkippedOptions]).toEqual(['permissionMode']) - expect(claudeStructuredSessionOptionsFrom(session, null).current).toEqual({ - model: 'sonnet', - effort: 'high' - }) - }) - +describe('Claude structured option write under the request deadline', () => { it("keeps a timed-out client write as the deadline's own error, not a rejection", async () => { const session = sessionFor(async () => { throw new ClaudeControlRequestTimeoutError('set_model') diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index cd5b4841de6d..fe2be22b0d40 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -7,7 +7,6 @@ import { AgentSessionPromptAnswerRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' -import { ClaudeControlRequestError } from './claude-stream-json-connection' import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity' import type { ClaudeStructuredSessionAdapter, @@ -67,7 +66,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { expect(events[0]).toMatchObject({ type: 'message', message: { subtype: 'hook_started' } }) }) - it('restores persisted model and effort before publishing a reacquired session', async () => { + it('launches a reacquired session with its persisted model and effort, and writes neither', async () => { const claude = fakeClaude() const adapter = adapterFor(claude, { resumesTranscript: true, continuesChain: true }) @@ -78,50 +77,21 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { options: { model: 'opus', effort: 'high' } }) - expect(claude.connections[0].calls.slice(-4)).toEqual([ - { subtype: 'set_model', params: { model: 'opus' } }, - // The restored model's advertised levels gate the replay, so a stale effort - // is dropped rather than re-applied to a model with no effort control. - { subtype: 'list_models' }, - { subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'high' } } }, - // The effort is only recorded once the child reports having adopted it. - { subtype: 'get_settings' } + expect(claude.connections[0].launch.options).toMatchObject({ model: 'opus', effort: 'high' }) + expect(claude.connections[0].calls.map((call) => call.subtype)).toEqual([ + 'initialize', + 'get_settings' ]) await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ current: { model: 'opus', effort: 'high' } }) }) - it('restores an encoded Fast preference through the absolute flag setting', async () => { + it('launches a resumed conversation with its saved Fast as a flag setting', async () => { const claude = fakeClaude({ - settings: { effective: { fastMode: false, fastModePerSessionOptIn: false } }, - routes: { - list_models: () => [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }] - } + settings: { effective: { fastMode: true, fastModePerSessionOptIn: false } } }) - const adapter = adapterFor(claude) - - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - options: { model: 'opus', fastMode: 'true' } - }) - - expect(claude.connections[0].calls).toContainEqual({ - subtype: 'apply_flag_settings', - params: { settings: { fastMode: true } } - }) - }) - - it('does not carry a saved opt-in into a new per-session-opt-in child', async () => { - const claude = fakeClaude({ - settings: { effective: { fastMode: false, fastModePerSessionOptIn: true } }, - routes: { - list_models: () => [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }] - } - }) - const adapter = adapterFor(claude) + const adapter = adapterFor(claude, { resumesTranscript: true, continuesChain: true }) await adapter.acquire({ identity: identityFor(), @@ -130,19 +100,17 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { options: { model: 'opus', fastMode: 'true' } }) + expect(claude.connections[0].launch.options.settings).toEqual({ fastMode: true }) expect( claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') ).toEqual([]) }) - it('restores Fast when reacquiring the same per-session-opt-in conversation', async () => { + it('does not carry a saved Fast opt-in into a new conversation', async () => { const claude = fakeClaude({ - settings: { effective: { fastMode: false, fastModePerSessionOptIn: true } }, - routes: { - list_models: () => [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }] - } + settings: { effective: { fastMode: false, fastModePerSessionOptIn: true } } }) - const adapter = adapterFor(claude, { resumesTranscript: true, continuesChain: true }) + const adapter = adapterFor(claude) await adapter.acquire({ identity: identityFor(), @@ -151,85 +119,27 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { options: { model: 'opus', fastMode: 'true' } }) - expect(claude.connections[0].calls).toContainEqual({ - subtype: 'apply_flag_settings', - params: { settings: { fastMode: true } } - }) - }) - - it('self-heals a Fast preference the running model no longer supports', async () => { - const claude = fakeClaude({ - settings: { effective: { fastMode: false } }, - routes: { - list_models: () => [{ value: 'opus', displayName: 'Opus', supportsFastMode: false }] - } - }) - const adapter = adapterFor(claude) - - await expect( - adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - options: { model: 'opus', fastMode: 'true' } - }) - ).resolves.toBeDefined() - - expect(adapter.readOptionRestoreFailures('session-1')).toContain('fastMode') + expect(claude.connections[0].launch.options.settings).toBeUndefined() expect( claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') ).toEqual([]) + // Left out, not refused: the record keeps the provider's own Fast, as before. + expect(adapter.readOptionRestoreFailures('session-1')).toEqual([]) }) it.each([ - ['model', 'set_model', { model: 'retired-model' }], - ['effort', 'apply_flag_settings', { effort: 'retired-effort' }], - ['permissionMode', 'set_permission_mode', { permissionMode: 'retired-mode' }] - ] as const)( - 'self-heals a persisted %s rejected during restore', - async (key, subtype, options) => { - const claude = fakeClaude({ - routes: { - [subtype]: () => { - throw new ClaudeControlRequestError(subtype, 'value is no longer available') - } - } - }) - const adapter = adapterFor(claude) - - await expect( - adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - options - }) - ).resolves.toBeDefined() - expect(adapter.readOptionRestoreFailures('session-1')).toEqual([key]) - } - ) - - it('does not treat a transport timeout while restoring an option as recoverable', async () => { - const claude = fakeClaude({ - routes: { - set_model: () => { - throw new Error('claude set_model request timed out') - } - } - }) - const input = { - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - options: { model: 'temporarily-unavailable' } - } + ['effort', { effort: 'retired-effort' }], + ['permissionMode', { permissionMode: 'retired-mode' }] + ] as const)('self-heals a persisted %s the CLI would refuse at launch', async (key, options) => { + const claude = fakeClaude() + const adapter = adapterFor(claude) - // Restore runs after publish, so its failure ends the session rather than the create. - await expect(endedAtStartup(claude, input)).resolves.toMatchObject({ - reason: 'claude set_model request timed out', - startupUnproven: true - }) - expect(claude.connections[0]?.closeCount).toBe(1) + await expect( + adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', options }) + ).resolves.toBeDefined() + expect(adapter.readOptionRestoreFailures('session-1')).toEqual([key]) + expect(claude.connections[0].launch.options).not.toHaveProperty('effort') + expect(claude.connections[0].launch.options).not.toHaveProperty('permissionMode') }) it('recovers a cancellable lifecycle when the replay arrives after dispatch returned', async () => { diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index 55ef841e05b1..30a1ba2946c3 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -5,12 +5,13 @@ import { adapterAtPublishFor, fakeClaude, identityFor, - PROVIDER_SESSION_ID, USER_MESSAGE, claudeStartupSettled } from './claude-structured-session-test-support' import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' -import { CLAUDE_STARTUP_DEADLINE_MS } from './claude-structured-session-startup' + +/** Far past any deadline a start used to have. */ +const NEVER_MS = 10 * 60_000 type LateSettlement = Parameters< NonNullable @@ -68,15 +69,11 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.closeAll() }) - it('reports `started` once saved options are restored, having written no prompt of its own', async () => { + it('reports `started` with the options its child was launched with, having written none', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS, initModel: 'claude-opus-9' }) const { adapter, events } = startingAdapter(claude) - const order: string[] = [] - claude.routes.set_model = () => { - order.push('set_model') - return undefined - } await adapter.acquire({ ...ACQUIRE, options: { model: 'opus' } }) + expect(claude.connections[0].launch.options.model).toBe('opus') expect(events.some((event) => event.type === 'started')).toBe(false) await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) @@ -88,12 +85,14 @@ describe('Claude structured session publishes before the CLI answers initialize' sessionId: 'session-1', fence: 7, acquisitionGeneration: expect.any(String), - // What the restore just proved, carried so the host never asks the CLI again. + // What the child was launched with, carried so the host never asks the CLI again. reportedOptions: expect.objectContaining({ model: 'opus' }), restoreSkippedOptions: [] }) - // The restore wrote the saved model before `started`; no message waits inside the adapter. - expect(order).toEqual(['set_model']) + expect(claude.connections[0].calls.map(({ subtype }) => subtype)).toEqual([ + 'initialize', + 'get_settings' + ]) expect(claude.connections[0].sent).toEqual([]) expect(events.slice(0, startedAt).some((event) => event.type === 'options')).toBe(true) await adapter.closeAll() @@ -135,27 +134,51 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.closeAll() }) - // The host's delivery loop waits here before it hands a message over, so the adapter no longer - // holds prompts of its own: nothing is written until startup lands because nothing is sent. - it('resolves awaitStarted only once startup lands', async () => { + // The child was launched with the chat's saved options, so nothing waits on initialize: the SDK + // streams the message to the CLI, which takes it behind its own start. + it('writes a message sent before initialize answers at once', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) - const { adapter } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - let started = false - const waited = claudeStartupSettled(adapter, 'session-1').then(() => { - started = true - }) - - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS - 1) - expect(started).toBe(false) - await vi.advanceTimersByTimeAsync(1) - await waited + const { adapter, events } = startingAdapter(claude) + await adapter.acquire({ ...ACQUIRE, options: { model: 'opus' } }) await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) + expect(events.some((event) => event.type === 'started')).toBe(false) + + await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) + await claudeStartupSettled(adapter, 'session-1') + expect(events.some((event) => event.type === 'started')).toBe(true) await adapter.closeAll() }) + it('confirms a launched effort only when the settings readback reports that effort', async () => { + const reportsHigh = { + applied: { model: 'claude-sonnet-5', effort: 'high' }, + effective: { model: 'claude-sonnet-5', effortLevel: 'high', env: {} }, + sources: {} + } + const agreeing = fakeClaude({ settings: reportsHigh }) + const disagreeing = fakeClaude({ settings: reportsHigh }) + const started = async (claude: ReturnType, effort: string) => { + const { adapter } = startingAdapter(claude) + await adapter.acquire({ ...ACQUIRE, options: { effort } }) + await claudeStartupSettled(adapter, 'session-1') + return adapter + } + + const kept = await started(agreeing, 'high') + await expect(kept.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ + current: { effort: 'high', confirmed: expect.arrayContaining(['effort']) } + }) + // The saved pick stays wanted; only the readback could vouch for it, and it did not. + const unconfirmed = await started(disagreeing, 'low') + const options = await unconfirmed.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(options.current.effort).toBe('low') + expect(options.current.confirmed ?? []).not.toContain('effort') + await kept.closeAll() + await unconfirmed.closeAll() + }) + it('ends the session with the exit reason when the CLI dies before init', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS, @@ -197,12 +220,12 @@ describe('Claude structured session publishes before the CLI answers initialize' }) }) - it('lands a start whose CLI answers initialize just inside the startup deadline', async () => { - const claude = fakeClaude({ initDelayMs: CLAUDE_STARTUP_DEADLINE_MS - 1 }) + it('lands a start whose CLI answers initialize only after minutes', async () => { + const claude = fakeClaude({ initDelayMs: NEVER_MS - 1 }) const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) - await vi.advanceTimersByTimeAsync(CLAUDE_STARTUP_DEADLINE_MS) + await vi.advanceTimersByTimeAsync(NEVER_MS) await claudeStartupSettled(adapter, 'session-1') expect(events.some((event) => event.type === 'started')).toBe(true) @@ -211,61 +234,24 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.closeAll() }) - // A CLI alive but silent would otherwise hold every message sent to the chat forever. - it('ends a start whose CLI never answers initialize at the deadline, and ignores a late answer', async () => { - const claude = fakeClaude({ initDelayMs: CLAUDE_STARTUP_DEADLINE_MS + SLOW_INIT_MS }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - - await vi.advanceTimersByTimeAsync(CLAUDE_STARTUP_DEADLINE_MS - 1) - expect(events.some((event) => event.type === 'ended')).toBe(false) - expect(claude.connections[0].closeCount).toBe(0) - await vi.advanceTimersByTimeAsync(1) - await claudeStartupSettled(adapter, 'session-1') - await adapter.drainObservedExits() - - // Orca ended it, so the chat says Claude couldn't start, not that it stopped on its own. - expect(events.find((event) => event.type === 'ended')).toMatchObject({ - reason: `claude did not answer initialize within ${CLAUDE_STARTUP_DEADLINE_MS / 1000}s`, - cause: 'unexpected-exit', - failure: { kind: 'startFailed' }, - startupUnproven: true - }) - expect(claude.connections[0].closeCount).toBe(1) - - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - expect(events.some((event) => event.type === 'started' || event.type === 'options')).toBe(false) - expect(claude.connections[0].calls.map(({ subtype }) => subtype)).not.toContain('get_settings') - expect(claude.connections[0].sent).toEqual([]) - }) - - // Claude answers initialize only once its SessionStart hooks finish; their frames show it is alive. - it('restarts the deadline on each start frame, so hooks that run in turn are not cut off', async () => { - const claude = fakeClaude({ - initDelayMs: CLAUDE_STARTUP_DEADLINE_MS + SLOW_INIT_MS, - initProof: 'none' - }) + // No deadline ends it: a Stop or a close does, and every message is already written meanwhile. + it('keeps a start whose CLI never answers initialize, taking messages, until it is closed', async () => { + const claude = fakeClaude({ initDelayMs: 10 * NEVER_MS }) const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) + await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) - await vi.advanceTimersByTimeAsync(CLAUDE_STARTUP_DEADLINE_MS - 1) - claude.connections[0].handlers.onMessage?.({ - type: 'system', - subtype: 'hook_started', - hook_name: 'SessionStart:startup', - session_id: PROVIDER_SESSION_ID - }) - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS + 1) - await claudeStartupSettled(adapter, 'session-1') + await vi.advanceTimersByTimeAsync(NEVER_MS) - expect(events.some((event) => event.type === 'started')).toBe(true) - expect(events.some((event) => event.type === 'ended')).toBe(false) + expect(events.some((event) => event.type === 'ended' || event.type === 'started')).toBe(false) expect(claude.connections[0].closeCount).toBe(0) - await adapter.closeAll() + expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) + await expect(adapter.closeSession('session-1')).resolves.toBe(true) + expect(claude.connections[0].closeCount).toBe(1) }) it('fails a start at once when a frame names another session before initialize answers', async () => { - const claude = fakeClaude({ initDelayMs: CLAUDE_STARTUP_DEADLINE_MS * 2, initProof: 'none' }) + const claude = fakeClaude({ initDelayMs: NEVER_MS, initProof: 'none' }) const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) @@ -333,8 +319,8 @@ describe('Claude structured session publishes before the CLI answers initialize' }) }) - // A Stop that closes a child still starting must end the wait the host's delivery loop is in, - // though initialize never answers; otherwise every later send joins a loop that never moves. + // A Stop that closes a child still starting must end the wait an option write is in, though + // initialize never answers. it('ends the wait on a start closed before init, without faulting it', async () => { const claude = fakeClaude({ initDelayMs: 10 * SLOW_INIT_MS }) const { adapter, events } = startingAdapter(claude) @@ -357,17 +343,19 @@ describe('Claude structured session publishes before the CLI answers initialize' expect(events.some((event) => event.type === 'started')).toBe(false) }) - it('interrupts nothing when Stop lands before init: nothing was written', async () => { + // The CLI answers no control request before initialize; Claude's Stop ends the child instead. + it('sends no interrupt to a CLI still starting, though it was handed a message', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) const { adapter } = startingAdapter(claude) await adapter.acquire(ACQUIRE) + await adapter.dispatch(PROMPT) - await expect( - adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: false }) + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + cancelled: false + }) expect(claude.connections[0].calls.map(({ subtype }) => subtype)).not.toContain('interrupt') - expect(claude.connections[0].sent).toEqual([]) + expect(adapter.stopEndsSession()).toBe(true) await adapter.closeAll() }) }) diff --git a/src/main/claude/claude-structured-spawn-options.test.ts b/src/main/claude/claude-structured-spawn-options.test.ts new file mode 100644 index 000000000000..2d3d474594e6 --- /dev/null +++ b/src/main/claude/claude-structured-spawn-options.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionModelOption } from '../../shared/agent-session-wire' +import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import { + AgentModelCatalogStore, + type AgentModelCatalogEntry +} from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { + CLAUDE_STRUCTURED_BASE_OPTIONS, + claudeStructuredPermissionOptions, + type ClaudeStructuredSdkOptions +} from './claude-structured-launch-resolution' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { claudeStructuredSpawnOptions } from './claude-structured-spawn-options' +import { + claudeStartupSettled, + fakeClaude, + identityFor, + PROVIDER_SESSION_ID, + recordingJournalSink +} from './claude-structured-session-test-support' + +const ACCOUNT_HOME = '/accounts/claude' +const OPUS: AgentSessionModelOption = { + id: 'opus', + label: 'Opus', + isDefault: true, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ], + supportsFastMode: true +} +const HAIKU: AgentSessionModelOption = { + id: 'haiku', + label: 'Haiku', + isDefault: false, + efforts: [], + supportsFastMode: false +} + +function catalog(models: AgentSessionModelOption[]): AgentModelCatalogEntry { + return { + agent: 'claude', + fingerprint: 'fingerprint', + models, + fastModeTierByModel: {}, + origin: 'probe', + fetchedAt: 0 + } +} + +function launched( + saved: Record, + options: { + catalog?: AgentModelCatalogEntry + resumesTranscript?: boolean + base?: ClaudeStructuredSdkOptions + } = {} +) { + const base = options.base ?? CLAUDE_STRUCTURED_BASE_OPTIONS + return claudeStructuredSpawnOptions({ + launch: { options: base, resumesTranscript: options.resumesTranscript ?? true }, + saved, + catalog: options.catalog ?? null + }) +} + +const BYPASS_LAUNCH: ClaudeStructuredSdkOptions = { + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + extraArgs: { + ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, + ...claudeStructuredPermissionOptions('bypassPermissions').extraArgs + } +} + +describe('a Claude chat launched with its saved options', () => { + it('passes the saved model, effort, Fast and permission mode as launch options', () => { + const spawn = launched({ + model: 'opus', + effort: 'high', + fastMode: 'true', + permissionMode: 'plan' + }) + + expect(spawn.sdkOptions).toMatchObject({ + model: 'opus', + effort: 'high', + settings: { fastMode: true }, + permissionMode: 'plan', + // The base launch is kept whole. + includePartialMessages: true, + extraArgs: { 'replay-user-messages': null } + }) + expect(Object.fromEntries(spawn.options)).toEqual({ + model: 'opus', + effort: 'high', + fastMode: 'true', + permissionMode: 'plan' + }) + expect(spawn.skipped).toEqual([]) + }) + + it('passes everything unchecked when the account has no cached catalog', () => { + const spawn = launched({ model: 'claude-retired-1', effort: 'low' }) + + expect(spawn.sdkOptions).toMatchObject({ model: 'claude-retired-1', effort: 'low' }) + expect(spawn.skipped).toEqual([]) + }) + + it('leaves out a saved model the cached catalog does not list, and records it skipped', () => { + const spawn = launched({ model: 'claude-retired-1' }, { catalog: catalog([OPUS, HAIKU]) }) + + expect(spawn.sdkOptions).not.toHaveProperty('model') + expect(spawn.options.has('model')).toBe(false) + expect(spawn.skipped).toEqual(['model']) + }) + + it('passes a saved model the cached catalog lists', () => { + const spawn = launched({ model: 'haiku' }, { catalog: catalog([OPUS, HAIKU]) }) + + expect(spawn.sdkOptions.model).toBe('haiku') + expect(spawn.skipped).toEqual([]) + }) + + it('leaves out an effort the cached entry for the saved model rules out', () => { + const spawn = launched({ model: 'haiku', effort: 'high' }, { catalog: catalog([OPUS, HAIKU]) }) + + expect(spawn.sdkOptions).toMatchObject({ model: 'haiku' }) + expect(spawn.sdkOptions).not.toHaveProperty('effort') + expect(spawn.skipped).toEqual(['effort']) + }) + + it('passes an effort with no saved model, which the launch cannot check', () => { + const spawn = launched({ effort: 'high' }, { catalog: catalog([HAIKU]) }) + + expect(spawn.sdkOptions.effort).toBe('high') + }) + + it('leaves out an effort the CLI would refuse at launch', () => { + expect(launched({ effort: 'ludicrous' }).skipped).toEqual(['effort']) + }) + + it('leaves out Fast the cached entry says the model does not support', () => { + const spawn = launched({ model: 'haiku', fastMode: 'true' }, { catalog: catalog([HAIKU]) }) + + expect(spawn.sdkOptions).not.toHaveProperty('settings') + expect(spawn.skipped).toEqual(['fastMode']) + }) + + it('starts a new conversation with Fast off rather than carry a saved Fast on', () => { + const spawn = launched({ fastMode: 'true' }, { resumesTranscript: false }) + + expect(spawn.sdkOptions).not.toHaveProperty('settings') + expect(spawn.options.has('fastMode')).toBe(false) + // Left out by rule, not refused: nothing is recorded as skipped. + expect(spawn.skipped).toEqual([]) + }) + + it('passes a saved Fast off to a new conversation too', () => { + const spawn = launched({ fastMode: 'false' }, { resumesTranscript: false }) + + expect(spawn.sdkOptions.settings).toEqual({ fastMode: false }) + }) + + it('launches a saved bypass under an Agent Permissions bypass with the owned bypass flag', () => { + const spawn = launched({ permissionMode: 'bypassPermissions' }, { base: BYPASS_LAUNCH }) + + expect(spawn.sdkOptions.extraArgs).toEqual({ + 'replay-user-messages': null, + 'dangerously-skip-permissions': null + }) + expect(spawn.sdkOptions).not.toHaveProperty('permissionMode') + expect(spawn.sdkOptions).not.toHaveProperty('allowDangerouslySkipPermissions') + }) + + it('never widens the Agent Permissions setting to a saved bypass', () => { + const spawn = launched({ permissionMode: 'bypassPermissions' }) + + expect(spawn.sdkOptions.extraArgs).toEqual({ 'replay-user-messages': null }) + expect(spawn.skipped).toEqual(['permissionMode']) + }) + + it('replaces an Agent Permissions bypass with a saved narrower mode', () => { + const spawn = launched({ permissionMode: 'acceptEdits' }, { base: BYPASS_LAUNCH }) + + expect(spawn.sdkOptions.permissionMode).toBe('acceptEdits') + expect(spawn.sdkOptions.extraArgs).toEqual({ 'replay-user-messages': null }) + }) +}) + +describe('a Claude child launched against the account cached catalog', () => { + async function startWith( + options: Record, + cached: AgentSessionModelOption[] | null + ) { + const store = new AgentModelCatalogStore() + const access = agentModelCatalogSessionAccess(store, 'claude', ACCOUNT_HOME) + if (cached && access) { + store.recordSuccess(access.fingerprint, 'claude', { + models: cached, + fastModeTierByModel: new Map(), + origin: 'probe' + }) + } + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: ACCOUNT_HOME, + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: true, + continuesChain: true + }), + onEvent: (event) => events.push(event), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500, + persistHandle: async () => {}, + modelCatalog: store + }) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: recordingJournalSink(), + options + }) + await claudeStartupSettled(adapter, 'session-1') + return { claude, events, adapter } + } + + it('does not launch a saved model the cache does not list; the start drops it from the record', async () => { + const { claude, events, adapter } = await startWith({ model: 'claude-retired-1' }, [OPUS]) + + expect(claude.connections[0]!.launch.options).not.toHaveProperty('model') + expect(adapter.readOptionRestoreFailures('session-1')).toEqual(['model']) + expect(events.find((event) => event.type === 'started')).toMatchObject({ + restoreSkippedOptions: ['model'] + }) + // Nothing was asked of the CLI to find that out. + expect( + claude.connections[0]!.calls.filter((call) => + ['list_models', 'set_model', 'apply_flag_settings'].includes(call.subtype) + ) + ).toEqual([]) + }) + + it('launches the saved model unchecked when the account has never listed its models', async () => { + const { claude, adapter } = await startWith({ model: 'claude-retired-1' }, null) + + expect(claude.connections[0]!.launch.options.model).toBe('claude-retired-1') + expect(adapter.readOptionRestoreFailures('session-1')).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 3f1b4046f542..bd286bf6c220 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -569,20 +569,22 @@ describe('what an earlier host process left behind', () => { describe('a child that exits before its message is handed over', () => { it('rejects the message with the exit reason instead of starting another child (W24)', async () => { - // Each child the loop starts dies between its start step and its handover step. - const awaitStarted = vi.fn(async (sessionId: string) => { - await host.handleAdapterEvent({ + await host.close(SESSION, 'evict') + await startHost() + // Each child the loop starts dies as its start step returns: its exit, asked for on the lane + // then, lands before the handover step. + acquire.mockImplementation(async (input) => { + const child = await spawnChild(input) + void host.handleAdapterEvent({ type: 'ended', - sessionId, - fence: store.getRecord(sessionId)!.lease.runtimeFence, - acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + sessionId: SESSION, + fence: input.fence, + acquisitionGeneration: child.acquisitionGeneration, reason: 'codex app-server crashed', cause: 'unexpected-exit' }) + return child }) - adapterExtras = { awaitStarted } - await host.close(SESSION, 'evict') - await startHost() const id = await accept('hello') @@ -596,11 +598,10 @@ describe('a child that exits before its message is handed over', () => { }) }) -describe('a start whose failure the delivery loop settles before the exit is published', () => { +describe('a start that fails after it was handed messages', () => { it('keeps one row in the words its rejected messages carry', async () => { - // The adapter's startup answer and its later exit event word the same start differently. - const awaitStarted = vi.fn(async () => agentSessionFailureFact('startFailed')) - adapterExtras = { awaitStarted } + // A starting child takes both messages at once; written, neither is answered. + dispatch.mockImplementation(async () => ({ state: 'admitted' as const })) acquire.mockImplementation(async (input) => ({ ...(await spawnChild(input)), providerChildPhase: 'starting' as const @@ -610,7 +611,7 @@ describe('a start whose failure the delivery loop settles before the exit is pub const first = await accept('first') const second = await accept('second') - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(2)) await host.handleAdapterEvent({ type: 'ended', sessionId: SESSION, @@ -629,17 +630,16 @@ describe('a start whose failure the delivery loop settles before the exit is pub item.itemId.includes('start-failure') ) expect(rows).toHaveLength(1) - const words = agentSessionFailureWords(agentSessionFailureFact('startFailed'), { - surface: 'rejection', - agentName: 'Codex', - provider: 'codex' + // Worded once, from the exit's own diagnostic, for the row and every message it was handed. + const row = rows[0].body + expect(row).toMatchObject({ + failure: { kind: 'providerStartFailed', detail: { text: 'codex: config.toml is invalid' } } }) - expect(rows[0].body).toMatchObject({ text: words.reason, failure: words.rejection }) for (const id of [first, second]) { expect(await submission(id)).toMatchObject({ dispatchState: 'rejected', - reason: words.reason, - rejection: words.rejection + reason: row.kind === 'status' ? row.text : null, + rejection: row.kind === 'status' ? row.failure : null }) } }) @@ -680,22 +680,19 @@ describe('Stop withdraws what is queued', () => { expect(dispatch).not.toHaveBeenCalled() }) - it('stops a child still proving its start, and the delivery loop ends with it (W17c)', async () => { - const ended = deferred() - const awaitStarted = vi.fn(() => ended.promise) - const closeSession = vi.fn(async () => { - ended.resolve() - return true - }) - adapterExtras = { awaitStarted, closeSession } + it('stops a child still proving its start, rejecting what it was handed, and the next send starts another (W17c)', async () => { + const closeSession = vi.fn(async () => true) + adapterExtras = { closeSession } await host.close(SESSION, 'evict') await startHost() acquire.mockImplementationOnce(async (input) => ({ ...(await spawnChild(input)), providerChildPhase: 'starting' as const })) + // Written to the starting child at once; it never answers. + dispatch.mockResolvedValueOnce({ state: 'admitted' }) const id = await accept('hello') - await eventually(async () => expect(awaitStarted).toHaveBeenCalled()) + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) @@ -704,25 +701,27 @@ describe('Stop withdraws what is queued', () => { reason: DISPATCH_REJECTED_CANCELLED }) expect(closeSession).toHaveBeenCalled() - // A loop still waiting on that start would swallow this send; it starts a new child instead. - awaitStarted.mockImplementation(async () => undefined) const next = await accept('after stop') await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) - expect(dispatch).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenCalledTimes(2) }) }) describe('an eviction between acceptance and handover', () => { it('rejects the message as not sent, never leaves it in doubt (W24)', async () => { const started = deferred() - adapterExtras = { awaitStarted: () => started.promise } await host.close(SESSION, 'evict') await startHost() + acquire.mockImplementationOnce(async (input) => { + await started.promise + return spawnChild(input) + }) const id = await accept('hello') await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) - // Between the delivery loop's start step and its handover step. - await host.close(SESSION, 'evict') + // Asked for during the delivery loop's start step, the close runs ahead of its handover step. + const closing = host.close(SESSION, 'evict') started.resolve() + await closing expect(await reopened(id)).toMatchObject({ dispatchState: 'rejected', @@ -732,20 +731,19 @@ describe('an eviction between acceptance and handover', () => { }) it('rejects a queued message behind a handed-over one, which alone stays in doubt (W24)', async () => { - const second = deferred() - const awaitStarted = vi.fn(async (): Promise => undefined) - adapterExtras = { awaitStarted } await host.close(SESSION, 'evict') await startHost() dispatch.mockResolvedValueOnce({ state: 'admitted' }) const handed = await accept('handed over') await eventually(async () => expect((await submission(handed))?.handedOverAt).toBeDefined()) - awaitStarted.mockImplementation(() => second.promise) - const queued = await accept('still queued') - await eventually(async () => expect(awaitStarted).toHaveBeenCalledTimes(2)) - - await host.close(SESSION, 'evict') + // Held, the second send is accepted and the close runs next, ahead of its handover. + const second = deferred() + void host['tasks'].serialize(SESSION, () => second.promise) + const accepting = accept('still queued') + const closing = host.close(SESSION, 'evict') second.resolve() + const queued = await accepting + await closing expect(await reopened(queued)).toMatchObject({ dispatchState: 'rejected', @@ -758,9 +756,12 @@ describe('an eviction between acceptance and handover', () => { it('does not stop an idle child while a message is still queued for it (W24)', async () => { idleMs = 0 const started = deferred() - adapterExtras = { awaitStarted: () => started.promise } await host.close(SESSION, 'evict') await startHost() + acquire.mockImplementationOnce(async (input) => { + await started.promise + return spawnChild(input) + }) const id = await accept('hello') await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) // The idle sweep ticks every few milliseconds meanwhile. @@ -790,7 +791,6 @@ describe('a close that stops the child and then a wind-down step fails', () => { async ({ end, starting, kind, verdict }) => { const started = deferred() adapterExtras = { - awaitStarted: () => started.promise, // Once: the host's own teardown acknowledges again. acknowledgeSessionRelease: vi.fn().mockImplementationOnce(() => { throw new Error('release acknowledgement failed') @@ -798,15 +798,20 @@ describe('a close that stops the child and then a wind-down step fails', () => { } await host.close(SESSION, 'evict') await startHost() - acquire.mockImplementationOnce(async (input) => ({ - ...(await spawnChild(input)), - ...(starting ? { providerChildPhase: 'starting' as const } : {}) - })) + acquire.mockImplementationOnce(async (input) => { + await started.promise + return { + ...(await spawnChild(input)), + ...(starting ? { providerChildPhase: 'starting' as const } : {}) + } + }) const id = await accept('hello') await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) - await expect(END_CHILD[end]()).resolves.toBeUndefined() + // Asked for during the start step, the close runs ahead of the handover step. + const ending = END_CHILD[end]() started.resolve() + await expect(ending).resolves.toBeUndefined() await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) const rejected = (await submission(id))! diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts new file mode 100644 index 000000000000..7ab0488f0fcc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts @@ -0,0 +1,155 @@ +// A Claude CLI that never answers initialize still takes the chat's message at once, and the +// chat's Stop still ends it. On the shipping adapter and host, against a CLI that echoes nothing. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID +} from '../../claude/claude-structured-session-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +let claude: ReturnType + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-hung-start-stop-')) + resetHostTestOperationIds() + // Alive, and silent: no initialize answer, no start frame, no echo of what it is sent. + claude = fakeClaude({ initProof: 'none', replayUuid: null }) + const openConnection = claude.openConnection + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + void host.handleAdapterEvent(mapped) + } + }, + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + openConnection: async (...args) => { + const connection = await openConnection(...args) + connection.initializationResult = () => { + connection.calls.push({ subtype: 'initialize' }) + return new Promise(() => {}) + } + return connection + }, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW + }) + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + logger: createStructuredAgentSessionLogger(), + now: () => NOW + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function envelope(method: 'agentSession.send' | 'agentSession.cancel', fields: object) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields: { ...fields } + }) + } +} + +/** The one rule every session list's Working and the chat's own Stop read. */ +async function readsWorking(): Promise { + const snapshot = await host.journalSnapshot(SESSION) + return isStructuredAgentSessionMainAgentWorking( + activeStructuredAgentSessionTurnId(snapshot.items), + snapshot.submissions, + store.getRecord(SESSION)!.lease.runtimeFence + ) +} + +it('writes the message to a start that never answers, and a Stop ends it: stopped, nothing working', async () => { + expect(host['sessions'].get(SESSION)?.child?.phase).toBe('starting') + const body = hostTestMessage('hello') + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error('send refused') + } + const id = sent.value.clientMessageId + const [connection] = claude.connections + + // Handed over and written while initialize is still unanswered. + await vi.waitFor(() => + expect(connection!.sent.some((message) => JSON.stringify(message).includes('hello'))).toBe(true) + ) + expect(connection!.calls).toEqual([{ subtype: 'initialize' }]) + expect(host['sessions'].get(SESSION)?.child?.phase).toBe('starting') + expect(await readsWorking()).toBe(true) + + const stopped = await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + + expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + await vi.waitFor(() => expect(host['sessions'].get(SESSION)?.child).toBeNull()) + expect(connection!.closeCount).toBeGreaterThan(0) + // Stopped, not in doubt: the child it was written to is gone and never echoed it. + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) + ).toMatchObject({ dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }) + ) + expect(await readsWorking()).toBe(false) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index f96ee1f0c370..511210878fc0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -21,6 +21,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { holdDelivery } from './structured-agent-session-delivery-hold.test-fixture' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { HOST_TEST_NOW as NOW, @@ -252,22 +253,19 @@ it('withdraws a follow-up still queued on the host when the turn the Stop names const connection = claude.connections[0]! const turnId = await openFirstTurn(connection) await endFirstTurn(connection) - // Holds the delivery loop between its start check and the handover, with the follow-up queued. - let release!: () => void - const held = new Promise((resolve) => (release = resolve)) - const awaitStarted = vi.spyOn(adapter, 'awaitStarted').mockImplementationOnce(async () => { - await held - }) + // Holds the delivery loop ahead of the handover, with the follow-up queued. + const { held, release } = holdDelivery() const followUp = await send('And then this.') - await eventually(() => expect(awaitStarted).toHaveBeenCalled()) + await held const submission = (await host.journalSnapshot(SESSION)).submissions.find( (entry) => entry.clientMessageId === followUp ) expect(submission && isQueuedAgentJournalSubmission(submission)).toBe(true) // Nothing reached Claude, so the host's withdrawal is the whole Stop, as with no turn named. - expect(await stop(turnId)).toMatchObject({ ok: true, value: { cancelled: true } }) + const stopped = stop(turnId) release() + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) await eventually(async () => expect(await dispatch(followUp)).toEqual({ state: 'rejected', @@ -296,16 +294,13 @@ it('withdraws a host-queued follow-up but leaves a newer turn running when the S const newerTurnId = await liveTurnId() expect(newerTurnId).not.toBeNull() expect(newerTurnId).not.toBe(olderTurnId) - let release!: () => void - const held = new Promise((resolve) => (release = resolve)) - const awaitStarted = vi.spyOn(adapter, 'awaitStarted').mockImplementationOnce(async () => { - await held - }) + const { held, release } = holdDelivery() const followUp = await send('And then this.') - await eventually(() => expect(awaitStarted).toHaveBeenCalled()) + await held - expect(await stop(olderTurnId)).toMatchObject({ ok: true, value: { cancelled: false } }) + const stopped = stop(olderTurnId) release() + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: false } }) await eventually(async () => expect(await dispatch(followUp)).toEqual({ state: 'rejected', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index c0a8d01d1300..0ed42cbbdd1b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -24,8 +24,7 @@ import type { ClaudeStructuredSessionEvent } from '../../claude/claude-structure import { fakeClaude, PROVIDER_SESSION_ID, - type FakeConnection, - claudeStartupSettled + type FakeConnection } from '../../claude/claude-structured-session-test-support' import { invokeCanUseTool } from '../../claude/claude-can-use-tool-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -130,7 +129,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await claudeStartupSettled(adapter, SESSION) + await adapter['sessions'].get(SESSION)?.startup.settled await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index 0133fbc9fedd..e19127fde7cd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -2,7 +2,6 @@ // list and a send each see only the one that happened. Ticks are driven by hand. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import { readStructuredSessionGateFacts } from '../../runtime/orchestration/structured-mailbox-pointer-host' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -222,38 +221,22 @@ describe('the sweep and the lease (P2-20)', () => { }) describe('a start that never finishes (P2-15)', () => { - it('is stopped by the sweep, and the delivery loop alone writes its one row and rejection', async () => { + it('is stopped by the sweep, and the message it was handed carries its one row and rejection', async () => { const stopReason = 'Codex never finished starting, so Orca stopped it.' - const order: string[] = [] - const started = Promise.withResolvers() - rig.adapter.closeSession.mockImplementation(async () => { - order.push('stopped') - started.resolve() - return true - }) const spawn = rig.adapter.acquire.getMockImplementation()! rig.adapter.acquire.mockImplementationOnce(async (input) => ({ ...(await spawn(input)), providerChildPhase: 'starting' as const })) - Object.assign(rig.host.deps.adapter, { awaitStarted: () => started.promise }) - // The loop rejects the queued messages in the same append as its row. - const append = AgentSessionJournal.prototype.appendLifecycleBatch - vi.spyOn(AgentSessionJournal.prototype, 'appendLifecycleBatch').mockImplementation(function ( - this: AgentSessionJournal, - ...args - ) { - if (args[0].rejectsQueued) { - order.push(`rejected: ${args[0].rejectsQueued.reason}`) - } - return append.apply(this, args) - }) + // Written to the starting child at once; it never answers. + rig.adapter.dispatch.mockResolvedValueOnce({ state: 'admitted' }) const reader = collectSubscriber() const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) expect(attached.ok).toBe(true) await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) const sent = await rig.host.send(CALLER, restTestSend('stuck behind the start', fence())) expect(sent.ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) rig.clock.now += IDLE_MS + 1 await sweepOnce(rig.host) @@ -263,8 +246,6 @@ describe('a start that never finishes (P2-15)', () => { expect.objectContaining({ dispatchState: 'rejected', reason: stopReason }) ) ) - // One rejection, written after the stop by the loop, with the stop's own words. - expect(order).toEqual(['stopped', `rejected: ${stopReason}`]) const errorRows = reader.events.flatMap((event) => event.type === 'batch' ? event.batch.items.filter( @@ -275,11 +256,10 @@ describe('a start that never finishes (P2-15)', () => { expect(errorRows.map((item) => (item.body.kind === 'status' ? item.body.text : null))).toEqual([ stopReason ]) - expect(rig.adapter.dispatch).not.toHaveBeenCalled() const again = await rig.host.send(CALLER, restTestSend('try again', fence())) expect(again.ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce(), COLD_START) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), COLD_START) await expect(sweepOnce(rig.host)).resolves.toBeUndefined() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index a1e11e0c42dd..9b820a65c1b2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -16,6 +16,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { holdLane } from './structured-agent-session-delivery-hold.test-fixture' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -36,7 +37,6 @@ let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let dispatch: Mock let cancelTurn: Mock -let awaitStarted: Mock> let closeSession: Mock> let acknowledgeSessionRelease: Mock< NonNullable @@ -56,7 +56,6 @@ beforeEach(async () => { // Admitted, not accepted: the message is written and its turn has not opened. dispatch = vi.fn(async () => ({ state: 'admitted' as const })) cancelTurn = vi.fn(async () => ({ cancelled: true })) - awaitStarted = vi.fn(async () => undefined) closeSession = vi.fn(async () => true) acknowledgeSessionRelease = vi.fn() stopEndsSession = false @@ -86,7 +85,6 @@ beforeEach(async () => { } }, dispatch, - awaitStarted, closeSession, acknowledgeSessionRelease, releaseAcquisition: vi.fn(async () => true), @@ -196,14 +194,14 @@ describe('a Stop that names no turn', () => { }) it('withdraws what is queued on a ready child and asks the provider for nothing more', async () => { - const started = Promise.withResolvers() - awaitStarted.mockImplementationOnce(() => started.promise) + // Held, the send is accepted and the Stop runs next, ahead of the handover the send asks for. + const release = holdLane(host, SESSION) const { id, result } = send('hello') + const stopped = stop() + release() await result - await eventually(() => expect(awaitStarted).toHaveBeenCalled()) - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - started.resolve(undefined) + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) expect(await submission(id)).toMatchObject({ dispatchState: 'rejected', @@ -423,21 +421,20 @@ describe('a Stop that names its turn, as an older client sends it', () => { expect(await statusRows()).toEqual([]) }) - async function queueOnHost(): Promise<{ id: string; release: () => void }> { - const started = Promise.withResolvers() - awaitStarted.mockImplementationOnce(() => started.promise) - const { id, result } = send('hello') - await result - await eventually(() => expect(awaitStarted).toHaveBeenCalled()) - return { id, release: () => started.resolve(undefined) } + /** A send queued on the host and not yet handed over: the lane is held until `release`, so a + * step asked for before it runs ahead of the handover. */ + function queueOnHost(): { id: string; release: () => void } { + const release = holdLane(host, SESSION) + return { id: send('hello').id, release } } it('reports success with no row when it withdrew a queued message and the turn had ended', async () => { - const queued = await queueOnHost() + const queued = queueOnHost() cancelTurn.mockResolvedValueOnce({ cancelled: false }) - expect(await stop('turn-1')).toMatchObject({ ok: true, value: { cancelled: true } }) + const stopped = stop('turn-1') queued.release() + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) expect(await submission(queued.id)).toMatchObject({ dispatchState: 'rejected' }) expect(await statusRows()).toEqual([]) @@ -445,14 +442,15 @@ describe('a Stop that names its turn, as an older client sends it', () => { // Codex refuses an interrupt for a turn that has ended. it('reports success with no row when the provider refused a turn that had ended', async () => { - const queued = await queueOnHost() + const queued = queueOnHost() cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { detail: { text: 'no such turn', audience: 'person' } } }) - expect(await stop('turn-1')).toMatchObject({ ok: true, value: { cancelled: true } }) + const stopped = stop('turn-1') queued.release() + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) expect(await submission(queued.id)).toMatchObject({ dispatchState: 'rejected' }) expect(await statusRows()).toEqual([]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-hold.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-hold.test-fixture.ts new file mode 100644 index 000000000000..9fbdb04dbc08 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-hold.test-fixture.ts @@ -0,0 +1,35 @@ +// Ways a test holds a session's work at a chosen step, so a Stop or close asked for meanwhile runs +// in a known order against the delivery loop's handover. + +import { vi } from 'vitest' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' + +/** Holds the delivery loop's next step at its first write, inside the session's lane, until + * `release`: a Stop or close asked for meanwhile runs ahead of the handover. `held` resolves + * once the step has reached the hold. */ +export function holdDelivery(): { held: Promise; release: () => void } { + const gate = Promise.withResolvers() + const reached = Promise.withResolvers() + const reject = AgentSessionJournal.prototype.rejectQueuedSubmissions + const step = vi + .spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions') + .mockImplementation(async function (this: AgentSessionJournal, ...args) { + // The step's leftover sweep, the one write every delivery step makes first. + if (args[1].rejection.kind === 'hostRestarted') { + step.mockRestore() + reached.resolve() + await gate.promise + } + return reject.apply(this, args) + }) + return { held: reached.promise, release: () => gate.resolve() } +} + +/** Holds the session's lane until the returned release: whatever is asked for meanwhile runs in + * the order it was asked, ahead of a handover the delivery loop asks for after it. */ +export function holdLane(host: StructuredAgentSessionHost, sessionId: string): () => void { + const held = Promise.withResolvers() + void host['tasks'].serialize(sessionId, () => held.promise) + return () => held.resolve() +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 9cb6dbab4ef2..7de8ad28581f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -15,6 +15,7 @@ import { REST_TEST_SESSION as SESSION, REST_TEST_THREAD as THREAD, restTestSend, + sweepOnce, sweepTicks, type RestTestRig } from './structured-agent-session-rest-test-rig' @@ -96,20 +97,25 @@ describe('the idle sweep', () => { it('never stops an agent a message is queued for, and hands the message over (P2-08)', async () => { await foundRestTestChat(rig) rig.adapter.closeSession.mockClear() - // The loop takes the message and waits on the child's start, outside the lock. - const started = Promise.withResolvers() - const awaitStarted = vi.fn(() => started.promise) - Object.assign(rig.host.deps.adapter, { awaitStarted }) + // Only the sweep this test asks for runs, at the step it is asked for. + rig.host.collaboratorsForTests().lifetime.idleSweep.dispose() + await sweepTicks(2) const reader = collectSubscriber() await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) - const sent = await rig.host.send(CALLER, restTestSend('queued one', fence())) - expect(sent.ok).toBe(true) - await vi.waitFor(() => expect(awaitStarted).toHaveBeenCalled()) - rig.clock.now += IDLE_MS + 1 + // Held, the send is accepted, the agent goes idle past the window, and the sweep runs, all + // ahead of the handover the send asks for. + const held = Promise.withResolvers() + void rig.host['tasks'].serialize(SESSION, () => held.promise) + const sending = rig.host.send(CALLER, restTestSend('queued one', fence())) + void rig.host['tasks'].serialize(SESSION, async () => { + rig.clock.now += IDLE_MS + 1 + }) + const swept = sweepOnce(rig.host) + held.resolve() + expect((await sending).ok).toBe(true) - await sweepTicks() + await swept expect(rig.adapter.closeSession).not.toHaveBeenCalled() - started.resolve() await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) const settled = (await rig.host.journalSnapshot(SESSION)).submissions.at(-1) expect(settled?.dispatchState).toBe('accepted') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts index b8bda4c1b78c..ef6d5ab60c62 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -87,7 +87,6 @@ beforeEach(async () => { } }, dispatch, - awaitStarted: vi.fn(async () => undefined), closeSession: vi.fn(async () => true), releaseAcquisition: vi.fn(async () => true), cancelTurn: vi.fn(async () => ({ cancelled: true })), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index dcb7e0ee6f2a..10133da85245 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -160,8 +160,8 @@ async function dispatchState(clientMessageId: string): Promise { for (let turn = 0; turn < 2000 && connection.sent.length === 0; turn += 1) { await new Promise((resolve) => setImmediate(resolve)) @@ -174,45 +174,43 @@ function waitOutSeveralSweeps(): Promise { } describe('a Claude chat whose CLI is still starting', () => { - it('keeps a message queued inside the idle window, and delivers it once startup lands', async () => { + it('writes a message at once, and keeps the agent inside the idle window while startup is still out', async () => { await attachStarting() - const held = await send('sent while starting') + const sent = await send('sent while starting') + // Written before initialize answers: the CLI queues it behind its own start. + await untilSent(claude.connections[0]) + expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) + await vi.waitFor(async () => expect(await dispatchState(sent)).toBe('accepted')) clock += IDLE_MS - 1 await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) expect(claude.connections[0].closeCount).toBe(0) - expect(await dispatchState(held)).toBe('pending') landInit() await claudeStartupSettled(adapter, SESSION) - - await vi.waitFor( - () => expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]), - { timeout: 3000 } - ) - await vi.waitFor(async () => expect(await dispatchState(held)).toBe('accepted')) + expect(host.hasSession(SESSION)).toBe(true) }) - it('gives the message it hands over at startup a full idle window to open its turn', async () => { + it('gives a message written while starting a full idle window after startup lands to open its turn', async () => { await attachStarting() - const held = await send('sent while starting') const connection = claude.connections[0] // Claude echoes a prompt only when it starts that turn, which a loaded machine delays. connection.send = async (message) => { connection.sent.push(message) } + const held = await send('sent while starting') + await untilSent(connection) + expect(connection.sent).toEqual([expect.objectContaining({ type: 'user' })]) clock += IDLE_MS - 1 await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) - // Startup lands just before the window closes; the handover's publish starts it again. + // Startup lands just before the window closes; the agent stays while its message waits. landInit() await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) - await untilSent(connection) - expect(connection.sent).toEqual([expect.objectContaining({ type: 'user' })]) clock += IDLE_MS - 1 await waitOutSeveralSweeps() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index c61d2bd117f0..c5b2e90754ae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -272,21 +272,17 @@ function deferred() { describe('Stop on a child still proving its start', () => { it('ends the child and keeps the conversation and its readers (R1)', async () => { - const ended = deferred() - adapterExtras = { - awaitStarted: vi.fn(() => ended.promise), - closeSession: vi.fn(async () => { - ended.resolve() - return true - }) - } + adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() acquire.mockImplementationOnce(spawnStartingChild) + // Written to the starting child at once; it never answers. + dispatch.mockResolvedValueOnce({ state: 'admitted' }) const first = await accept('hello') const journal = conversation()?.journal const events = await subscribe() const frames = watchStatus() await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) + await eventually(async () => expect((await submission(first))?.handedOverAt).toBeDefined()) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) @@ -307,7 +303,7 @@ describe('Stop on a child still proving its start', () => { const next = await accept('after stop') await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) expect(conversation()?.journal).toBe(journal) - expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([next]) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([first, next]) // The reader opened before the Stop saw the next message delivered on the same stream. expect( events.some( @@ -384,53 +380,38 @@ describe('a published child that dies while it proves its start', () => { const EXIT = START_EXIT const TEXT = START_TEXT - it.each([['the loop sees the start fail first'], ['the exit is processed first']])( - 'leaves one error row keyed by the start, and every queued message rejected with it: %s (R2)', - async (order) => { - const settled = deferred() - adapterExtras = { awaitStarted: vi.fn(() => settled.promise) } - await restartHost() - acquire.mockImplementation(spawnStartingChild) - const first = await accept('first') - const events = await subscribe() - const second = await accept('second') - await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalled()) - const child = currentChild() - - if (order === 'the exit is processed first') { - await exit(child, EXIT, true) - settled.resolve(START_FAILURE) - } else { - settled.resolve(START_FAILURE) - await eventually(async () => - expect((await submission(second))?.dispatchState).toBe('rejected') - ) - await exit(child, EXIT, true) - } + it('leaves one error row keyed by the start, and every message it was handed rejected with it (R2)', async () => { + await restartHost() + acquire.mockImplementation(spawnStartingChild) + // Written to the starting child at once; it never answers. + dispatch.mockImplementation(async () => ({ state: 'admitted' as const })) + const first = await accept('first') + const events = await subscribe() + const second = await accept('second') + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(2)) + const child = currentChild() - await eventually(async () => - expect((await submission(second))?.dispatchState).toBe('rejected') - ) - expect(await statusRows()).toEqual([ - { - itemId: `orca:${encodeURIComponent(`start-failure:${child.acquisitionGeneration}`)}`, - text: TEXT, - tone: 'error', - failure: START_FAILURE - } - ]) - for (const id of [first, second]) { - expect(await submission(id)).toMatchObject({ - dispatchState: 'rejected', - reason: TEXT, - rejection: START_FAILURE - }) + await exit(child, EXIT, true) + + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + expect(await statusRows()).toEqual([ + { + itemId: `orca:${encodeURIComponent(`start-failure:${child.acquisitionGeneration}`)}`, + text: TEXT, + tone: 'error', + failure: START_FAILURE } - expect(rejectedIn(events, second)).toBe(true) - expect(dispatch).not.toHaveBeenCalled() - expect(acquire).toHaveBeenCalledTimes(2) + ]) + for (const id of [first, second]) { + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: TEXT, + rejection: START_FAILURE + }) } - ) + expect(rejectedIn(events, second)).toBe(true) + expect(acquire).toHaveBeenCalledTimes(2) + }) }) /** A start by an operation that needs the agent, such as a goal change, outside the loop. */ @@ -445,7 +426,6 @@ describe('a start another operation made that dies while a sent message waits on const TEXT = START_TEXT it("is the message's own failed start: one error row, the message rejected, no second start (R2)", async () => { - adapterExtras = { awaitStarted: vi.fn(async () => START_FAILURE) } await restartHost() acquire.mockImplementation(spawnStartingChild) // An operation that needs the agent starts a child that has not proven its start. @@ -510,8 +490,7 @@ describe('a start another operation made that dies while a sent message waits on expect(acquire).toHaveBeenCalledTimes(3) }) - it('waits on a child started since the failed one, not on the failure (R2)', async () => { - adapterExtras = { awaitStarted: vi.fn(async () => undefined) } + it('hands over to a child started since the failed one, not to the failure (R2)', async () => { await restartHost() acquire.mockImplementation(spawnStartingChild) await startForOperation() @@ -534,13 +513,25 @@ describe('a start another operation made that dies while a sent message waits on describe('a child that ends before its message is handed over', () => { it('starts one child for the message, then rejects it and stops (R2)', async () => { - // The child the loop starts exits between its start step and its handover step. - adapterExtras = { - awaitStarted: vi.fn(async () => { - await exit(currentChild(), 'codex app-server crashed') - }) - } await restartHost() + // The child the loop starts exits as its start step returns: that exit, asked for on the lane + // then, lands before the handover step. + acquire.mockImplementation(async (input) => { + const child = await spawnChild(input) + void host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: input.fence, + acquisitionGeneration: child.acquisitionGeneration, + reason: 'codex app-server crashed', + failure: { + kind: 'providerExited', + detail: { text: 'codex app-server crashed', audience: 'log' } + }, + cause: 'unexpected-exit' + }) + return child + }) const id = await accept('hello') const events = await subscribe() @@ -564,47 +555,34 @@ describe('a child that ends before its message is handed over', () => { }) }) -describe('another child indexed while the loop waits on the one it started', () => { - it('hands nothing over until the child now there has proven its start (R2)', async () => { - const starts = new Map>>() - const startOf = (generation: string) => { - const start = starts.get(generation) ?? deferred() - starts.set(generation, start) - return start - } - adapterExtras = { - awaitStarted: vi.fn(() => startOf(currentChild().acquisitionGeneration).promise), - // The stop ends the child without settling the start the loop is waiting on. - closeSession: vi.fn(async () => true) - } +describe('another child indexed after a Stop ended the starting one', () => { + it('hands the next message to the child now there, still starting (R2)', async () => { + adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() acquire.mockImplementation(spawnStartingChild) + // The starting child takes the first message at once and never answers it. + dispatch.mockResolvedValueOnce({ state: 'admitted' }) const first = await accept('first') - await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) const stopped = currentChild() expect(await stop()).toMatchObject({ ok: true }) - const second = await accept('second') - // An operation that needs the agent starts its own child before the loop's handover step runs. + // An operation that needs the agent starts its own child before the second message is sent. await host['serialize'](SESSION, () => ensureStructuredAgentSessionAgent(host['attachContext'](), SESSION) ) const replacement = currentChild() expect(replacement.acquisitionGeneration).not.toBe(stopped.acquisitionGeneration) + const secondId = await accept('second') - startOf(stopped.acquisitionGeneration).resolve() - await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(2)) - expect(dispatch).not.toHaveBeenCalled() - - await host.handleAdapterEvent({ - type: 'started', - ...replacement, - reportedOptions: { model: 'sonnet' }, - restoreSkippedOptions: [] + await eventually(async () => + expect((await submission(secondId))?.dispatchState).toBe('accepted') + ) + expect(conversation()?.child).toMatchObject({ + generation: replacement.acquisitionGeneration, + phase: 'starting' }) - startOf(replacement.acquisitionGeneration).resolve() - - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([second]) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([first, secondId]) + expect(acquire).toHaveBeenCalledTimes(3) expect(await submission(first)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) }) }) @@ -693,23 +671,17 @@ async function settleLoop(): Promise { } describe('how a stopped child ends the start its loop was waiting on', () => { - /** A child the loop waits on, whose start the stop below does not settle, so a message sent - * after the stop is queued when the loop next looks. */ + /** A starting child that took the first message and never answered it, then the stop below; + * the message sent after the stop is the next delivery's. */ async function stoppedWhileStarting(stop: () => Promise) { - const start = deferred() - adapterExtras = { - awaitStarted: vi.fn(() => start.promise), - closeSession: vi.fn(async () => true) - } + adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() acquire.mockImplementationOnce(spawnStartingChild) + dispatch.mockResolvedValueOnce({ state: 'admitted' }) await accept('first') - await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) await stop() - const second = await accept('second') - adapterExtras.awaitStarted = undefined - start.resolve() - return second + return accept('second') } it("goes on after a user's Stop and delivers what was sent since (R2)", async () => { @@ -731,22 +703,29 @@ describe('how a stopped child ends the start its loop was waiting on', () => { ) } - /** A message queued behind a starting child when the close's stop cut it. `beforeStart` runs - * after the stop and before the loop looks again. */ + /** A message queued for a starting child when the close's stop cut it: asked for during the + * start step, the stop runs before the handover. `beforeStart` runs after the stop and before + * the loop looks again. */ async function closedWhileStarting(beforeStart: () => void = () => undefined) { const start = deferred() - adapterExtras = { - awaitStarted: vi.fn(() => start.promise), - closeSession: vi.fn(async () => true) - } + adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) + acquire.mockImplementationOnce(async (input) => { + await start.promise + return spawnStartingChild(input) + }) const first = await accept('first') - await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) - await closeStopOnly() - const starts = acquire.mock.calls.length - beforeStart() + await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + let starts = 0 + const closed = host['serialize'](SESSION, async () => { + await stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { + cause: 'user-close' + }) + starts = acquire.mock.calls.length + beforeStart() + }) start.resolve() + await closed await settleLoop() return { first, starts } } @@ -816,25 +795,31 @@ describe('how a stopped child ends the start its loop was waiting on', () => { it("fails the start after a host stop, as a start Orca stopped rather than the provider's (R2)", async () => { const reason = 'the start watchdog fired' - const second = await stoppedWhileStarting(() => - host['serialize'](SESSION, () => - stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { - cause: 'host-stop', - reason - }) - ) + adapterExtras = { closeSession: vi.fn(async () => true) } + await restartHost() + acquire.mockImplementationOnce(spawnStartingChild) + dispatch.mockResolvedValueOnce({ state: 'admitted' }) + const first = await accept('first') + await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) + await host['serialize'](SESSION, () => + stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { + cause: 'host-stop', + reason + }) ) - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + // What the stopped start was handed fails with it, said once; a later send starts afresh. + await eventually(async () => expect((await submission(first))?.dispatchState).toBe('rejected')) // The sentence is the constructor's, not the reason the stop was given. const text = 'Codex never finished starting, so Orca stopped it.' - expect(await submission(second)).toMatchObject({ + expect(await submission(first)).toMatchObject({ reason: text, rejection: { kind: 'hostStopped' } }) expect(await statusRows()).toEqual([ { itemId: expect.any(String), text, tone: 'error', failure: { kind: 'hostStopped' } } ]) - expect(dispatch).not.toHaveBeenCalled() + const second = await accept('second') + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index 003197209424..9fb49fdb9284 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -58,9 +58,6 @@ export async function createQueuedMessageTestRig( const dispatch: Mock = vi.fn(async () => ({ state: 'admitted' as const })) - const awaitStarted: Mock> = vi.fn( - async () => undefined - ) // The provider's receipt of a /compact; its end arrives later, as `finishCompact` writes it. const compact: Mock> = vi.fn(async () => ({ state: 'accepted' as const, @@ -103,7 +100,6 @@ export async function createQueuedMessageTestRig( } }, dispatch, - awaitStarted, closeSession, releaseAcquisition: vi.fn(async () => true), compact, @@ -324,7 +320,6 @@ export async function createQueuedMessageTestRig( dispatch, cancelTurn, closeSession, - awaitStarted, compact, finishCompact, providerEvents, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index eefd106b3eaa..4744a84db1d6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -30,16 +30,16 @@ import { hostTestMessage, hostTestOperationId } from './structured-agent-session-host-test-data' +import { holdDelivery } from './structured-agent-session-delivery-hold.test-fixture' let rig: QueuedMessageTestRig let host: QueuedMessageTestRig['host'] let store: QueuedMessageTestRig['store'] let dispatch: QueuedMessageTestRig['dispatch'] -let awaitStarted: QueuedMessageTestRig['awaitStarted'] beforeEach(async () => { rig = await createQueuedMessageTestRig() - ;({ host, store, dispatch, awaitStarted } = rig) + ;({ host, store, dispatch } = rig) }) afterEach(() => rig.dispose()) @@ -377,29 +377,27 @@ describe('Stop and Delete', () => { expect(await drafts()).toHaveLength(2) }) - /** A draft consumed into a submission the delivery loop has not handed over: - * the loop is held at the child's start proof until the returned release. */ + /** A draft consumed into a submission the delivery loop has not handed over: its delivery step + * is held until the returned release, so a step asked for meanwhile runs ahead of the handover. */ async function consumedButNotHandedOver(): Promise<{ draftId: string; release: () => void }> { const working = await workingSend() const queued = await send('stopped in flight', 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { throw new Error('expected a queued receipt') } - let release: () => void = () => undefined - awaitStarted.mockImplementationOnce( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) + const { release } = holdDelivery() await settleAccepted(working, 'a') const draftId = queued.value.queued.messageId await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) expect((await rig.handoff(draftId))?.handedOverAt).toBeUndefined() - return { draftId, release: () => release() } + return { draftId, release } } it("a Stop between consume and the agent's receipt sends the draft back to waiting, paused like the rest", async () => { const { draftId, release } = await consumedButNotHandedOver() - const stopped = await stop() + const stopping = stop() release() + const stopped = await stopping expect(stopped).toMatchObject({ ok: true }) expect(await drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index c5baeed25def..a69d1712f0de 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -11,6 +11,7 @@ import { hostTestMessage, hostTestOperationId } from './structured-agent-session-host-test-data' +import { holdDelivery } from './structured-agent-session-delivery-hold.test-fixture' import { QUEUED_RIG_CALLER, createQueuedMessageTestRig, @@ -374,13 +375,16 @@ describe('a card handed off after a restart', () => { ) await rig.restartHostProcess() // Sent again in this process, then withdrawn by a Stop before the agent had it. - let release: () => void = () => undefined - rig.awaitStarted.mockImplementationOnce( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) - expect(await rig.sendNow(draftId)).toMatchObject({ ok: true }) - await rig.stop() + // Its delivery is held, so the Stop runs ahead of the handover. + const { held, release } = holdDelivery() + const handedOver = rig.dispatch.mock.calls.length + const sending = rig.sendNow(draftId) + await held + const stopping = rig.stop() release() + expect(await sending).toMatchObject({ ok: true }) + await stopping + expect(rig.dispatch).toHaveBeenCalledTimes(handedOver) await eventually(async () => expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts index 5e23fbe1a84a..6cf71ea894c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { holdDelivery } from './structured-agent-session-delivery-hold.test-fixture' import { createQueuedMessageTestRig, eventually, @@ -43,11 +44,8 @@ it('Stop, then a user send: the withdrawn draft and the paused cards behind it d const a = await queuedDraft('A') const b = await queuedDraft('B') const c = await queuedDraft('C') - // The turn ends and the drain consumes A; the agent's start is held, so A is not handed over. - let release: () => void = () => undefined - rig.awaitStarted.mockImplementationOnce( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) + // The turn ends and the drain consumes A; its delivery is held, so A is not handed over. + const { release } = holdDelivery() await rig.settleAccepted(working, 'working') await eventually(async () => expect(await rig.handoff(a)).toBeDefined()) // Never under the draft's own id: the submission names A by its link. @@ -55,8 +53,10 @@ it('Stop, then a user send: the withdrawn draft and the paused cards behind it d expect(firstA).not.toBe(a) expect((await rig.submission(firstA))?.handedOverAt).toBeUndefined() - expect(await rig.stop()).toMatchObject({ ok: true }) + // The Stop runs once the held step returns, ahead of A's handover. + const stopped = rig.stop() release() + expect(await stopped).toMatchObject({ ok: true }) // A is back in its place, behind the same queue pause as B and C: no returned card blocks them. const paused = [a, b, c].map((messageId) => ({ messageId, state: 'waiting' })) expect(await rig.drafts()).toEqual(paused) @@ -104,10 +104,7 @@ it('Stop, then a user send: the withdrawn draft and the paused cards behind it d it('a Stop whose withdrawal throws after landing still answers; the draft it released sends again under a fresh id', async () => { const working = await rig.workingSend() const a = await queuedDraft('A') - let release: () => void = () => undefined - rig.awaitStarted.mockImplementationOnce( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) + const { release } = holdDelivery() await rig.settleAccepted(working, 'working') await eventually(async () => expect(await rig.handoff(a)).toBeDefined()) const firstA = await rig.handoffId(a) @@ -124,7 +121,9 @@ it('a Stop whose withdrawal throws after landing still answers; the draft it rel }) const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) try { - expect(await rig.stop()).toMatchObject({ ok: true }) + const stopped = rig.stop() + release() + expect(await stopped).toMatchObject({ ok: true }) expect(warned).toHaveBeenCalledWith( "[agent-session] stop-queued-bookkeeping: Stop's withdrawal failed", expect.objectContaining({ step: 'withdrawal', error: new Error('disk full') }) @@ -142,15 +141,12 @@ it('a Stop whose withdrawal throws after landing still answers; the draft it rel }) }) -/** A consumed card whose delivery is held at the agent's start, so a Stop withdraws it; - * the settlement hook throws on that withdrawal's row, leaving the card owed a return. */ +/** A consumed card whose delivery is held, so a Stop withdraws it ahead of its handover; the + * settlement hook throws on that withdrawal's row, leaving the card owed a return. */ async function stopWithSkippedSettlement(): Promise<{ a: string; working: string }> { const working = await rig.workingSend() const a = await queuedDraft('A') - let release: () => void = () => undefined - rig.awaitStarted.mockImplementationOnce( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) + const { release } = holdDelivery() await rig.settleAccepted(working, 'working') await eventually(async () => expect(await rig.handoff(a)).toBeDefined()) const settle = JournalQueuedMessages.prototype.onRowInTransaction @@ -166,7 +162,9 @@ async function stopWithSkippedSettlement(): Promise<{ a: string; working: string }) const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) try { - expect(await rig.stop()).toMatchObject({ ok: true }) + const stopped = rig.stop() + release() + expect(await stopped).toMatchObject({ ok: true }) } finally { hook.mockRestore() warned.mockRestore() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index 0afc0a05077d..1c3550698a63 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -1,21 +1,17 @@ -// A start a queued message waited on can be seen failing twice: by the delivery loop, when the -// adapter settles the start without proving it, and by the exit settlement, when the child's exit -// lands. The chat gets one row for that start, the loop's, in the words the message was rejected -// with — whichever of the two reports first. +// A start that fails can be written up twice: by the exit settlement, for a message the starting +// child was handed, and by the delivery loop, for a message the exit found still queued. The chat +// gets one row for that start, in the words the message was rejected with. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - agentSessionFailureFact, - type SubmissionRejectionFact -} from '../../../shared/agent-session-failure' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { HOST_TEST_NOW as NOW, @@ -32,9 +28,6 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' -const ADAPTER_FAILURE = agentSessionFailureFact('notSignedIn') -const ADAPTER_FAILURE_TEXT = - 'Codex is not signed in for the selected account. Sign in, then send your message again.' // The exit's reason is Orca's log text; the row says only that the start stopped. const EXIT_TEXT = 'Codex stopped before it finished starting. Send your message to try again.' // The first child (generation-1) is lost at setup; the send starts generation-2. @@ -51,8 +44,9 @@ let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let generation = 0 -let settleStart: (failure: SubmissionRejectionFact | undefined) => void = () => {} -let awaitStarted = vi.fn<() => Promise>() +/** The next start's child exits as its start step returns, before the handover step. */ +let exitOnStart = false +let dispatch: Mock let frames: AgentSessionSubscribeEvent[] = [] function exitBeforeProof(): Promise { @@ -67,7 +61,7 @@ function exitBeforeProof(): Promise { }) } -async function sendQueued(text: string): Promise { +async function send(text: string): Promise { const body = hostTestMessage(text) const sent = await host.send(CALLER, { envelope: { @@ -83,9 +77,7 @@ async function sendQueued(text: string): Promise { body }) expect(sent).toMatchObject({ ok: true }) - // The loop started a child and waits on its start with the message still queued. - await eventually(() => expect(awaitStarted).toHaveBeenCalledOnce()) - expect(generation).toBe(2) + await eventually(() => expect(generation).toBe(2)) return sent.ok ? sent.value.clientMessageId : '' } @@ -117,30 +109,48 @@ beforeEach(async () => { resetHostTestOperationIds() generation = 0 frames = [] - awaitStarted = vi.fn( - () => new Promise((resolve) => (settleStart = resolve)) - ) + exitOnStart = false + dispatch = vi.fn(async () => ({ state: 'admitted' as const })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ logger: createStructuredAgentSessionLogger(), store, adapter: { - acquire: vi.fn(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: generation === 0 ? ('created' as const) : ('resumed' as const), - mintedAtFence: fence, - observedAt: NOW - }, - acquisitionGeneration: `generation-${++generation}`, - providerChildPhase: 'starting' as const - })), - awaitStarted, + acquire: vi.fn(async ({ fence, spawnToken }) => { + const child = { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: generation === 0 ? ('created' as const) : ('resumed' as const), + mintedAtFence: fence, + observedAt: NOW + }, + acquisitionGeneration: `generation-${++generation}`, + providerChildPhase: 'starting' as const + } + if (exitOnStart) { + // Asked for on the lane while the start step runs, so it lands before the handover. + void host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence, + acquisitionGeneration: child.acquisitionGeneration, + reason: EXIT_REASON, + cause: 'unexpected-exit', + startupUnproven: true + }) + } + return child + }), releaseAcquisition: vi.fn(async () => true), closeSession: vi.fn(async () => true), - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), + dispatch: (...args) => dispatch(...args), cancelTurn: vi.fn(async () => ({ cancelled: true })), answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) @@ -162,36 +172,32 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -describe('a queued message whose start fails and whose child then exits', () => { - it("keeps the loop's row when the loop saw the failure first", async () => { - const queued = await sendQueued('hello') +describe('a start that fails before it proves itself', () => { + it("writes one row in the exit's words for a message the child was handed", async () => { + const sent = await send('hello') + // A starting child takes the message at once. + await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) - settleStart(ADAPTER_FAILURE) + await exitBeforeProof() await eventually(async () => - expect(await submission(queued)).toMatchObject({ - dispatchState: 'rejected', - reason: ADAPTER_FAILURE_TEXT, - rejection: ADAPTER_FAILURE - }) + expect(await submission(sent)).toMatchObject({ dispatchState: 'rejected', reason: EXIT_TEXT }) ) - await exitBeforeProof() await host.flushStreamedEvents(SESSION) - expect(await startRows()).toEqual([ADAPTER_FAILURE_TEXT]) - expect(publishedStartRows()).toEqual([ADAPTER_FAILURE_TEXT]) + expect(await startRows()).toEqual([EXIT_TEXT]) + expect(publishedStartRows()).toEqual([EXIT_TEXT]) }) it('leaves the row to the loop when the exit lands while the message still waits', async () => { - const queued = await sendQueued('hello') + exitOnStart = true + const queued = await send('hello') - await exitBeforeProof() - expect(await submission(queued)).toMatchObject({ dispatchState: 'pending' }) - settleStart(undefined) await eventually(async () => expect(await submission(queued)).toMatchObject({ dispatchState: 'rejected' }) ) await host.flushStreamedEvents(SESSION) + expect(dispatch).not.toHaveBeenCalled() expect(await startRows()).toEqual([EXIT_TEXT]) // Written once, after the message was settled, not first by the exit and again by the loop. expect(publishedStartRows()).toEqual([EXIT_TEXT]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index 65da28ca6b2c..779e30ca0733 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -103,27 +103,23 @@ async function queuedDraft(text: string): Promise { * behind the start, which the Stop holds. */ async function stopOfStart(options: { held?: true } = {}): Promise { rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) - let release: () => void = () => undefined - rig.awaitStarted.mockImplementation( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) + // Handed over at once to a child that never proves its start; nothing echoes it. rig.send('work on this') await eventually(() => expect(childPhase()).toBe('starting')) const held = options.held ? await queuedDraft('queued behind the start') : undefined expect(await rig.stop()).toMatchObject({ ok: true }) - release() expect(stopEvents()).toEqual([expect.objectContaining({ reason: 'user-stop' })]) expect(stopEvents()[0]).not.toHaveProperty('turnId') await eventually(() => expect(childPhase()).toBeUndefined()) - rig.awaitStarted.mockImplementation(async () => undefined) return held } /** Orchestration mail after the Stop starts a new child and its turn runs. */ async function mailTurn(): Promise { + const handedOver = rig.dispatch.mock.calls.length const mail = rig.send('mail for the worker', undefined, { internal: true }) await mail.result - await eventually(() => expect(rig.dispatch).toHaveBeenCalled()) + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(handedOver + 1)) await turnOpenedBy(mail.id) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index 1d7781c98869..d856af451f80 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -76,15 +76,6 @@ function idleSweep() { return rig.host.collaboratorsForTests().lifetime.idleSweep } -/** Holds every start until the returned release. */ -function holdStart(): () => void { - let release: () => void = () => undefined - rig.awaitStarted.mockImplementation( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) - return () => release() -} - describe('every Stop entry writes its event, with its reason, before it ends the child', () => { it.each([ // A person closing this chat: its tab, its launch, or a /clear that replaces it. @@ -123,7 +114,6 @@ describe('every Stop entry writes its event, with its reason, before it ends the restartable: true, idleSweep: MANUAL_IDLE_SWEEP }) - holdStart() rig.send('work on this') await eventually(async () => expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe( @@ -418,7 +408,6 @@ describe("a person's Stop pause and the Stop events after it", () => { // The agent at rest goes, writing nothing; mail then starts a new child, which never lands. await idleSweep().tick() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - holdStart() rig.send('mail for the lead', undefined, { internal: true }) await eventually(async () => expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe( @@ -430,7 +419,8 @@ describe("a person's Stop pause and the Stop events after it", () => { await idleSweep().tick() expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop', 'host-stop']) - expect(await rig.handoff(held)).toBeUndefined() expect(await rig.queuePause()).not.toEqual({ reason: 'stopped' }) + // The pause ended with the host's stop, so the card goes out to the next start. + await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index 1ceff1b63a44..6597811f3dd1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -8,6 +8,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' +import { holdLane } from './structured-agent-session-delivery-hold.test-fixture' import { createQueuedMessageTestRig, eventually, @@ -64,15 +65,6 @@ function withdraw(clientMessageId: string) { }) } -/** Holds every start until the returned release. */ -function holdStart(): () => void { - let release: () => void = () => undefined - rig.awaitStarted.mockImplementation( - () => new Promise((resolve) => (release = () => resolve(undefined))) - ) - return () => release() -} - describe("a Stop's event", () => { it('reaches the journal before the interrupt, naming the turn it stopped and who asked', async () => { rig = await createQueuedMessageTestRig() @@ -134,8 +126,9 @@ describe("a Stop's event", () => { it('at an agent still starting, reaches the journal before the start is ended, and holds a card queued before it', async () => { rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) - const release = holdStart() + // A starting agent takes the send at once; it never proves its start. rig.send('work on this') + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(1)) const held = await queuedDraft('queued while it starts') let atEnd: JournalStopEvent[] | undefined rig.closeSession.mockImplementationOnce(async () => { @@ -143,7 +136,6 @@ describe("a Stop's event", () => { return true }) expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - release() await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(held)).toBeUndefined() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) @@ -158,11 +150,14 @@ describe("a Stop's event", () => { rig = await createQueuedMessageTestRig() expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) expect(stopEvents()).toEqual([]) - const release = holdStart() - const waiting = rig.send('waits for the start') - await eventually(async () => expect(await rig.submission(waiting.id)).toBeDefined()) - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + // Held, the send is accepted and the Stop runs next, ahead of the handover the send asks for. + const release = holdLane(rig.host, HOST_TEST_SESSION) + const waiting = rig.send('waits for its handover') + const stopped = rig.stop() release() + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(await rig.submission(waiting.id)).toMatchObject({ dispatchState: 'rejected' }) + expect(rig.dispatch).not.toHaveBeenCalled() expect(rig.cancelTurn).not.toHaveBeenCalled() expect(stopEvents()).toEqual([ { reason: 'user-stop', caller: QUEUED_RIG_CALLER.callerKey, at: expect.any(Number) } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 41031cb3b3eb..06186459071b 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -336,23 +336,26 @@ it('says only that the compaction failed when the provider refused it without wo it('refuses the command at handover when the provider opened a turn meanwhile (B3)', async () => { await attach() const events = state.acquire.mock.calls.at(-1)?.[0].events - // The provider starts a turn of its own after acceptance, before the command is handed over. - Object.assign(state.host.deps.adapter, { - awaitStarted: vi.fn(async () => { - events?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'provider-turn', ordinal: 0 }, - { kind: 'turn', turnId: 'provider-turn', state: 'running' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } - ) - }) - }) const params = compactParams() + // Held, the command is accepted, then the provider starts a turn of its own, both ahead of the + // handover the acceptance asks for. + const held = Promise.withResolvers() + void state.host['tasks'].serialize(SESSION, () => held.promise) + const commanded = state.host.conversationCommand(CALLER, params) + void state.host['tasks'].serialize(SESSION, async () => { + events?.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'provider-turn', ordinal: 0 }, + { kind: 'turn', turnId: 'provider-turn', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } + ) + }) + held.resolve() const refused = { kind: 'commandRefused', refusal: { code: 'agent_session_operation_invalid', details: { reason: 'turnActive' } } } - await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + await expect(commanded).resolves.toMatchObject({ ok: true, value: { state: 'completed', error: "This command didn't run. Try it again.", failure: refused } }) diff --git a/src/main/runtime/claude-structured-send-during-startup.test.ts b/src/main/runtime/claude-structured-send-during-startup.test.ts index d5f7c67a1e4f..a50615fbed4a 100644 --- a/src/main/runtime/claude-structured-send-during-startup.test.ts +++ b/src/main/runtime/claude-structured-send-during-startup.test.ts @@ -1,9 +1,10 @@ -// A Claude chat is published the moment its child spawns, before the CLI has answered initialize. -// A send in that window — into a fresh start, or into the restart the delivery loop makes for a -// send after a start that failed — is accepted and stays queued until the child proves its start. -// When the CLI dies first, the queued message is rejected with the CLI's own diagnostic, the chat -// shows the cause once, and nothing is left as a delivery nobody can confirm. Against the production runtime, -// adapter, record store and host, with only the CLI process scripted. +// A Claude chat is published the moment its child spawns, before the CLI has answered initialize, +// and launched with the chat's saved options. A send in that window — into a fresh start, or into +// the restart the delivery loop makes for a send after a start that failed — is written to the CLI +// at once, which takes it behind its own start. When the CLI dies before answering, the message is +// rejected with the CLI's own diagnostic, the chat shows the cause once, and nothing is left as a +// delivery nobody can confirm. Against the production runtime, adapter, record store and host, with +// only the CLI process scripted. import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' @@ -43,7 +44,7 @@ async function send(host: StructuredAgentSessionHost, text: string): Promise { - it('restarts once, rejects the held message with the diagnostic when that start dies too, then delivers once the CLI is healthy', async () => { + it('restarts once, rejects the message it wrote with the diagnostic when that start dies too, then delivers once the CLI is healthy', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ @@ -89,10 +90,11 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { expect(await statusRows(host)).toEqual([STARTUP_TEXT]) const releasedFence = fence(host) - // The delivery loop asks for the child back and the message waits for its start; the CLI - // dies again first. + // The delivery loop asks for the child back and writes the message to it at once; the CLI + // dies again before it answers initialize. const held = await send(host, 'hello?') await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(2)) + await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) await vi.waitFor(() => expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') ) @@ -111,7 +113,6 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { // The restart moved the fence twice: its acquisition, and the exit that released it. expect(fence(host)).toBe(releasedFence + 2) expect(claude.children(SESSION)).toHaveLength(2) - expect(claude.child(SESSION).calls).not.toContain('send') // The user signs in and retries: one restart, proven, written to the CLI. claude.behave(SESSION, {}) @@ -127,44 +128,53 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { }) describe('a send while the first Claude start is still answering initialize', () => { - it('is queued, and written once the CLI proves its start', async () => { + it('is written to the CLI at once, before it answers', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() await host.attach(CALLER, claude.attachParams(SESSION, null)) - await send(host, 'hello') - expect(claude.child(SESSION).calls).not.toContain('send') + const sent = await send(host, 'hello') + await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') - // The CLI answers: startup lands and the queued message is written to the proven child. + // The CLI answers: the start lands under the message already written, and nothing fails. claude.child(SESSION).answerInit() - await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + ) + expect(claude.child(SESSION).calls.filter((call) => call === 'send')).toHaveLength(1) + expect((await submission(host, sent))?.dispatchState).not.toBe('rejected') expect(claude.children(SESSION)).toHaveLength(1) expect(await statusRows(host)).toEqual([]) }) // Only a SessionStart hook makes the CLI name its session before the first turn, and that hook // comes from status hooks a user can turn off; the initialize answer alone starts the chat. - it('is written once the CLI answers initialize, with no start frame before the first turn', async () => { + it('is written at once, and the start lands, with no start frame before the first turn', async () => { claude.behave(SESSION, { initHangs: true, sendsNoStartFrame: true }) const host = await claude.install() await host.attach(CALLER, claude.attachParams(SESSION, null)) await send(host, 'hello') + await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) claude.child(SESSION).answerInit() - await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + ) expect(claude.children(SESSION)).toHaveLength(1) expect(await statusRows(host)).toEqual([]) }) - it('is rejected with the diagnostic when the CLI dies first, and restarts nothing', async () => { + it('is rejected with the diagnostic when the CLI dies before answering, and restarts nothing', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() await host.attach(CALLER, claude.attachParams(SESSION, null)) const startedFence = fence(host) const held = await send(host, 'hello') + await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) await failLatestStart(host, 1) await vi.waitFor(async () => @@ -178,6 +188,5 @@ describe('a send while the first Claude start is still answering initialize', () expect(await statusRows(host)).toEqual([STARTUP_TEXT]) expect(fence(host)).toBe(startedFence + 1) expect(claude.children(SESSION)).toHaveLength(1) - expect(claude.child(SESSION).calls).not.toContain('send') }) }) diff --git a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts index c5b21f9027fa..3eb815bca50e 100644 --- a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts +++ b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts @@ -1,6 +1,6 @@ -// A send is accepted into a chat whose Claude child is gone, and its delivery restarts the child. -// When that child dies before it proves its start, the message was never handed to it — delivery -// waits for the start — so the send settles `rejected` with the child's own diagnostic, never as a +// A send is accepted into a chat whose Claude child is gone, and its delivery restarts the child +// and writes the message to it at once. When that child dies before it proves its start, it never +// ran the message, so the send settles `rejected` with the child's own diagnostic, never as a // delivery nobody can confirm, and a client that was subscribed the whole time receives the // failure row and the rejected submission over the wire. Against the production runtime, adapter, // record store and host, with only the CLI scripted. @@ -140,6 +140,8 @@ describe('a send whose restarted Claude child dies before it proves its start', const sent = await send(host, 'hello?') // Accepted: the answer comes before the restart it needs. expect(answered.get(sent)).toBe('pending') + await eventually(() => expect(claude.children(SESSION)).toHaveLength(2)) + await eventually(() => expect(claude.child(SESSION).calls).toContain('send')) await failLatestStart(host, 2) // Provably not delivered, with the cause; not "unconfirmed". @@ -154,7 +156,6 @@ describe('a send whose restarted Claude child dies before it proves its start', expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) expect(fence(host)).toBe(releasedFence + 2) expect(claude.children(SESSION)).toHaveLength(2) - expect(claude.child(SESSION).calls).not.toContain('send') // Retry under a new id: one restart, and once the CLI is healthy the message is written. claude.behave(SESSION, {}) diff --git a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts index 711a78ceff0b..65215d609966 100644 --- a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts +++ b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts @@ -1,7 +1,7 @@ -// A Claude start can fail on Orca's side while the CLI is still running: a saved option it can't -// restore, or an init frame naming another session. Orca ends that child itself, so the chat must -// not say Claude stopped on its own; only an exit Orca saw says that. Against the production -// runtime, adapter, record store and host, with only the CLI process scripted. +// A Claude start can fail on Orca's side while the CLI is still running: an init frame naming +// another session. Orca ends that child itself, so the chat must not say Claude stopped on its own; +// only an exit Orca saw says that. Against the production runtime, adapter, record store and host, +// with only the CLI process scripted. import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' @@ -23,7 +23,6 @@ let operations = 0 afterEach(async () => { vi.restoreAllMocks() - vi.unstubAllEnvs() await claude.dispose() claude = createScriptedClaudeRuntime([SESSION]) }) @@ -76,7 +75,8 @@ async function released(host: StructuredAgentSessionHost): Promise { } describe('a Claude start that Orca fails while the CLI is still running', () => { - it('reads as a start that could not happen when a saved option cannot be restored', async () => { + // Saved options ride the launch, so no option write runs at startup that could fail it. + it('starts with a saved option the CLI would not take as a write', async () => { claude.behave(SESSION, { optionWritesFail: true }) const host = await claude.install() await expect( @@ -85,21 +85,23 @@ describe('a Claude start that Orca fails while the CLI is still running', () => claude.attachParams(SESSION, null, { options: { permissionMode: 'plan' } }) ) ).resolves.toMatchObject({ ok: true }) - await released(host) + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + ) - expect(claude.child(SESSION).calls).toContain('set_permission_mode') - expect(await failureRows(host)).toEqual([ - { text: expect.stringMatching(/^Claude couldn't start\./), kind: 'startFailed' } - ]) + expect(claude.child(SESSION).launch.options.permissionMode).toBe('plan') + expect(claude.child(SESSION).calls).not.toContain('set_permission_mode') + expect(await failureRows(host)).toEqual([]) }) - it('rejects a held message as a start that could not happen when init names another session', async () => { + it('rejects a message it wrote as a start that could not happen when init names another session', async () => { claude.behave(SESSION, { initHangs: true, initNamesForeignSession: true }) const host = await claude.install() await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ ok: true }) const held = await send(host, 'hello') + await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) claude.child(SESSION).answerInit() await released(host) @@ -114,37 +116,6 @@ describe('a Claude start that Orca fails while the CLI is still running', () => expect(await failureRows(host)).toEqual([ { text: expect.stringMatching(/^Claude couldn't start\./), kind: 'startFailed' } ]) - expect(claude.child(SESSION).calls).not.toContain('send') - }) - - it('rejects a held message as a start that could not happen when the CLI never answers initialize', async () => { - vi.stubEnv('ORCA_E2E_CLAUDE_STARTUP_DEADLINE_MS', '50') - claude.behave(SESSION, { initHangs: true }) - const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ - ok: true - }) - const held = await send(host, 'hello') - await released(host) - - await vi.waitFor(async () => - expect(await submission(host, held)).toMatchObject({ - dispatchState: 'rejected', - reason: expect.stringMatching(/^Claude couldn't start\./), - rejection: { kind: 'startFailed' } - }) - ) - expect(await failureRows(host)).toEqual([ - { text: expect.stringMatching(/^Claude couldn't start\./), kind: 'startFailed' } - ]) - expect(claude.child(SESSION).connection.closed).toBe(true) - - // The CLI answering after the deadline neither starts the chat nor takes the message. - claude.child(SESSION).answerInit() - await waitForStructuredAgentSessionRecovery() - expect(claude.child(SESSION).calls).not.toContain('get_settings') - expect(claude.child(SESSION).calls).not.toContain('send') - expect(claude.children(SESSION)).toHaveLength(1) }) it('still says Claude stopped when the CLI exits on its own before its start lands', async () => { diff --git a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts index 2be1755199e5..e431658a078e 100644 --- a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts +++ b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts @@ -1,11 +1,8 @@ -// Only a Claude start's initialize answer runs under the startup deadline; each option write the -// restore replays, and startup's own settings read, is a control request under the ordinary -// request deadline. A CLI -// that answers initialize and then never answers one of those used to fault the whole session -// when that deadline fired: a start that was merely slow died with the deadline's error as its -// cause. Now the unanswered request is skipped and startup lands on the CLI's own values, while -// the saved choice stays saved for the next start to retry. Against -// the production runtime, adapter, record store and host, with only the CLI process scripted. +// A Claude start asks the CLI one control request after initialize, its settings read, under the +// ordinary request deadline; the chat's saved options ride the launch, so no option write runs at +// startup. A CLI that answers initialize and then answers no control request still starts, on its +// launch options, and the saved choices stay saved. Against the production runtime, adapter, record +// store and host, with only the CLI process scripted. import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' @@ -17,6 +14,8 @@ import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtim const SESSION = 'claude-startup-unanswered-control' const CALLER = { callerKey: 'client-1' } const DEADLINE_MS = 50 +/** As live: no frame before the first turn names a model, so only a turn reports one. */ +const LIVE_START = { sendsNoStartFrame: true } let claude = createScriptedClaudeRuntime([SESSION]) let operations = 0 @@ -106,8 +105,8 @@ async function sendTo( } describe('a Claude start whose CLI answers initialize but not a control request', () => { - it('lands with the unanswered option write skipped instead of faulting at the deadline, and keeps the saved choice', async () => { - claude.behave(SESSION, { optionWritesHang: true, controlTimeoutMs: DEADLINE_MS }) + it('launches with the saved choices, writes none of them, and keeps them saved', async () => { + claude.behave(SESSION, { ...LIVE_START, optionWritesHang: true, controlTimeoutMs: DEADLINE_MS }) vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = await claude.install() const saved = { model: 'sonnet', permissionMode: 'plan' } @@ -115,28 +114,29 @@ describe('a Claude start whose CLI answers initialize but not a control request' host.attach(CALLER, claude.attachParams(SESSION, null, { options: saved })) ).resolves.toMatchObject({ ok: true }) - // The restore asked; the CLI never answered; startup went on without it. - await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('set_model')) - await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('set_permission_mode')) await vi.waitFor(() => expect(record(host)?.lease.claimStatus).toBe('live'), { timeout: DEADLINE_MS * 40 }) - // The live child runs on the CLI's own model; silence is not a refusal, so the saved - // choice is neither replaced by that value nor dropped, and the next start retries it. + expect(claude.child(SESSION).launch.options).toMatchObject({ + model: 'sonnet', + permissionMode: 'plan' + }) + // The record keeps the saved choices through the start. await vi.waitFor(() => expect(record(host)?.options).toEqual({ ...saved, effort: 'high' }), { timeout: DEADLINE_MS * 40 }) expect(host.deps.adapter.readOptionRestoreFailures?.(SESSION)).toEqual([]) expect(await statusRows(host)).toEqual([]) expect(claude.children(SESSION)).toHaveLength(1) + expect(claude.child(SESSION).calls).not.toContain('set_model') + expect(claude.child(SESSION).calls).not.toContain('set_permission_mode') - // The proven child takes the next message. await send(host, 'hello') await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) }) - it('keeps the unanswered saved choice when the user later changes a different option', async () => { - const behavior = { optionWritesHang: true, controlTimeoutMs: DEADLINE_MS } + it('keeps the launched saved choice when the user later changes a different option', async () => { + const behavior = { ...LIVE_START, optionWritesHang: true, controlTimeoutMs: DEADLINE_MS } claude.behave(SESSION, behavior) vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = await claude.install() @@ -156,10 +156,10 @@ describe('a Claude start whose CLI answers initialize but not a control request' expect(record(host)?.options).toMatchObject({ model: 'sonnet', permissionMode: 'plan' }) }) - it('replays the unanswered saved model after a turn reports another model, another option changes, and the chat is cleared', async () => { + it('launches the saved model again after a turn reports another model, another option changes, and the chat is cleared', async () => { const sessions = [SESSION] claude = createScriptedClaudeRuntime(sessions) - const behavior = { optionWritesHang: true, controlTimeoutMs: DEADLINE_MS } + const behavior = { ...LIVE_START, optionWritesHang: true, controlTimeoutMs: DEADLINE_MS } claude.behave(SESSION, behavior) vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = await claude.install() @@ -206,14 +206,17 @@ describe('a Claude start whose CLI answers initialize but not a control request' }) const replacement = cleared.ok ? cleared.value.replacementSessionId! : '' sessions.push(replacement) + claude.behave(replacement, LIVE_START) // The cleared chat starts nothing until its first message. expect(claude.children(replacement)).toEqual([]) await sendTo(host, replacement, 'first message') - // Started fresh under its own derived id, and it replays the saved model rather than the one + // Started fresh under its own derived id, launched with the saved model rather than the one // the turn reported. - await vi.waitFor(() => expect(claude.child(replacement).calls).toContain('set_model')) + await vi.waitFor(() => expect(claude.children(replacement)).toHaveLength(1)) expect(claude.child(replacement).launch.options).toMatchObject({ - sessionId: claudeSessionIdForOrcaSession(replacement) + sessionId: claudeSessionIdForOrcaSession(replacement), + model: 'sonnet', + permissionMode: 'plan' }) expect(claude.child(replacement).launch.options.resume).toBeUndefined() await vi.waitFor(() => @@ -226,8 +229,8 @@ describe('a Claude start whose CLI answers initialize but not a control request' expect(record(host)?.options).toEqual({ model: 'sonnet', permissionMode: 'plan' }) }) - it('replaces the unanswered saved model with the one the user then sets', async () => { - const behavior = { optionWritesHang: true, controlTimeoutMs: DEADLINE_MS } + it('replaces the launched saved model with the one the user then sets', async () => { + const behavior = { ...LIVE_START, optionWritesHang: true, controlTimeoutMs: DEADLINE_MS } claude.behave(SESSION, behavior) vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = await claude.install() @@ -235,7 +238,7 @@ describe('a Claude start whose CLI answers initialize but not a control request' host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'sonnet' } })) ).resolves.toMatchObject({ ok: true }) // The record holds the saved model from creation; only the start's own report (effort) says - // startup finished, and an option write before then is refused as still starting. + // startup finished, and an option write before then waits for it. await vi.waitFor( () => expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high' }), { timeout: DEADLINE_MS * 40 } diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index 78f5d686952c..0744bc809a6f 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -43,8 +43,7 @@ export type ScriptedClaudeBehavior = { optionWritesHang?: boolean /** Startup's own settings read goes unanswered. */ startupSettingsReadHangs?: boolean - /** Every option write loses its answer while the CLI keeps running (not a refusal), so a - * start that restores one faults. */ + /** Every option write loses its answer while the CLI keeps running (not a refusal). */ optionWritesFail?: boolean /** The init frame names another provider session than the one launched. */ initNamesForeignSession?: boolean From 49962ab51c4ec2f3cc8e5d0d2864c0924c00143f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:37:25 -0700 Subject: [PATCH 10/22] revert(native-chat): keep a failed Claude turn's error row The shared turn fold already shows a failed turn's error once after it settles, as an error; dropping the row left the CLI's synthetic reply looking like something Claude said. --- .../claude-api-error-reply-journaling.test.ts | 115 ------------------ src/main/claude/claude-message-journaling.ts | 8 -- src/main/claude/claude-open-turn.ts | 14 --- src/main/claude/claude-result-journaling.ts | 5 +- 4 files changed, 1 insertion(+), 141 deletions(-) delete mode 100644 src/main/claude/claude-api-error-reply-journaling.test.ts diff --git a/src/main/claude/claude-api-error-reply-journaling.test.ts b/src/main/claude/claude-api-error-reply-journaling.test.ts deleted file mode 100644 index 0f3d5b1592ee..000000000000 --- a/src/main/claude/claude-api-error-reply-journaling.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -// The CLI answers an API error (a retired model, a lost sign-in) with a synthetic assistant reply, -// then a failed result repeating its text: captured in the auth-failed lifecycle capture, Claude -// Code 2.1.280. The chat says it once. - -import { describe, expect, it, vi } from 'vitest' -import type { - AgentJournalItemBody, - AgentJournalItemIdentity -} from '../../shared/agent-session-journal-types' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { createClaudeJournalTranslator } from './claude-structured-journal-translation' - -const MODEL_ERROR = - "There's an issue with the selected model (claude-retired-1). It may not exist or you may not have access to it." - -function journaled() { - const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] - const sink: StructuredAgentSessionEventSink = { - appendItem: (identity, body) => items.push({ identity, body }), - appendTombstone: () => {}, - publish: vi.fn() - } - return { items, translator: createClaudeJournalTranslator({ sink }) } -} - -function frame(message: Record, startsTurn = false) { - return { - type: 'message' as const, - sessionId: 'orca-session', - ...(startsTurn ? { startsTurn: true as const } : {}), - message: { session_id: 'claude-session', parent_tool_use_id: null, ...message } - } -} - -function sendEcho(text: string) { - return frame( - { type: 'user', uuid: 'user-1', message: { role: 'user', content: [{ type: 'text', text }] } }, - true - ) -} - -function apiErrorReply(text: string) { - return frame({ - type: 'assistant', - uuid: 'assistant-1', - error: 'model_not_found', - is_api_error_message: true, - message: { model: '', role: 'assistant', content: [{ type: 'text', text }] } - }) -} - -function failedResult(text: string) { - return frame({ - type: 'result', - subtype: 'success', - uuid: 'result-1', - is_error: true, - result: text, - terminal_reason: 'api_error' - }) -} - -function statusRows(items: { body: AgentJournalItemBody }[]): string[] { - return items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) -} - -describe('a Claude API error the turn already replied with', () => { - it('is said once, as the reply, and the turn reads failed', () => { - const { items, translator } = journaled() - - translator.handle(sendEcho('hello')) - translator.handle(apiErrorReply(MODEL_ERROR)) - translator.handle(failedResult(MODEL_ERROR)) - - expect(items.filter((item) => JSON.stringify(item.body).includes('selected model'))).toEqual([ - expect.objectContaining({ - body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: MODEL_ERROR }] } - }) - ]) - expect(statusRows(items)).toEqual([]) - expect(items.findLast((item) => item.identity.provider === 'legacy')?.body).toMatchObject({ - kind: 'turn', - state: 'completed', - outcome: 'failure' - }) - }) - - it('still gets its error row when the failed result says something the reply did not', () => { - const { items, translator } = journaled() - - translator.handle(sendEcho('hello')) - translator.handle(apiErrorReply('Not logged in')) - translator.handle(failedResult('API Error: 529 upstream overloaded')) - - expect(statusRows(items)).toEqual(['API Error: 529 upstream overloaded']) - }) - - it('does not hide the error row of a later turn', () => { - const { items, translator } = journaled() - - translator.handle(sendEcho('hello')) - translator.handle(apiErrorReply(MODEL_ERROR)) - translator.handle(failedResult(MODEL_ERROR)) - translator.handle({ - ...sendEcho('again'), - message: { ...sendEcho('again').message, uuid: 'user-2' } - }) - translator.handle({ - ...failedResult(MODEL_ERROR), - message: { ...failedResult(MODEL_ERROR).message, uuid: 'result-2' } - }) - - expect(statusRows(items)).toEqual([MODEL_ERROR]) - }) -}) diff --git a/src/main/claude/claude-message-journaling.ts b/src/main/claude/claude-message-journaling.ts index 9648c14796a4..9d75db1fca9f 100644 --- a/src/main/claude/claude-message-journaling.ts +++ b/src/main/claude/claude-message-journaling.ts @@ -114,14 +114,6 @@ export function journalClaudeMessage( ctx.turn.ensureOpen(message, source, observedAt) ctx.sink.appendItem(identity, body, stamp(identity, body)) changed = true - if (source.assistant && !envelope.parentToolUseId && message.is_api_error_message === true) { - ctx.turn.observeApiErrorReply( - body.blocks - .flatMap((block) => (block.type === 'text' ? [block.text] : [])) - .join('\n') - .trim() - ) - } } for (const tool of claudeToolUses(outputEnvelope)) { ctx.turn.ensureOpen(message, source, observedAt) diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index 4570de76220b..1cf0a8d048cc 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -41,8 +41,6 @@ export class ClaudeOpenTurn { * cycle's init (a background task finishing), so membership is read from the * cycle's work, not from when the turn opened. */ private cycleWorkObserved = false - /** The API error the open turn's own reply already said; its failed result repeats it. */ - private apiErrorText: string | null = null private readonly opener: ( frame: Record, source: ClaudeTurnSource | null, @@ -88,15 +86,6 @@ export class ClaudeOpenTurn { return this.current !== null } - get spokenApiError(): string | null { - return this.current ? this.apiErrorText : null - } - - /** The open turn's reply was the CLI's own API error message. */ - observeApiErrorReply(text: string): void { - this.apiErrorText = text - } - /** Whether a turn is open inside a provider request cycle that has already * done work — the state in which the CLI folds an arriving send into it. A * cycle's first send is its opener, never a fold. */ @@ -129,7 +118,6 @@ export class ClaudeOpenTurn { }) } this.current = turn - this.apiErrorText = null this.publish(turn) this.deps.sink.setActivity?.(null) } @@ -147,7 +135,6 @@ export class ClaudeOpenTurn { }) } this.current = turn - this.apiErrorText = null this.deps.sink.setActivity?.(null) } @@ -185,7 +172,6 @@ export class ClaudeOpenTurn { this.publish(this.current, end, contextUsage) this.current = null } - this.apiErrorText = null this.deps.sink.setActivity?.(null) } diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index fa6d4290b63f..1473886b9eac 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -60,7 +60,6 @@ export function journalClaudeResult( // person's cancellation is theirs to decide as the end is written (`turnEndAfterStop`). const turnId = settlesTurn ? turn.id : null const leftToStop = turnId !== null && sink.journalStopDecidesTurn?.(turnId, observedAt) === true - const spokenApiError = settlesTurn ? turn.spokenApiError : null if (settlesTurn) { prompts.retryPendingCancellations() turn.suppressReopenOnFailure(message.is_error === true) @@ -76,9 +75,7 @@ export function journalClaudeResult( } const kind = claudeProviderFrameKind(message) const failure = claudeResultFailure(message, leftToStop) - // The turn's reply already said this error; a second row would say it twice. - const repeatsReply = failure?.text != null && failure.text === spokenApiError - if ((failure && !repeatsReply) || !isSettledClaudeResultKind(kind)) { + if (failure || !isSettledClaudeResultKind(kind)) { providerFallback.append( kind, message, From 81de61234a7290391bae4063380f8782707ab60d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:40:19 -0700 Subject: [PATCH 11/22] fix(native-chat): pass saved Claude options unchecked, heal a retired model on the CLI's word, and never leave an unrun message in doubt - Saved model, effort and Fast are launched as picked; only values no Claude can parse are left out. The pre-spawn cache check is gone. - A saved Fast on for a new conversation is applied once the settings readback shows no per-session opt-in (dropped when there is one, or when the model is listed without Fast), with nothing waiting on it; the record keeps the pick. - Under an Agent Permissions bypass, a saved narrower mode launches with the allow flag so bypass stays reachable. - A turn whose reply is the CLI's model_not_found for the launched model drops that model from the record; the launch's own row for it is kept out of the account model cache. - A child that ends before it answered initialize, for any reason, settles every message it was handed as not sent (cancelled for a Stop). - A launched effort the CLI reports only as `applied.effort` is confirmed from there. - The untimed-initialize comment is back to main's text. - A real-CLI test for a message written before initialize answers, under saved options. --- .../claude-agent-sdk-control-requests.ts | 2 +- .../claude-structured-acquisition-launch.ts | 12 +- ...claude-structured-dispatch-test-support.ts | 1 + .../claude-structured-launch-resolution.ts | 18 +- .../claude/claude-structured-options.test.ts | 1 + src/main/claude/claude-structured-options.ts | 6 +- .../claude-structured-real-cli-fold.test.ts | 2 +- .../claude/claude-structured-real-cli.test.ts | 65 +++++- .../claude-structured-retired-model.test.ts | 150 +++++++++++++ .../claude/claude-structured-retired-model.ts | 44 ++++ .../claude-structured-session-acquisition.ts | 2 + .../claude-structured-session-adapter.test.ts | 63 +++++- .../claude-structured-session-adapter.ts | 12 ++ .../claude/claude-structured-session-close.ts | 3 +- ...laude-structured-session-exit-lifecycle.ts | 3 +- .../claude-structured-session-options.ts | 32 +-- .../claude-structured-session-publication.ts | 1 + ...claude-structured-session-startup-state.ts | 4 +- .../claude-structured-session-startup.test.ts | 15 ++ .../claude-structured-session-startup.ts | 53 ++++- .../claude/claude-structured-session-state.ts | 10 +- .../claude-structured-session-test-support.ts | 6 +- .../claude-structured-spawn-options.test.ts | 202 ++++-------------- .../claude/claude-structured-spawn-options.ts | 50 ++--- ...structured-agent-session-adapter-router.ts | 2 + .../structured-agent-session-adapter.ts | 16 ++ .../structured-agent-session-child-close.ts | 8 +- .../structured-agent-session-child-exit.ts | 14 +- ...ent-session-claude-hung-start-stop.test.ts | 57 ++++- ...gent-session-dead-generation-settlement.ts | 10 +- ...structured-agent-session-event-recovery.ts | 8 +- ...ructured-agent-session-provider-started.ts | 44 +++- ...ude-structured-retired-saved-model.test.ts | 78 +++++++ ...ctured-agent-session-lifecycle-delivery.ts | 10 +- .../structured-claude-runtime-adapter.ts | 5 +- 35 files changed, 741 insertions(+), 268 deletions(-) create mode 100644 src/main/claude/claude-structured-retired-model.test.ts create mode 100644 src/main/claude/claude-structured-retired-model.ts create mode 100644 src/main/runtime/claude-structured-retired-saved-model.test.ts diff --git a/src/main/claude/claude-agent-sdk-control-requests.ts b/src/main/claude/claude-agent-sdk-control-requests.ts index 97845e531985..d33131471021 100644 --- a/src/main/claude/claude-agent-sdk-control-requests.ts +++ b/src/main/claude/claude-agent-sdk-control-requests.ts @@ -104,7 +104,7 @@ export type ClaudeControlSurface = { ) => Promise stopTask: (taskId: string, options?: ClaudeControlOptions) => Promise supportedModels: (options?: ClaudeControlOptions) => Promise - /** Untimed here: startup's own deadline bounds it, and the child's exit closes the query. */ + /** Untimed: a slow start is still a start, and the child's exit closes the query under it. */ initializationResult: () => Promise getSettings: (options?: ClaudeControlOptions) => Promise /** The `/context` breakdown; older CLIs reject the request and the caller shows nothing. */ diff --git a/src/main/claude/claude-structured-acquisition-launch.ts b/src/main/claude/claude-structured-acquisition-launch.ts index 365cd6cc06d0..2023df22053d 100644 --- a/src/main/claude/claude-structured-acquisition-launch.ts +++ b/src/main/claude/claude-structured-acquisition-launch.ts @@ -8,7 +8,6 @@ import { stopAgentSessionProviderRoot } from '../native-chat/agent-session-wire/ import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation' import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' import { claudeStructuredSpawnOptions } from './claude-structured-spawn-options' -import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' import { cancelClaudeAcquisitionAttempt, type ClaudeAcquisitionAttempt, @@ -94,16 +93,7 @@ export async function resolveClaudeAcquisitionLaunch(args: { : new AgentSessionPreSpawnError(error) }) acquisitions.assertCurrent(sessionId, attempt) - const access = agentModelCatalogSessionAccess( - deps.modelCatalog, - 'claude', - launch.claudeConfigDir - ) - const spawn = claudeStructuredSpawnOptions({ - launch, - saved: input.options, - catalog: access?.store.get(access.fingerprint) ?? null - }) + const spawn = claudeStructuredSpawnOptions({ launch, saved: input.options }) return { ...launch, options: spawn.sdkOptions, diff --git a/src/main/claude/claude-structured-dispatch-test-support.ts b/src/main/claude/claude-structured-dispatch-test-support.ts index 5af0f17243b5..bd4bdba4af8e 100644 --- a/src/main/claude/claude-structured-dispatch-test-support.ts +++ b/src/main/claude/claude-structured-dispatch-test-support.ts @@ -29,6 +29,7 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C reportedModelMutation: 0, confirmedOptions: new Set(), restoreSkippedOptions: new Set(), + launchedModel: null, capabilities: [], events: undefined, translator: null, diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 9ee598256609..ae95dd3486da 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -100,18 +100,22 @@ export function claudeStructuredOptionsBypassPermissions( return options.extraArgs?.['dangerously-skip-permissions'] !== undefined } -/** `options` launched in `mode` instead of the mode they carry. */ +/** `options` launched in `mode` instead of the mode they carry; never more than they allow. */ export function claudeStructuredOptionsWithPermissionMode( options: ClaudeStructuredSdkOptions, mode: PermissionMode ): ClaudeStructuredSdkOptions { - const { 'dangerously-skip-permissions': _bypass, ...extraArgs } = options.extraArgs ?? {} - const { permissionMode: _mode, ...rest } = options + if (mode === 'bypassPermissions') { + return options + } + const { 'dangerously-skip-permissions': bypass, ...extraArgs } = options.extraArgs ?? {} return { - ...rest, - // The bypass flag starts the child in bypass; any other mode is named outright. - ...(mode === 'bypassPermissions' ? {} : { permissionMode: mode }), - extraArgs: { ...extraArgs, ...claudeStructuredPermissionOptions(mode).extraArgs } + ...options, + permissionMode: mode, + // The bypass flag would start the child in bypass. The allow flag starts it in `mode` and + // keeps bypass reachable, as the Agent Permissions setting grants (verified on 2.1.280). + ...(bypass !== undefined ? { allowDangerouslySkipPermissions: true } : {}), + extraArgs } } diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index ff63e7108462..502f61223e78 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -38,6 +38,7 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe reportedModelMutation: 0, confirmedOptions: new Set(), restoreSkippedOptions: new Set(), + launchedModel: null, capabilities: [], events: undefined, translator: null, diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index 464ef640acf0..15f33ea1b01f 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -103,7 +103,7 @@ export async function setClaudeStructuredOption( // every later model switch, so refuse before the write rather than read the // acceptance back as adoption. if (input.key === 'effort') { - const { modelId, levels } = claudeModelEffortLevels(listed, readClaudeCurrentModel(session).id) + const { modelId, levels } = claudeModelEffortLevels(session, listed) if (levels && !levels.has(input.value)) { throw new AgentSessionOptionRejectedError( `claude model ${modelId} does not accept effort ${input.value}` @@ -114,7 +114,7 @@ export async function setClaudeStructuredOption( if (typeof fastMode !== 'boolean') { throw new AgentSessionOptionRejectedError('claude fast mode must be encoded as true or false') } - const support = claudeModelFastModeSupport(listed, readClaudeCurrentModel(session).id) + const support = claudeModelFastModeSupport(session, listed) // A catalog that identified nothing is not evidence against this model, the same // rule the admit-check below applies — otherwise a CLI that cannot answer has Fast // refused on every model. A catalog that did list the model and stayed silent @@ -142,7 +142,7 @@ export async function setClaudeStructuredOption( } const modelFastModeSupport = input.key === 'model' && session.options.get('fastMode') === 'true' - ? claudeModelFastModeSupport(listed, input.value) + ? claudeModelFastModeSupport(session, listed, input.value) : null const modelWasConfirmed = readClaudeCurrentModel(session).confirmed const mutationSequence = ++session.optionMutationSequence diff --git a/src/main/claude/claude-structured-real-cli-fold.test.ts b/src/main/claude/claude-structured-real-cli-fold.test.ts index 6894af091e8f..8abcc0de2c9b 100644 --- a/src/main/claude/claude-structured-real-cli-fold.test.ts +++ b/src/main/claude/claude-structured-real-cli-fold.test.ts @@ -110,7 +110,7 @@ describe.skipIf(!realClaudeAvailable)('Claude structured real CLI fold', () => { events: sink }) await claudeStartupSettled(adapter, SESSION_ID) - // Startup proved from the SessionStart hook frame, with no init yet. + // Startup landed on the initialize answer, with no turn and so no init yet. expect( events.some( (event) => diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index 99bc5460e1de..6846709673e8 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -34,7 +34,8 @@ function realAdapter( events: ClaudeStructuredSessionEvent[] = [], cwd = process.cwd(), onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'], - env = realClaudeLaunchHome().env + env = realClaudeLaunchHome().env, + waitsForStartup = true ): ClaudeStructuredSessionAdapter { const adapter = new ClaudeStructuredSessionAdapter({ resolveLaunch: async () => ({ @@ -53,6 +54,9 @@ function realAdapter( readProcessStartTime: async () => 1, now: () => 2 }) + if (!waitsForStartup) { + return adapter + } // These proofs read startup facts, which land after the session is published. const acquire = adapter.acquire adapter.acquire = async (input) => { @@ -334,6 +338,65 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { 90_000 ) + // The contract a chat's first message depends on: saved options ride the launch, and the + // message is written as soon as the child is published, before the CLI answers initialize. + it.skipIf(!realClaudeAuthenticated)( + 'runs a message written before initialize answers, under the saved options it was launched with', + async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = realAdapter( + providerSessionId, + claudeConfigDir, + events, + process.cwd(), + undefined, + realClaudeLaunchHome().env, + false + ) + const messages = (): Record[] => + events.flatMap((event) => (event.type === 'message' ? [event.message] : [])) + + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-saved-options', + options: { model: 'sonnet', permissionMode: 'plan', effort: 'low' } + }) + await expect( + adapter.dispatch({ + sessionId: 'real-cli-handshake', + clientMessageId: 'real-cli-saved-options-1', + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Reply with exactly: OK. Do not use any tools.' }] + }, + fence: 1 + }) + ).resolves.toEqual({ state: 'admitted' }) + const deadline = Date.now() + 90_000 + while (!messages().some((m) => m.type === 'result') && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 250)) + } + + // The echo of the message Orca wrote, then a turn that ended in success. + expect(messages()).toContainEqual(expect.objectContaining({ type: 'user', isReplay: true })) + expect(messages().find((m) => m.type === 'result')).toMatchObject({ is_error: false }) + // The turn's own init names what the child was launched with. + expect(messages().find((m) => m.type === 'system' && m.subtype === 'init')).toMatchObject({ + model: 'claude-sonnet-5', + permissionMode: 'plan' + }) + } finally { + await adapter.closeAll() + } + }, + 120_000 + ) + // The window a Stop naming no turn exists for: Orca has written the message, and Claude has not // started its reply, so no turn id exists. Only the live binary can say the interrupt lands there. it.skipIf(!realClaudeAuthenticated)( diff --git a/src/main/claude/claude-structured-retired-model.test.ts b/src/main/claude/claude-structured-retired-model.test.ts new file mode 100644 index 000000000000..51d7e951ed91 --- /dev/null +++ b/src/main/claude/claude-structured-retired-model.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest' +import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' +import { + claudeCatalogRowsOfAccount, + retireClaudeLaunchedModel +} from './claude-structured-retired-model' +import { + claudeStartupSettled, + fakeClaude, + identityFor, + PROVIDER_SESSION_ID, + recordingJournalSink +} from './claude-structured-session-test-support' + +const ACCOUNT_HOME = '/accounts/claude' + +function catalogAccess(store: AgentModelCatalogStore) { + const access = agentModelCatalogSessionAccess(store, 'claude', ACCOUNT_HOME) + if (!access) { + throw new Error('a store and an account home always give access') + } + return access +} +const RETIRED = 'claude-retired-1' +/** The rows Claude Code 2.1.280 lists for a child launched with `--model claude-bogus-9`: its own + * native rows, then one for the launched id, named by that id. */ +const NATIVE_OPUS = { value: 'opus', resolvedModel: 'claude-opus-5', displayName: 'Opus' } +const LAUNCHED_ROW = { + value: RETIRED, + resolvedModel: RETIRED, + displayName: RETIRED, + description: 'Custom model' +} + +function modelNotFound(parentToolUseId: string | null = null): Record { + return { + type: 'assistant', + uuid: 'reply-1', + session_id: PROVIDER_SESSION_ID, + parent_tool_use_id: parentToolUseId, + error: 'model_not_found', + is_api_error_message: true, + message: { model: '', role: 'assistant', content: [] } + } +} + +function sessionLaunchedWith(model: string | null, picked: string | null = model) { + return { + launchedModel: model, + options: new Map(picked === null ? [] : [['model', picked]]), + restoreSkippedOptions: new Set() + } +} + +describe("the CLI's word that a launched model does not exist", () => { + it('drops the launched model once, as a skipped saved option', () => { + const session = sessionLaunchedWith(RETIRED) + + expect(retireClaudeLaunchedModel(session, modelNotFound())).toBe(true) + expect(session.options.has('model')).toBe(false) + expect([...session.restoreSkippedOptions]).toEqual(['model']) + expect(retireClaudeLaunchedModel(session, modelNotFound())).toBe(false) + }) + + it.each([ + ['a subagent reply', sessionLaunchedWith(RETIRED), modelNotFound('toolu_1')], + ['another error', sessionLaunchedWith(RETIRED), { ...modelNotFound(), error: 'rate_limit' }], + ['a model picked since the launch', sessionLaunchedWith(RETIRED, 'opus'), modelNotFound()], + ['a launch that named no model', sessionLaunchedWith(null), modelNotFound()] + ])('keeps the saved model for %s', (_case, session, message) => { + expect(retireClaudeLaunchedModel(session, message)).toBe(false) + expect([...session.restoreSkippedOptions]).toEqual([]) + }) +}) + +describe("the account catalog a child's listing writes through", () => { + const rows = [ + { id: 'opus', label: 'Opus' }, + { id: RETIRED, label: RETIRED } + ] + + function access(cached: string[] | null) { + const store = new AgentModelCatalogStore() + const handle = catalogAccess(store) + if (cached) { + store.recordSuccess(handle.fingerprint, 'claude', { + models: cached.map((id) => ({ id, label: id, isDefault: false, efforts: [] })), + fastModeTierByModel: new Map(), + origin: 'probe' + }) + } + return handle + } + + it('leaves out the row the launch added for its own model', () => { + expect(claudeCatalogRowsOfAccount(access(null), rows, RETIRED)).toEqual([rows[0]]) + }) + + it('keeps that row when the account already listed the model', () => { + expect(claudeCatalogRowsOfAccount(access([RETIRED]), rows, RETIRED)).toEqual(rows) + }) + + it('keeps a launched native model, which carries its own display name', () => { + expect(claudeCatalogRowsOfAccount(access(null), rows, 'opus')).toEqual(rows) + }) + + it('keeps every row for a launch that named no model', () => { + expect(claudeCatalogRowsOfAccount(access(null), rows, null)).toEqual(rows) + }) + + it('never writes a launched-only model into the account catalog at start', async () => { + const store = new AgentModelCatalogStore() + const handle = catalogAccess(store) + const claude = fakeClaude({ initModels: [NATIVE_OPUS, LAUNCHED_ROW] }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: ACCOUNT_HOME, + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: true, + continuesChain: true + }), + onEvent: (event) => events.push(event), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500, + persistHandle: async () => {}, + modelCatalog: store + }) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: recordingJournalSink(), + options: { model: RETIRED } + }) + await claudeStartupSettled(adapter, 'session-1') + + expect(events.some((event) => event.type === 'started')).toBe(true) + expect(store.get(handle.fingerprint)?.models.map((model) => model.id)).toEqual(['opus']) + await adapter.closeAll() + }) +}) diff --git a/src/main/claude/claude-structured-retired-model.ts b/src/main/claude/claude-structured-retired-model.ts new file mode 100644 index 000000000000..f04f889a05a4 --- /dev/null +++ b/src/main/claude/claude-structured-retired-model.ts @@ -0,0 +1,44 @@ +// A chat's saved model is passed to the CLI as `--model` unchecked, so one the provider has since +// retired fails the turn. The CLI says so itself: a synthetic reply whose `error` is +// `model_not_found` (Claude Code 2.1.280). That reply is the evidence the saved model cannot run. + +import type { AgentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import type { ClaudeSession } from './claude-structured-session-state' + +/** True once, when a root reply says the model this child was launched with does not exist and + * the chat still has that model picked: the session stops holding it, so the record drops it. */ +export function retireClaudeLaunchedModel( + session: Pick, + message: Record +): boolean { + const launched = session.launchedModel + if ( + launched === null || + message.type !== 'assistant' || + (message.parent_tool_use_id ?? null) !== null || + message.error !== 'model_not_found' || + session.options.get('model') !== launched + ) { + return false + } + session.options.delete('model') + session.restoreSkippedOptions.add('model') + return true +} + +/** The rows of a listing the account's catalog keeps. A child launched with `--model X` lists X + * itself (Claude Code 2.1.280 adds a row named by the raw id, "Custom model"), whether or not X + * exists: that row is the launch talking, not the account, unless the account already listed X. + * Native rows carry a display name of their own, so a row named by its id is the tell. */ +export function claudeCatalogRowsOfAccount( + access: AgentModelCatalogSessionAccess, + rows: readonly T[], + launchedModel: string | null +): T[] { + const listedBefore = access.store + .get(access.fingerprint) + ?.models.some((model) => model.id === launchedModel) + return rows.filter( + (row) => listedBefore === true || row.id !== launchedModel || row.label !== row.id + ) +} diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index a5c94760c2f1..1c15e9776586 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -284,6 +284,8 @@ export async function acquireClaudeSession({ initProof, sessionId, providerSessionId: launch.providerSessionId, + startup: session.startup, + resumesTranscript: launch.resumesTranscript, requestTimeoutMs: deps.requestTimeoutMs, emit }), diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index fe2be22b0d40..8b60cce0015b 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -106,11 +106,12 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { ).toEqual([]) }) - it('does not carry a saved Fast opt-in into a new conversation', async () => { + it('does not carry a saved Fast into a new conversation whose settings opt in per session', async () => { const claude = fakeClaude({ settings: { effective: { fastMode: false, fastModePerSessionOptIn: true } } }) - const adapter = adapterFor(claude) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) await adapter.acquire({ identity: identityFor(), @@ -123,8 +124,64 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { expect( claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') ).toEqual([]) - // Left out, not refused: the record keeps the provider's own Fast, as before. + // Left out, not refused: the record takes the provider's own Fast, as before. expect(adapter.readOptionRestoreFailures('session-1')).toEqual([]) + expect(events.find((event) => event.type === 'started')).toMatchObject({ + reportedOptions: { fastMode: false } + }) + }) + + // No message waits on it: the launch carries no Fast, and the start applies it once the CLI's + // settings show no per-session opt-in, as the live CLI reports with no settings file at all. + it('applies a saved Fast on to a new conversation once its settings allow it, and keeps the pick', async () => { + const claude = fakeClaude({ + settings: { applied: {}, effective: {}, sources: {} }, + initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }] + }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + options: { model: 'opus', fastMode: 'true' } + }) + + expect(claude.connections[0].launch.options.settings).toBeUndefined() + expect(claude.connections[0].calls.map((call) => call.subtype)).toEqual([ + 'initialize', + 'get_settings', + 'apply_flag_settings' + ]) + expect(claude.connections[0].calls.at(-1)).toEqual({ + subtype: 'apply_flag_settings', + params: { settings: { fastMode: true } } + }) + expect(events.find((event) => event.type === 'started')).toMatchObject({ + reportedOptions: { fastMode: true }, + restoreSkippedOptions: [] + }) + }) + + it('drops a saved Fast on from a new conversation whose model the CLI lists without it', async () => { + const claude = fakeClaude({ + settings: { applied: {}, effective: {}, sources: {} }, + initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: false }] + }) + const adapter = adapterFor(claude) + + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + options: { model: 'opus', fastMode: 'true' } + }) + + expect( + claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') + ).toEqual([]) + expect(adapter.readOptionRestoreFailures('session-1')).toEqual(['fastMode']) }) it.each([ diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 81d438f04a7d..4c520573f24e 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -44,6 +44,7 @@ import { settleClaudePromptFreeingChild } from './claude-structured-prompt-ownership' import { claudePromptCancelRoute } from './claude-structured-prompt-replies' +import { retireClaudeLaunchedModel } from './claude-structured-retired-model' export type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' export type { @@ -160,6 +161,15 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } session?.translator?.handle(event) this.deps.onEvent?.(event) + if (event.type === 'message' && session && retireClaudeLaunchedModel(session, event.message)) { + this.deps.onEvent?.({ + type: 'options-skipped', + sessionId: event.sessionId, + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration, + keys: ['model'] + }) + } this.publishChildWork(event.sessionId, session, event.type === 'message' ? event.message : null) // A subagent's card holds it waiting only once its row is written: its wait goes out after. void claudePromptCardWritten(session, event)?.then(() => this.publishChildWork(event.sessionId)) @@ -245,6 +255,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda input, this.deps.requestTimeoutMs ) + startAnswered = (sessionId: string): boolean | undefined => + this.sessions.get(sessionId)?.startup.answered awaitOptionWritable = (sessionId: string): Promise => claudeStartupSettledWithin( this.sessions.get(sessionId), diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 903f49f052e7..341829926e62 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -149,7 +149,8 @@ async function finalizeClaudePublishedSession( cause: 'requested-close', fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, - observedAt: Date.now() + observedAt: Date.now(), + ...(session.startup.answered ? {} : { startupUnanswered: true }) } as const try { try { diff --git a/src/main/claude/claude-structured-session-exit-lifecycle.ts b/src/main/claude/claude-structured-session-exit-lifecycle.ts index 854646490a8c..c512d58c8677 100644 --- a/src/main/claude/claude-structured-session-exit-lifecycle.ts +++ b/src/main/claude/claude-structured-session-exit-lifecycle.ts @@ -106,7 +106,8 @@ export function settleClaudeUnexpectedExit( fence: exit.session.fence, acquisitionGeneration: exit.session.acquisitionGeneration, observedAt: deps.now?.() ?? Date.now(), - ...(exit.session.startup.state === 'proven' ? {} : { startupUnproven: true }) + ...(exit.session.startup.state === 'proven' ? {} : { startupUnproven: true }), + ...(exit.session.startup.answered ? {} : { startupUnanswered: true }) } try { lifecycle.emit(exit.session, ended) diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 06cfea6bbdad..1e4eea0f95cc 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -14,6 +14,7 @@ import { } from './claude-structured-model-catalog' import type { ClaudeSession } from './claude-structured-session-state' import { structuredAgentSessionOptionModels } from '../native-chat/agent-session-wire/structured-agent-session-option-models' +import { claudeCatalogRowsOfAccount } from './claude-structured-retired-model' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' /** @@ -122,6 +123,14 @@ export function readClaudeCurrentModel(session: ClaudeSession): { } } +/** + * The effort levels the session's current model advertises, with the catalog id + * that matched so a refusal names the model the pill shows. Levels are null when + * nothing identified the model: `apply_flag_settings` accepts and stores any + * level for a model with no effort control, so the catalog is the only evidence + * of a refusal — and an absent or unlisted one is not evidence, or a live CLI + * that predates `list_models` would have every effort refused under it. + */ /** One catalog read serves a whole option write. The admit check, the effort guard * and the Fast guard all ask about the same list; each taking its own read made a * single model write pay for two `list_models` round trips and let two guards answer @@ -135,18 +144,11 @@ export async function readClaudeListedModels( return catalog ? listedModels({ models: catalog }) : [] } -/** - * The effort levels the session's current model advertises, with the catalog id - * that matched so a refusal names the model the pill shows. Levels are null when - * nothing identified the model: `apply_flag_settings` accepts and stores any - * level for a model with no effort control, so the catalog is the only evidence - * of a refusal — and an absent or unlisted one is not evidence, or a live CLI - * that predates `list_models` would have every effort refused under it. - */ export function claudeModelEffortLevels( - models: readonly ListedModel[], - modelId: string | undefined + session: ClaudeSession, + models: readonly ListedModel[] ): { modelId: string | undefined; levels: ReadonlySet | null } { + const modelId = readClaudeCurrentModel(session).id const matched = modelId ? models.find((model) => model.id === modelId || model.resolvedModel === modelId) : undefined @@ -156,11 +158,12 @@ export function claudeModelEffortLevels( } } -/** Fast support for `reportedModelId`; none means the listing's default. */ export function claudeModelFastModeSupport( + session: ClaudeSession, models: readonly ListedModel[], - reportedModelId: string | undefined + requestedModel?: string ): { modelId: string | undefined; supported: boolean | null } { + const reportedModelId = requestedModel ?? readClaudeCurrentModel(session).id const modelId = reportedModelId ?? models.find((model) => model.isDefault)?.id const matched = modelId ? matchListedModel(models, modelId) : undefined return { @@ -268,9 +271,10 @@ function writeClaudeCatalogThrough(session: ClaudeSession, discovered: ListedMod if (discovered.length === 0 || !session.catalogAccess) { return } - const support = claudeFastModeSupport(discovered, undefined) + const rows = claudeCatalogRowsOfAccount(session.catalogAccess, discovered, session.launchedModel) + const support = claudeFastModeSupport(rows, undefined) session.catalogAccess.store.recordSuccess(session.catalogAccess.fingerprint, 'claude', { - models: catalogClaudeModels(session, discovered), + models: catalogClaudeModels(session, rows), ...(support ? { fastModeSupport: support } : {}), fastModeTierByModel: new Map(), origin: 'live-session' diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index c685c2cd2ae7..15d8bdad67ca 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -64,6 +64,7 @@ export function createClaudeSessionPublication(input: { reportedModelMutation: 0, confirmedOptions: new Set(), restoreSkippedOptions: new Set(), + launchedModel: null, translator: input.translator, events: input.events, ...(input.unbindReadingControl ? { unbindReadingControl: input.unbindReadingControl } : {}), diff --git a/src/main/claude/claude-structured-session-startup-state.ts b/src/main/claude/claude-structured-session-startup-state.ts index a57aa2e8fa84..ba4107fa7f4c 100644 --- a/src/main/claude/claude-structured-session-startup-state.ts +++ b/src/main/claude/claude-structured-session-startup-state.ts @@ -9,6 +9,8 @@ import type { ClaudeSession } from './claude-structured-session-state' export type ClaudeSessionStartup = { state: 'pending' | 'proven' | 'failed' + /** The CLI answered initialize: it may have run what it was handed. Before that it ran nothing. */ + answered: boolean failure: Error | null /** Resolves once startup has landed or faulted, or the child exited or was closed; never * rejects. A close must end it: an option write waits here. */ @@ -21,7 +23,7 @@ export function createClaudeSessionStartup(): ClaudeSessionStartup { const ended = new Promise((resolve) => { end = resolve }) - return { state: 'pending', failure: null, settled: ended, end } + return { state: 'pending', answered: false, failure: null, settled: ended, end } } export function claudeStartupFailureFact(session: ClaudeSession): SubmissionRejectionFact | null { diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index 30a1ba2946c3..7946959b0afa 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -179,6 +179,21 @@ describe('Claude structured session publishes before the CLI answers initialize' await unconfirmed.closeAll() }) + // Measured on Claude Code 2.1.280: `--effort xhigh` reads back as `applied.effort: 'xhigh'` with + // `effective` empty, since `effective` holds only the settings files. + it('confirms a launched effort the CLI reports only as applied', async () => { + const claude = fakeClaude({ + settings: { applied: { model: 'claude-opus-5', effort: 'xhigh' }, effective: {}, sources: {} } + }) + const { adapter } = startingAdapter(claude) + await adapter.acquire({ ...ACQUIRE, options: { effort: 'xhigh' } }) + await claudeStartupSettled(adapter, 'session-1') + + const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(options.current).toMatchObject({ effort: 'xhigh', confirmed: ['effort'] }) + await adapter.closeAll() + }) + it('ends the session with the exit reason when the CLI dies before init', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS, diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index f9392f9e04bb..01a4bb3e1472 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -18,12 +18,17 @@ import { prepareClaudeStructuredSessionAcquisitionOptions, readClaudeStructuredSessionSettings } from './claude-structured-session-acquisition-options' +import { listedModels } from './claude-structured-model-catalog' import { + claudeModelFastModeSupport, claudeStructuredSessionOptionsFrom, observeClaudeSettingsApplied, readClaudeSettingsEffort } from './claude-structured-session-options' -import { failClaudeStartup } from './claude-structured-session-startup-state' +import { + failClaudeStartup, + type ClaudeSessionStartup +} from './claude-structured-session-startup-state' import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' /** The CLI's own frame naming the session it runs (system/init or a SessionStart hook). Only a @@ -92,6 +97,8 @@ export type ClaudeStartupFacts = { initialization: unknown settings: unknown prepared: ReturnType + resumesTranscript: boolean + requestTimeoutMs: number | undefined } /** Settles on the CLI's initialize answer, or on its exit or a refused proof. */ @@ -100,12 +107,15 @@ export async function readClaudeStartupFacts(input: { initProof: ClaudeInitProof sessionId: string providerSessionId: string + startup: Pick + resumesTranscript: boolean requestTimeoutMs: number | undefined emit: (event: ClaudeStructuredSessionEvent) => void }): Promise { // The CLI's first answer has no request deadline of its own; the reads after it do. const initialization = await Promise.race([ input.connection.initializationResult().then((result) => { + input.startup.answered = true const authError = claudeInitializationAuthError(result) if (authError) { throw authError @@ -139,8 +149,41 @@ export async function readClaudeStartupFacts(input: { initProof: input.initProof, initialization, settings, - prepared: prepareClaudeStructuredSessionAcquisitionOptions({ settings, initialization }) + prepared: prepareClaudeStructuredSessionAcquisitionOptions({ settings, initialization }), + resumesTranscript: input.resumesTranscript, + requestTimeoutMs: input.requestTimeoutMs + } +} + +/** A new conversation is launched without a saved Fast on, which its settings may opt in to per + * session. Those settings now read: an opt-in drops it, as before; otherwise it is applied, and + * no message waits on that. */ +function applyClaudeFreshSessionFastMode(session: ClaudeSession, facts: ClaudeStartupFacts): void { + if (facts.resumesTranscript || session.options.get('fastMode') !== 'true') { + return + } + if (facts.prepared.fastModePerSessionOptIn === true) { + session.options.delete('fastMode') + return } + // The guards a live Fast write takes, over the listing this start already holds. + const listed = listedModels({ models: readClaudeModels(facts.initialization) }) + const blocked = + session.fastModeDisabledReason !== undefined && + !['preference', 'sdk_opt_in_required'].includes(session.fastModeDisabledReason) + if ( + blocked || + (listed.length > 0 && claudeModelFastModeSupport(session, listed).supported !== true) + ) { + session.options.delete('fastMode') + session.restoreSkippedOptions.add('fastMode') + return + } + void session.connection + .applyFlagSettings({ fastMode: true }, { timeoutMs: facts.requestTimeoutMs }) + .catch((error: unknown) => + console.warn('[claude-structured] applying a saved Fast on to a new session failed:', error) + ) } function applyClaudeStartupFacts(session: ClaudeSession, facts: ClaudeStartupFacts): void { @@ -162,6 +205,11 @@ function applyClaudeStartupFacts(session: ClaudeSession, facts: ClaudeStartupFac session.confirmedOptions.add('effort') } } + // A launch `--effort` shows only in `applied`, never in `effective` (measured on 2.1.280). + const launchedEffort = session.options.get('effort') + if (launchedEffort !== undefined && session.appliedOptions?.effort === launchedEffort) { + session.confirmedOptions.add('effort') + } if (published.fastMode !== null) { session.reportedOptions.fastMode = published.fastMode if (agrees('fastMode', String(published.fastMode))) { @@ -226,6 +274,7 @@ export async function settleClaudeSessionStartup(input: { } } applyClaudeStartupFacts(session, facts) + applyClaudeFreshSessionFastMode(session, facts) if (!superseded()) { input.onStarted({ // `list_models` is answered from this same initialize result, so nothing is re-read. diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 876090c07e4d..4fe87a85c9c2 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -6,7 +6,10 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { StructuredAgentSessionStartedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + StructuredAgentSessionOptionsSkippedEvent, + StructuredAgentSessionStartedEvent +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection, openClaudeStreamJsonConnection @@ -67,6 +70,7 @@ export type ClaudeStructuredSessionEvent = | { type: 'auth-diagnostic'; sessionId: string; diagnostic: ClaudeAuthDiagnostic } /** Startup facts applied and saved options restored; held prompts are about to be written. */ | StructuredAgentSessionStartedEvent + | StructuredAgentSessionOptionsSkippedEvent | { type: 'ended' sessionId: string @@ -80,6 +84,8 @@ export type ClaudeStructuredSessionEvent = observedAt?: number /** The child ended before proving startup, so reacquiring would repeat the same start. */ startupUnproven?: true + /** The child ended before it answered initialize, so it ran nothing it was handed. */ + startupUnanswered?: true } export type ClaudeLateDispatchOutcome = @@ -179,6 +185,8 @@ export type ClaudeSession = { /** Options whose recorded value the provider reported, not merely accepted. */ confirmedOptions: Set restoreSkippedOptions: Set + /** The model the child was launched with, as `--model`; null when it runs the CLI's own. */ + launchedModel: string | null /** Absent when the adapter runs without a host catalog store (tests). */ catalogAccess?: AgentModelCatalogSessionAccess /** CLI-advertised protocol capabilities from init; gates interrupt-receipt handling. */ diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index 7220f88728df..de8ab6a82e92 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -162,10 +162,12 @@ export function fakeClaude( getSettings: async () => { connection.calls.push({ subtype: 'get_settings' }) // Shape measured from Claude Code 2.1.258: {applied, effective, sources}, - // and the only place the session's current effort is reported. + // and the only place the session's current effort is reported. A launch `--effort` + // shows only in `applied`; `effective` holds the settings files' own (2.1.280). + const effort = launch.options.effort ?? 'high' return ( options.settings ?? { - applied: { model: 'claude-sonnet-5', effort: 'high', advisor: null, ultracode: false }, + applied: { model: 'claude-sonnet-5', effort, advisor: null, ultracode: false }, effective: { model: 'claude-sonnet-5', effortLevel: 'high', env: {} }, sources: {} } diff --git a/src/main/claude/claude-structured-spawn-options.test.ts b/src/main/claude/claude-structured-spawn-options.test.ts index 2d3d474594e6..f82411770d26 100644 --- a/src/main/claude/claude-structured-spawn-options.test.ts +++ b/src/main/claude/claude-structured-spawn-options.test.ts @@ -1,69 +1,21 @@ import { describe, expect, it } from 'vitest' -import type { AgentSessionModelOption } from '../../shared/agent-session-wire' -import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' -import { - AgentModelCatalogStore, - type AgentModelCatalogEntry -} from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { CLAUDE_STRUCTURED_BASE_OPTIONS, claudeStructuredPermissionOptions, type ClaudeStructuredSdkOptions } from './claude-structured-launch-resolution' -import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' -import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' import { claudeStructuredSpawnOptions } from './claude-structured-spawn-options' -import { - claudeStartupSettled, - fakeClaude, - identityFor, - PROVIDER_SESSION_ID, - recordingJournalSink -} from './claude-structured-session-test-support' - -const ACCOUNT_HOME = '/accounts/claude' -const OPUS: AgentSessionModelOption = { - id: 'opus', - label: 'Opus', - isDefault: true, - efforts: [ - { value: 'low', label: 'Low' }, - { value: 'high', label: 'High' } - ], - supportsFastMode: true -} -const HAIKU: AgentSessionModelOption = { - id: 'haiku', - label: 'Haiku', - isDefault: false, - efforts: [], - supportsFastMode: false -} - -function catalog(models: AgentSessionModelOption[]): AgentModelCatalogEntry { - return { - agent: 'claude', - fingerprint: 'fingerprint', - models, - fastModeTierByModel: {}, - origin: 'probe', - fetchedAt: 0 - } -} function launched( saved: Record, - options: { - catalog?: AgentModelCatalogEntry - resumesTranscript?: boolean - base?: ClaudeStructuredSdkOptions - } = {} + options: { resumesTranscript?: boolean; base?: ClaudeStructuredSdkOptions } = {} ) { - const base = options.base ?? CLAUDE_STRUCTURED_BASE_OPTIONS return claudeStructuredSpawnOptions({ - launch: { options: base, resumesTranscript: options.resumesTranscript ?? true }, - saved, - catalog: options.catalog ?? null + launch: { + options: options.base ?? CLAUDE_STRUCTURED_BASE_OPTIONS, + resumesTranscript: options.resumesTranscript ?? true + }, + saved }) } @@ -102,63 +54,36 @@ describe('a Claude chat launched with its saved options', () => { expect(spawn.skipped).toEqual([]) }) - it('passes everything unchecked when the account has no cached catalog', () => { - const spawn = launched({ model: 'claude-retired-1', effort: 'low' }) - - expect(spawn.sdkOptions).toMatchObject({ model: 'claude-retired-1', effort: 'low' }) - expect(spawn.skipped).toEqual([]) - }) + // A resolved id, a model newer than any listing Orca holds, a retired one: the CLI's own answer + // decides, and a turn's model-not-found error drops it then. + it.each(['claude-sonnet-5', 'claude-next-9', 'claude-retired-1'])( + 'passes the saved model %s as it was picked', + (model) => { + const spawn = launched({ model, effort: 'xhigh' }) - it('leaves out a saved model the cached catalog does not list, and records it skipped', () => { - const spawn = launched({ model: 'claude-retired-1' }, { catalog: catalog([OPUS, HAIKU]) }) - - expect(spawn.sdkOptions).not.toHaveProperty('model') - expect(spawn.options.has('model')).toBe(false) - expect(spawn.skipped).toEqual(['model']) - }) - - it('passes a saved model the cached catalog lists', () => { - const spawn = launched({ model: 'haiku' }, { catalog: catalog([OPUS, HAIKU]) }) - - expect(spawn.sdkOptions.model).toBe('haiku') - expect(spawn.skipped).toEqual([]) - }) + expect(spawn.sdkOptions).toMatchObject({ model, effort: 'xhigh' }) + expect(spawn.skipped).toEqual([]) + } + ) - it('leaves out an effort the cached entry for the saved model rules out', () => { - const spawn = launched({ model: 'haiku', effort: 'high' }, { catalog: catalog([OPUS, HAIKU]) }) + it('leaves out an effort or permission mode no Claude can parse, and Fast it cannot decode', () => { + const spawn = launched({ effort: 'ludicrous', permissionMode: 'retired-mode', fastMode: 'yes' }) - expect(spawn.sdkOptions).toMatchObject({ model: 'haiku' }) expect(spawn.sdkOptions).not.toHaveProperty('effort') - expect(spawn.skipped).toEqual(['effort']) - }) - - it('passes an effort with no saved model, which the launch cannot check', () => { - const spawn = launched({ effort: 'high' }, { catalog: catalog([HAIKU]) }) - - expect(spawn.sdkOptions.effort).toBe('high') - }) - - it('leaves out an effort the CLI would refuse at launch', () => { - expect(launched({ effort: 'ludicrous' }).skipped).toEqual(['effort']) - }) - - it('leaves out Fast the cached entry says the model does not support', () => { - const spawn = launched({ model: 'haiku', fastMode: 'true' }, { catalog: catalog([HAIKU]) }) - + expect(spawn.sdkOptions).not.toHaveProperty('permissionMode') expect(spawn.sdkOptions).not.toHaveProperty('settings') - expect(spawn.skipped).toEqual(['fastMode']) + expect(spawn.skipped).toEqual(['effort', 'fastMode', 'permissionMode']) }) - it('starts a new conversation with Fast off rather than carry a saved Fast on', () => { + it('keeps a saved Fast on for a new conversation, for its start to apply', () => { const spawn = launched({ fastMode: 'true' }, { resumesTranscript: false }) expect(spawn.sdkOptions).not.toHaveProperty('settings') - expect(spawn.options.has('fastMode')).toBe(false) - // Left out by rule, not refused: nothing is recorded as skipped. + expect(spawn.options.get('fastMode')).toBe('true') expect(spawn.skipped).toEqual([]) }) - it('passes a saved Fast off to a new conversation too', () => { + it('passes a saved Fast off to a new conversation', () => { const spawn = launched({ fastMode: 'false' }, { resumesTranscript: false }) expect(spawn.sdkOptions.settings).toEqual({ fastMode: false }) @@ -179,82 +104,27 @@ describe('a Claude chat launched with its saved options', () => { const spawn = launched({ permissionMode: 'bypassPermissions' }) expect(spawn.sdkOptions.extraArgs).toEqual({ 'replay-user-messages': null }) + expect(spawn.sdkOptions).not.toHaveProperty('allowDangerouslySkipPermissions') expect(spawn.skipped).toEqual(['permissionMode']) }) - it('replaces an Agent Permissions bypass with a saved narrower mode', () => { + // Measured on Claude Code 2.1.280: `--permission-mode plan --allow-dangerously-skip-permissions` + // starts in plan and takes `set_permission_mode bypassPermissions` later; without the allow flag + // that write is refused. + it('starts a saved narrower mode under an Agent Permissions bypass with bypass still reachable', () => { const spawn = launched({ permissionMode: 'acceptEdits' }, { base: BYPASS_LAUNCH }) - expect(spawn.sdkOptions.permissionMode).toBe('acceptEdits') - expect(spawn.sdkOptions.extraArgs).toEqual({ 'replay-user-messages': null }) - }) -}) - -describe('a Claude child launched against the account cached catalog', () => { - async function startWith( - options: Record, - cached: AgentSessionModelOption[] | null - ) { - const store = new AgentModelCatalogStore() - const access = agentModelCatalogSessionAccess(store, 'claude', ACCOUNT_HOME) - if (cached && access) { - store.recordSuccess(access.fingerprint, 'claude', { - models: cached, - fastModeTierByModel: new Map(), - origin: 'probe' - }) - } - const claude = fakeClaude() - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = new ClaudeStructuredSessionAdapter({ - resolveLaunch: async () => ({ - pathToClaudeCodeExecutable: 'claude', - options: {}, - cwd: '/work/repo', - claudeConfigDir: ACCOUNT_HOME, - providerSessionId: PROVIDER_SESSION_ID, - resumeLeafUuid: null, - resumesTranscript: true, - continuesChain: true - }), - onEvent: (event) => events.push(event), - openConnection: claude.openConnection, - readProcessStartTime: async () => 1_700_000_000_000, - now: () => 1_700_000_000_500, - persistHandle: async () => {}, - modelCatalog: store - }) - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - events: recordingJournalSink(), - options - }) - await claudeStartupSettled(adapter, 'session-1') - return { claude, events, adapter } - } - - it('does not launch a saved model the cache does not list; the start drops it from the record', async () => { - const { claude, events, adapter } = await startWith({ model: 'claude-retired-1' }, [OPUS]) - - expect(claude.connections[0]!.launch.options).not.toHaveProperty('model') - expect(adapter.readOptionRestoreFailures('session-1')).toEqual(['model']) - expect(events.find((event) => event.type === 'started')).toMatchObject({ - restoreSkippedOptions: ['model'] + expect(spawn.sdkOptions).toMatchObject({ + permissionMode: 'acceptEdits', + allowDangerouslySkipPermissions: true }) - // Nothing was asked of the CLI to find that out. - expect( - claude.connections[0]!.calls.filter((call) => - ['list_models', 'set_model', 'apply_flag_settings'].includes(call.subtype) - ) - ).toEqual([]) + expect(spawn.sdkOptions.extraArgs).toEqual({ 'replay-user-messages': null }) }) - it('launches the saved model unchecked when the account has never listed its models', async () => { - const { claude, adapter } = await startWith({ model: 'claude-retired-1' }, null) + it('starts a saved narrower mode without the allow flag when Agent Permissions prompts', () => { + const spawn = launched({ permissionMode: 'plan' }) - expect(claude.connections[0]!.launch.options.model).toBe('claude-retired-1') - expect(adapter.readOptionRestoreFailures('session-1')).toEqual([]) + expect(spawn.sdkOptions.permissionMode).toBe('plan') + expect(spawn.sdkOptions).not.toHaveProperty('allowDangerouslySkipPermissions') }) }) diff --git a/src/main/claude/claude-structured-spawn-options.ts b/src/main/claude/claude-structured-spawn-options.ts index 34728e3c69de..01bf109a3520 100644 --- a/src/main/claude/claude-structured-spawn-options.ts +++ b/src/main/claude/claude-structured-spawn-options.ts @@ -3,7 +3,6 @@ // a control request to apply each one. import type { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk' -import type { AgentModelCatalogEntry } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' import { claudeStructuredOptionsBypassPermissions, @@ -11,14 +10,8 @@ import { type ClaudeStructuredLaunch, type ClaudeStructuredSdkOptions } from './claude-structured-launch-resolution' -import type { ListedModel } from './claude-structured-model-catalog' import type { ClaudeSession } from './claude-structured-session-state' import { restoredClaudeStructuredSessionOptions } from './claude-structured-options' -import { - claudeCatalogAdmitsModel, - claudeModelEffortLevels, - claudeModelFastModeSupport -} from './claude-structured-session-options' const EFFORT_LEVELS: ReadonlySet = new Set([ 'low', @@ -46,44 +39,33 @@ function isPermissionMode(value: string): value is PermissionMode { export type ClaudeStructuredSpawnOptions = { sdkOptions: ClaudeStructuredSdkOptions - /** What the child was launched with, as the session's options. */ + /** The chat's options as the session holds them: what was launched, and a Fast the start applies. */ options: Map /** Saved options left out: the provider's own value wins and is re-persisted. */ skipped: readonly string[] } /** - * Checked against the account's cached catalog only, since nothing has been asked yet: a value - * the cache rules out is left out, and with no cache entry every value passes. A value the CLI - * itself would refuse at launch never passes: the start would fail on every reopen. + * Every saved value is passed as the user chose it; the CLI's own answer (a turn's error, the + * settings readback) says what it ran. Only a value no Claude version can parse is left out, by + * the SDK's types, not the installed binary's: one that binary rejects fails its start. */ export function claudeStructuredSpawnOptions(input: { launch: Pick saved: Readonly> | undefined - catalog: AgentModelCatalogEntry | null }): ClaudeStructuredSpawnOptions { const saved = restoredClaudeStructuredSessionOptions(input.saved) - const listed: ListedModel[] = (input.catalog?.models ?? []).map((model) => ({ - ...model, - resolvedModel: null - })) const options = new Map() const skipped: string[] = [] let sdkOptions: ClaudeStructuredSdkOptions = { ...input.launch.options } const model = saved.get('model') if (model !== undefined) { - if (claudeCatalogAdmitsModel(listed, model)) { - options.set('model', model) - sdkOptions.model = model - } else { - skipped.push('model') - } + options.set('model', model) + sdkOptions.model = model } const effort = saved.get('effort') if (effort !== undefined) { - // With no saved model the CLI picks one this launch cannot name, so there is nothing to check. - const { levels } = claudeModelEffortLevels(listed, options.get('model')) - if (isEffortLevel(effort) && (!levels || levels.has(effort))) { + if (isEffortLevel(effort)) { options.set('effort', effort) sdkOptions.effort = effort } else { @@ -93,18 +75,15 @@ export function claudeStructuredSpawnOptions(input: { const fastMode = saved.get('fastMode') if (fastMode !== undefined) { const decoded = decodeStructuredAgentSessionOptionValue('fastMode', fastMode) - const support = claudeModelFastModeSupport(listed, options.get('model')) - if (decoded === true && !input.launch.resumesTranscript) { - // A fresh CLI session may be one whose settings opt in to Fast per session, which only the - // CLI can say; it starts with Fast off rather than carry an opt-in it never made. - } else if ( - typeof decoded !== 'boolean' || - (decoded && listed.length > 0 && support.supported !== true) - ) { + if (typeof decoded !== 'boolean') { skipped.push('fastMode') } else { options.set('fastMode', fastMode) - sdkOptions.settings = { fastMode: decoded } + // A new CLI session may opt in to Fast per session, which only its settings say: the start + // applies a saved Fast on there once it has read them (`applyClaudeFreshSessionFastMode`). + if (!decoded || input.launch.resumesTranscript) { + sdkOptions.settings = { fastMode: decoded } + } } } const permissionMode = saved.get('permissionMode') @@ -126,9 +105,10 @@ export function claudeStructuredSpawnOptions(input: { /** The published session takes on what its child was launched with. */ export function adoptClaudeStructuredSpawnOptions( - session: Pick, + session: Pick, spawn: Pick ): void { + session.launchedModel = spawn.options.get('model') ?? null for (const key of spawn.skipped) { session.restoreSkippedOptions.add(key) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index d1dbf847cb08..653ce8120471 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -148,6 +148,8 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi awaitOptionWritable = (sessionId: string): Promise => this.liveOwnerOrNull(sessionId)?.awaitOptionWritable?.(sessionId) ?? Promise.resolve() + startAnswered = (sessionId: string): boolean | undefined => + this.liveOwnerOrNull(sessionId)?.startAnswered?.(sessionId) readOptions = (input: { sessionId: string; fence: number }) => { const reader = this.owner(input.sessionId).readOptions diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 56bd770f7a5f..4fa7e6e581ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -201,6 +201,8 @@ export type StructuredAgentSessionEndedEvent = { observedAt?: number /** The provider ended before it finished starting, so resuming it would repeat the failure. */ startupUnproven?: true + /** The provider ended before it answered its start, so it ran nothing it was handed. */ + startupUnanswered?: true } /** The child a publish-first acquire handed over has now proven its start: startup facts applied. @@ -217,9 +219,20 @@ export type StructuredAgentSessionStartedEvent = { restoreSkippedOptions: readonly string[] } +/** A running child showed saved options it cannot run, as a model the provider reports missing. + * The record drops them, so the next start uses the provider's own. */ +export type StructuredAgentSessionOptionsSkippedEvent = { + type: 'options-skipped' + sessionId: string + fence: number + acquisitionGeneration: string + keys: readonly string[] +} + export type StructuredAgentSessionLifecycleEvent = | StructuredAgentSessionEndedEvent | StructuredAgentSessionStartedEvent + | StructuredAgentSessionOptionsSkippedEvent /** Whether the provider child behind an acquisition has proven its start. A publish-first * acquire hands over a `starting` child, which already takes input, and the `started` lifecycle @@ -362,6 +375,9 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & ): Promise>> /** Resolves once a live session can take an option write, or after a bound; never rejects. */ awaitOptionWritable?(sessionId: string): Promise + /** False while the live child has not answered its start, so it has run nothing it was handed. + * Absent or undefined reads as answered. */ + startAnswered?(sessionId: string): boolean | undefined readOptions?(input: { sessionId: string; fence: number }): Promise /** Option keys skipped after a provider rejected their persisted restore value. */ readOptionRestoreFailures?(sessionId: string): readonly string[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts index 0022df7c1565..524b4fd4d3f2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts @@ -25,10 +25,16 @@ export async function joinStructuredAgentSessionChildClose( sessionId: string, child: StructuredAgentSessionProviderChild ): Promise { + // Read before the close: once the child is gone the provider can no longer say. + const unanswered = context.deps.adapter.startAnswered?.(sessionId) === false if (!(await closeProviderRoot(context, sessionId))) { return 'unverifiable' } - await context.endExitedChild(sessionId, child, { expected: true, reason: 'closed by Orca' }) + await context.endExitedChild(sessionId, child, { + expected: true, + reason: 'closed by Orca', + ...(unanswered ? { startupUnanswered: true } : {}) + }) context.restartWitness?.stopped(sessionId) return 'exited' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index 11b5750eb359..d76a740d9321 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -40,6 +40,7 @@ export type StructuredAgentSessionChildExit = { /** Host receipt of the exit: the end time of a turn it interrupted. */ observedAt?: number startupUnproven?: true + startupUnanswered?: true } export type StructuredAgentSessionChildExitSession = Pick< @@ -84,7 +85,8 @@ export function settleStructuredAgentSessionChildExit< reason: event.reason, ...(event.failure ? { failure: event.failure } : {}), ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }), - ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}) + ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}), + ...(event.startupUnanswered ? { startupUnanswered: event.startupUnanswered } : {}) }) }) } @@ -117,6 +119,14 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< ? agentSessionFailureFact('hostStopped') : undefined : exit.failure + // What a close settles a send the child was handed and never echoed as, when that send cannot + // have run: a person's Stop of a start, or any close before the child answered its start. + const unrunRejection = + startClose === 'user-stop' + ? agentSessionFailureFact('cancelled') + : expected && exit.startupUnanswered && close?.cause !== 'host-stop' + ? agentSessionFailureFact(close?.cause === 'user-close' ? 'chatClosed' : 'notDelivered') + : undefined const endChild = (): void => { endProviderChild(session, { generation: child.generation, @@ -182,7 +192,7 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< ...(startFailed && child.generation ? { exitedDuringStartup: { generation: child.generation } } : {}), - ...(startClose === 'user-stop' ? { stoppedDuringStartup: true as const } : {}) + ...(unrunRejection ? { unrunRejection } : {}) }) if (!settled.ok) { logExitFailure(context, sessionId, 'exit-settlement', settled.error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts index 7ab0488f0fcc..924465db91e2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts @@ -36,6 +36,8 @@ let host: StructuredAgentSessionHost let adapter: ClaudeStructuredSessionAdapter let store: AgentSessionRecordStore let claude: ReturnType +/** Answers initialize, which the CLI otherwise never does; it echoes nothing either way. */ +let answerInitialize: () => void = () => {} beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-claude-hung-start-stop-')) @@ -65,7 +67,9 @@ beforeEach(async () => { const connection = await openConnection(...args) connection.initializationResult = () => { connection.calls.push({ subtype: 'initialize' }) - return new Promise(() => {}) + return new Promise((resolve) => { + answerInitialize = () => resolve({ models: [] }) + }) } return connection }, @@ -153,3 +157,54 @@ it('writes the message to a start that never answers, and a Stop ends it: stoppe ) expect(await readsWorking()).toBe(false) }) + +async function sendHello(): Promise { + const body = hostTestMessage('hello') + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error('send refused') + } + await vi.waitFor(() => + expect( + claude.connections[0]!.sent.some((message) => JSON.stringify(message).includes('hello')) + ).toBe(true) + ) + return sent.value.clientMessageId +} + +async function submission(id: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) +} + +// A CLI that never answered initialize ran nothing it was handed, however its child ends. +it.each([ + ['user-close', 'The chat closed before this message was sent.'], + ['evict', 'This message was not delivered. Send it again to continue.'] +] as const)( + 'settles a message written to a start that never answered as not sent when a %s ends it', + async (cause, reason) => { + const id = await sendHello() + + await host.close(SESSION, cause) + + await vi.waitFor(async () => + expect(await submission(id)).toMatchObject({ dispatchState: 'rejected', reason }) + ) + } +) + +// Once it answered, the CLI may have taken the message without echoing it yet: a close leaves it in +// doubt, as for any running child. +it('leaves a message in doubt when the start answered and the chat closes before the echo', async () => { + answerInitialize() + await adapter['sessions'].get(SESSION)?.startup.settled + const id = await sendHello() + + await host.close(SESSION, 'user-close') + + await vi.waitFor(async () => + expect(await submission(id)).toMatchObject({ dispatchState: 'unknown' }) + ) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 6eda352decb0..cf7eb2d02301 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -122,8 +122,8 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { /** The provider never finished starting: the start that failed, keyed by the child's * generation. Its row is the one the delivery loop writes for the same start. */ exitedDuringStartup?: { generation: string | null } - /** A person's Stop ended a child that never finished starting: what it was handed is stopped. */ - stoppedDuringStartup?: true + /** A close ended the child before what it was handed could run: each such send is rejected so. */ + unrunRejection?: SubmissionRejectionFact }): Promise { try { const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal) @@ -133,14 +133,14 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { } // A queued message is the delivery loop's to settle: it was never handed to this child. A send // a child still starting was handed and never echoed did not run, and its root is gone: it is - // rejected, with the child's own diagnostic or as the Stop that ended it. A proven child's + // rejected, with the child's own diagnostic or as the close that ended it. A proven child's // handed-over sends stay in doubt. const startupFailure = input.exitedDuringStartup ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) : null - const stopped = input.stoppedDuringStartup ? agentSessionFailureFact('cancelled') : null + const closed = input.unrunRejection const unrun = - startupFailure ?? (stopped && agentSessionFailureWords(stopped, { surface: 'rejection' })) + startupFailure ?? (closed && agentSessionFailureWords(closed, { surface: 'rejection' })) if (!unrun) { await withdrawCodexSendsNoTurnOpenedFor(input.journal, input.fence) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 398d04873d41..2ed6fe511154 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -7,7 +7,10 @@ import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' -import { settleStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' +import { + settleStructuredAgentSessionOptionsSkipped, + settleStructuredAgentSessionProviderStarted +} from './structured-agent-session-provider-started' import { endExitedStructuredAgentSessionChildUnderSerialize, settleStructuredAgentSessionChildExit, @@ -95,6 +98,9 @@ export class StructuredAgentSessionEventRecovery { if (event.type === 'started') { return settleStructuredAgentSessionProviderStarted(this.context, event) } + if (event.type === 'options-skipped') { + return settleStructuredAgentSessionOptionsSkipped(this.context, event) + } await settleStructuredAgentSessionChildExit(this.exitContext, event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index acc2e6488c3d..1b97f1e61e9d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -10,7 +10,10 @@ // asks the provider nothing: the event carries what the child proved. import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' -import type { StructuredAgentSessionStartedEvent } from './structured-agent-session-adapter' +import type { + StructuredAgentSessionOptionsSkippedEvent, + StructuredAgentSessionStartedEvent +} from './structured-agent-session-adapter' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession @@ -80,3 +83,42 @@ async function persistStartedOptions( now: context.now() }) } + +/** A running child showed saved options it cannot run: the record drops them, as a start that + * skipped them would, so the next start runs the provider's own. Reported, never thrown. */ +export function settleStructuredAgentSessionOptionsSkipped( + context: StructuredAgentSessionProviderStartedContext, + event: StructuredAgentSessionOptionsSkippedEvent +): Promise { + return context.serialize(event.sessionId, async () => { + const { store } = context.deps + const record = store.getRecord(event.sessionId) + if ( + !record?.options || + record.lease.runtimeFence !== event.fence || + !agentSessionLeaseAdmitsWriter(record.lease) + ) { + return + } + const options = { ...record.options } + for (const key of event.keys) { + delete options[key] + } + try { + await store.replaceSessionOptions({ + sessionId: event.sessionId, + fence: event.fence, + options, + now: context.now() + }) + } catch (error) { + context.deps.logger.warn('dropping a saved option the provider cannot run failed', { + scope: 'provider-options-skipped', + sessionId: event.sessionId, + error + }) + } finally { + context.publishStatus?.(event.sessionId) + } + }) +} diff --git a/src/main/runtime/claude-structured-retired-saved-model.test.ts b/src/main/runtime/claude-structured-retired-saved-model.test.ts new file mode 100644 index 000000000000..e92f9b08ae50 --- /dev/null +++ b/src/main/runtime/claude-structured-retired-saved-model.test.ts @@ -0,0 +1,78 @@ +// A chat's saved model rides the launch unchecked. When the CLI answers a turn by saying that model +// does not exist, the record drops it, so the next start runs the CLI's own default; the user's +// message and the CLI's own answer stay as they are. Against the production runtime, adapter, +// record store and host, with only the CLI process scripted. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' + +const SESSION = 'claude-retired-saved-model' +const CALLER = { callerKey: 'client-1' } +const RETIRED = 'claude-retired-1' + +let claude = createScriptedClaudeRuntime([SESSION]) + +afterEach(async () => { + await claude.dispose() + claude = createScriptedClaudeRuntime([SESSION]) +}) + +/** The reply Claude Code 2.1.280 sends when its `--model` names a model that does not exist. */ +function modelNotFoundReply(uuid: string, parentToolUseId: string | null = null) { + return { + type: 'assistant', + uuid, + session_id: claudeSessionIdForOrcaSession(SESSION), + parent_tool_use_id: parentToolUseId, + error: 'model_not_found', + is_api_error_message: true, + message: { + model: '', + role: 'assistant', + content: [ + { + type: 'text', + text: `There's an issue with the selected model (${RETIRED}). It may not exist or you may not have access to it.` + } + ] + } + } +} + +async function startedWith(options: Record): Promise { + claude.behave(SESSION, { sendsNoStartFrame: true }) + const host = await claude.install() + await expect( + host.attach(CALLER, claude.attachParams(SESSION, null, { options })) + ).resolves.toMatchObject({ ok: true }) + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + ) + return host +} + +describe("a Claude chat whose saved model the CLI says doesn't exist", () => { + it('drops the model from the record, keeping the rest, so the next start runs the default', async () => { + const host = await startedWith({ model: RETIRED, effort: 'high' }) + expect(claude.child(SESSION).launch.options.model).toBe(RETIRED) + expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED) + + claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) + + await vi.waitFor(() => + expect(host.deps.store.getRecord(SESSION)?.options).not.toHaveProperty('model') + ) + expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({ effort: 'high' }) + }) + + it("keeps a model the reply did not come from: a subagent's own error", async () => { + const host = await startedWith({ model: RETIRED }) + + claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1', 'toolu_subagent')) + await host.flushStreamedEvents(SESSION) + + expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED) + }) +}) diff --git a/src/main/runtime/structured-agent-session-lifecycle-delivery.ts b/src/main/runtime/structured-agent-session-lifecycle-delivery.ts index ba307c2cfb0a..f228b96ce35c 100644 --- a/src/main/runtime/structured-agent-session-lifecycle-delivery.ts +++ b/src/main/runtime/structured-agent-session-lifecycle-delivery.ts @@ -3,9 +3,9 @@ // Exit recovery runs on one chain so teardown can drain it: exit callbacks arrive from child // process tasks, and a fire-and-forget one could otherwise append after the host flushed and // removed its journal directory. That chain orders nothing across sessions, and a recovery on it -// can run a whole reacquisition, so `started` and the end of a close the host asked for stay off -// it: each takes only its own session's serialized step, and is tracked here so the same drain -// still waits for it. +// can run a whole reacquisition, so `started`, a dropped saved option and the end of a close the +// host asked for stay off it: each takes only its own session's serialized step, and is tracked +// here so the same drain still waits for it. import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' @@ -27,7 +27,7 @@ export function createStructuredAgentSessionLifecycleDelivery(input: { await input.handle(event) } catch (error) { input.logger.warn(`delivering a provider ${event.type} event to the host failed`, { - scope: event.type === 'started' ? 'lifecycle-started' : 'lifecycle-exit', + scope: event.type === 'ended' ? 'lifecycle-exit' : `lifecycle-${event.type}`, sessionId: event.sessionId, error }) @@ -35,7 +35,7 @@ export function createStructuredAgentSessionLifecycleDelivery(input: { } return { deliver: (event) => { - if (event.type === 'started' || event.cause === 'requested-close') { + if (event.type !== 'ended' || event.cause === 'requested-close') { // Called now, so the step is queued on its own session's lane, behind nothing of another's. const settling = settle(event) settlingStarts.add(settling) diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 8b4b70f8bc2b..8f559882ebf4 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -46,7 +46,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { export function structuredClaudeLifecycleEvent( event: ClaudeStructuredSessionEvent ): StructuredAgentSessionLifecycleEvent | null { - if (event.type === 'started') { + if (event.type === 'started' || event.type === 'options-skipped') { return event } // Every exit of a child with an identity, expected or not: the host ends that child's record. @@ -66,7 +66,8 @@ export function structuredClaudeLifecycleEvent( acquisitionGeneration: event.acquisitionGeneration, // The instant the translator ended the open turn at; the host reads the exit's turn by it. ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }), - ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}) + ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}), + ...(event.startupUnanswered ? { startupUnanswered: event.startupUnanswered } : {}) } } return null From 5b280f66d08ffabaae070f9eb50e6772a19c9900 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:50:38 -0700 Subject: [PATCH 12/22] fix(native-chat): type the close's ended event and the start-exit test fixtures The close's ended event is typed as the adapter event so its optional startupUnanswered spread fits exactOptionalPropertyTypes; two tests guard the fixture's optional generation, and the hung-start fixture records initialize on the fake connection it holds. --- src/main/claude/claude-structured-session-close.ts | 6 +++--- .../structured-agent-session-accept-then-deliver.test.ts | 6 +++++- .../structured-agent-session-claude-hung-start-stop.test.ts | 3 ++- .../structured-agent-session-provider-child-record.test.ts | 6 +++++- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 341829926e62..0bebd7c67156 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -141,7 +141,7 @@ async function finalizeClaudePublishedSession( } if (!session.closeEnded) { session.closeEnded = true - const ended = { + const ended: ClaudeStructuredSessionEvent = { type: 'ended', sessionId: input.sessionId, reason: 'claude session closed', @@ -150,8 +150,8 @@ async function finalizeClaudePublishedSession( fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, observedAt: Date.now(), - ...(session.startup.answered ? {} : { startupUnanswered: true }) - } as const + ...(session.startup.answered ? {} : { startupUnanswered: true as const }) + } try { try { session.translator?.handle(ended) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index bd286bf6c220..2665c2af14d2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -575,11 +575,15 @@ describe('a child that exits before its message is handed over', () => { // then, lands before the handover step. acquire.mockImplementation(async (input) => { const child = await spawnChild(input) + const { acquisitionGeneration } = child + if (acquisitionGeneration === undefined) { + throw new Error('spawnChild always names a generation') + } void host.handleAdapterEvent({ type: 'ended', sessionId: SESSION, fence: input.fence, - acquisitionGeneration: child.acquisitionGeneration, + acquisitionGeneration, reason: 'codex app-server crashed', cause: 'unexpected-exit' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts index 924465db91e2..9c213d6731e5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts @@ -65,8 +65,9 @@ beforeEach(async () => { onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), openConnection: async (...args) => { const connection = await openConnection(...args) + const fake = claude.connections.at(-1) connection.initializationResult = () => { - connection.calls.push({ subtype: 'initialize' }) + fake?.calls.push({ subtype: 'initialize' }) return new Promise((resolve) => { answerInitialize = () => resolve({ models: [] }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index c5b2e90754ae..ab8abf7f8862 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -518,11 +518,15 @@ describe('a child that ends before its message is handed over', () => { // then, lands before the handover step. acquire.mockImplementation(async (input) => { const child = await spawnChild(input) + const { acquisitionGeneration } = child + if (acquisitionGeneration === undefined) { + throw new Error('spawnChild always names a generation') + } void host.handleAdapterEvent({ type: 'ended', sessionId: SESSION, fence: input.fence, - acquisitionGeneration: child.acquisitionGeneration, + acquisitionGeneration, reason: 'codex app-server crashed', failure: { kind: 'providerExited', From 70aed7ca83775e07c0c438ccb36cd9af6f9a3c38 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:27:30 -0700 Subject: [PATCH 13/22] fix(native-chat): a Claude model heal keeps a later pick, a refused Fast is dropped, no allow flag - `options-skipped` carries the retired value; the record drops it only while it still holds it. - `started` carries the values a heal retired, and the record does not take them back from the CLI's report of the same value. - A saved Fast on a new conversation is applied before `started`: a refusal drops it and records it skipped, as main's refused restore did; silence keeps it wanted and unconfirmed. - A saved narrower mode under an Agent Permissions bypass launches without any bypass flag again: the allow flag is one older CLIs reject at start. Kept as a known limit. - The real-CLI test asserts the message was written before initialize answered. - The fake reports a launch effort only under `applied`, and a misplaced doc comment moves back. --- .../claude-structured-launch-resolution.ts | 12 +--- .../claude/claude-structured-real-cli.test.ts | 28 +++++---- .../claude-structured-retired-model.test.ts | 6 +- .../claude/claude-structured-retired-model.ts | 20 ++++-- .../claude-structured-session-adapter.test.ts | 40 ++++++++++++ .../claude-structured-session-adapter.ts | 6 +- .../claude-structured-session-options.ts | 16 ++--- .../claude-structured-session-startup.ts | 32 +++++++--- .../claude-structured-session-test-support.ts | 11 ++-- .../claude-structured-spawn-options.test.ts | 12 ++-- .../structured-agent-session-adapter.ts | 5 +- ...ctured-agent-session-option-restoration.ts | 10 ++- ...ructured-agent-session-provider-started.ts | 8 ++- ...ude-structured-retired-saved-model.test.ts | 63 +++++++++++++++++++ 14 files changed, 203 insertions(+), 66 deletions(-) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index ae95dd3486da..8673762f9677 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -108,15 +108,9 @@ export function claudeStructuredOptionsWithPermissionMode( if (mode === 'bypassPermissions') { return options } - const { 'dangerously-skip-permissions': bypass, ...extraArgs } = options.extraArgs ?? {} - return { - ...options, - permissionMode: mode, - // The bypass flag would start the child in bypass. The allow flag starts it in `mode` and - // keeps bypass reachable, as the Agent Permissions setting grants (verified on 2.1.280). - ...(bypass !== undefined ? { allowDangerouslySkipPermissions: true } : {}), - extraArgs - } + // Known limit: no switch back to bypass later; that needs the allow flag older CLIs reject. + const { 'dangerously-skip-permissions': _bypass, ...extraArgs } = options.extraArgs ?? {} + return { ...options, permissionMode: mode, extraArgs } } export type ClaudeStructuredLaunch = { diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index 6846709673e8..0e9345537bb1 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -365,18 +365,22 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { spawnToken: 'real-cli-saved-options', options: { model: 'sonnet', permissionMode: 'plan', effort: 'low' } }) - await expect( - adapter.dispatch({ - sessionId: 'real-cli-handshake', - clientMessageId: 'real-cli-saved-options-1', - body: { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: 'Reply with exactly: OK. Do not use any tools.' }] - }, - fence: 1 - }) - ).resolves.toEqual({ state: 'admitted' }) + const dispatched = await adapter.dispatch({ + sessionId: 'real-cli-handshake', + clientMessageId: 'real-cli-saved-options-1', + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Reply with exactly: OK. Do not use any tools.' }] + }, + fence: 1 + }) + // Read as the write resolves: the CLI had not answered initialize, so nothing held it. + const answeredAtDispatch = adapter['sessions'].get('real-cli-handshake')?.startup.answered + const startedAtDispatch = events.some((event) => event.type === 'started') + expect(dispatched).toEqual({ state: 'admitted' }) + expect(answeredAtDispatch).toBe(false) + expect(startedAtDispatch).toBe(false) const deadline = Date.now() + 90_000 while (!messages().some((m) => m.type === 'result') && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 250)) diff --git a/src/main/claude/claude-structured-retired-model.test.ts b/src/main/claude/claude-structured-retired-model.test.ts index 51d7e951ed91..b6eaac6741ff 100644 --- a/src/main/claude/claude-structured-retired-model.test.ts +++ b/src/main/claude/claude-structured-retired-model.test.ts @@ -59,10 +59,10 @@ describe("the CLI's word that a launched model does not exist", () => { it('drops the launched model once, as a skipped saved option', () => { const session = sessionLaunchedWith(RETIRED) - expect(retireClaudeLaunchedModel(session, modelNotFound())).toBe(true) + expect(retireClaudeLaunchedModel(session, modelNotFound())).toBe(RETIRED) expect(session.options.has('model')).toBe(false) expect([...session.restoreSkippedOptions]).toEqual(['model']) - expect(retireClaudeLaunchedModel(session, modelNotFound())).toBe(false) + expect(retireClaudeLaunchedModel(session, modelNotFound())).toBeNull() }) it.each([ @@ -71,7 +71,7 @@ describe("the CLI's word that a launched model does not exist", () => { ['a model picked since the launch', sessionLaunchedWith(RETIRED, 'opus'), modelNotFound()], ['a launch that named no model', sessionLaunchedWith(null), modelNotFound()] ])('keeps the saved model for %s', (_case, session, message) => { - expect(retireClaudeLaunchedModel(session, message)).toBe(false) + expect(retireClaudeLaunchedModel(session, message)).toBeNull() expect([...session.restoreSkippedOptions]).toEqual([]) }) }) diff --git a/src/main/claude/claude-structured-retired-model.ts b/src/main/claude/claude-structured-retired-model.ts index f04f889a05a4..b418ab451efd 100644 --- a/src/main/claude/claude-structured-retired-model.ts +++ b/src/main/claude/claude-structured-retired-model.ts @@ -5,12 +5,12 @@ import type { AgentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import type { ClaudeSession } from './claude-structured-session-state' -/** True once, when a root reply says the model this child was launched with does not exist and - * the chat still has that model picked: the session stops holding it, so the record drops it. */ +/** The launched model, once, when a root reply says it does not exist and the chat still has it + * picked: the session stops holding it, so the record drops it. Null otherwise. */ export function retireClaudeLaunchedModel( session: Pick, message: Record -): boolean { +): string | null { const launched = session.launchedModel if ( launched === null || @@ -19,11 +19,21 @@ export function retireClaudeLaunchedModel( message.error !== 'model_not_found' || session.options.get('model') !== launched ) { - return false + return null } session.options.delete('model') session.restoreSkippedOptions.add('model') - return true + return launched +} + +/** The saved values this child showed it cannot run. Only a retired launch model is one: a launch + * never skips a model, so a skipped model is that. */ +export function claudeRetiredOptions( + session: Pick +): { retiredOptions?: Record } { + return session.restoreSkippedOptions.has('model') && session.launchedModel !== null + ? { retiredOptions: { model: session.launchedModel } } + : {} } /** The rows of a listing the account's catalog keeps. A child launched with `--model X` lists X diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 8b60cce0015b..9317b0490f05 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -8,6 +8,10 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity' +import { + ClaudeControlRequestError, + ClaudeControlRequestTimeoutError +} from './claude-agent-sdk-control-requests' import type { ClaudeStructuredSessionAdapter, ClaudeStructuredSessionEvent @@ -164,6 +168,42 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { }) }) + it.each([ + ['refuses', new ClaudeControlRequestError('apply_flag_settings', 'not for this org'), false], + ['never answers', new ClaudeControlRequestTimeoutError('apply_flag_settings'), true] + ] as const)( + 'settles a saved Fast on a new conversation when the CLI %s its apply', + async (_case, failure, kept) => { + const claude = fakeClaude({ + settings: { applied: {}, effective: {}, sources: {} }, + initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }], + routes: { + apply_flag_settings: () => { + throw failure + } + } + }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + options: { model: 'opus', fastMode: 'true' } + }) + + // A refusal drops the pick, as main's refused restore did; silence keeps it, unconfirmed. + expect(events.find((event) => event.type === 'started')).toMatchObject({ + reportedOptions: kept ? { fastMode: true } : { model: 'opus' }, + restoreSkippedOptions: kept ? [] : ['fastMode'] + }) + const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(options.current.fastMode === true).toBe(kept) + expect(options.current.confirmed ?? []).not.toContain('fastMode') + } + ) + it('drops a saved Fast on from a new conversation whose model the CLI lists without it', async () => { const claude = fakeClaude({ settings: { applied: {}, effective: {}, sources: {} }, diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 4c520573f24e..aa877f0a4426 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -161,13 +161,15 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } session?.translator?.handle(event) this.deps.onEvent?.(event) - if (event.type === 'message' && session && retireClaudeLaunchedModel(session, event.message)) { + const retired = + event.type === 'message' && session ? retireClaudeLaunchedModel(session, event.message) : null + if (session && retired !== null) { this.deps.onEvent?.({ type: 'options-skipped', sessionId: event.sessionId, fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, - keys: ['model'] + options: { model: retired } }) } this.publishChildWork(event.sessionId, session, event.type === 'message' ? event.message : null) diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 1e4eea0f95cc..24f89e29eee9 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -123,14 +123,6 @@ export function readClaudeCurrentModel(session: ClaudeSession): { } } -/** - * The effort levels the session's current model advertises, with the catalog id - * that matched so a refusal names the model the pill shows. Levels are null when - * nothing identified the model: `apply_flag_settings` accepts and stores any - * level for a model with no effort control, so the catalog is the only evidence - * of a refusal — and an absent or unlisted one is not evidence, or a live CLI - * that predates `list_models` would have every effort refused under it. - */ /** One catalog read serves a whole option write. The admit check, the effort guard * and the Fast guard all ask about the same list; each taking its own read made a * single model write pay for two `list_models` round trips and let two guards answer @@ -144,6 +136,14 @@ export async function readClaudeListedModels( return catalog ? listedModels({ models: catalog }) : [] } +/** + * The effort levels the session's current model advertises, with the catalog id + * that matched so a refusal names the model the pill shows. Levels are null when + * nothing identified the model: `apply_flag_settings` accepts and stores any + * level for a model with no effort control, so the catalog is the only evidence + * of a refusal — and an absent or unlisted one is not evidence, or a live CLI + * that predates `list_models` would have every effort refused under it. + */ export function claudeModelEffortLevels( session: ClaudeSession, models: readonly ListedModel[] diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index 01a4bb3e1472..026730181e09 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -5,6 +5,7 @@ import type { StructuredAgentSessionStartedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' +import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' import { claudeAuthDiagnostic, @@ -19,6 +20,7 @@ import { readClaudeStructuredSessionSettings } from './claude-structured-session-acquisition-options' import { listedModels } from './claude-structured-model-catalog' +import { claudeRetiredOptions } from './claude-structured-retired-model' import { claudeModelFastModeSupport, claudeStructuredSessionOptionsFrom, @@ -156,9 +158,13 @@ export async function readClaudeStartupFacts(input: { } /** A new conversation is launched without a saved Fast on, which its settings may opt in to per - * session. Those settings now read: an opt-in drops it, as before; otherwise it is applied, and - * no message waits on that. */ -function applyClaudeFreshSessionFastMode(session: ClaudeSession, facts: ClaudeStartupFacts): void { + * session. Those settings now read: an opt-in drops it, as before; otherwise it is applied before + * `started`, and no message waits on that. A refusal drops it as main's restore did; silence keeps + * it wanted and unconfirmed. */ +async function applyClaudeFreshSessionFastMode( + session: ClaudeSession, + facts: ClaudeStartupFacts +): Promise { if (facts.resumesTranscript || session.options.get('fastMode') !== 'true') { return } @@ -179,11 +185,18 @@ function applyClaudeFreshSessionFastMode(session: ClaudeSession, facts: ClaudeSt session.restoreSkippedOptions.add('fastMode') return } - void session.connection - .applyFlagSettings({ fastMode: true }, { timeoutMs: facts.requestTimeoutMs }) - .catch((error: unknown) => - console.warn('[claude-structured] applying a saved Fast on to a new session failed:', error) + try { + await session.connection.applyFlagSettings( + { fastMode: true }, + { timeoutMs: facts.requestTimeoutMs } ) + } catch (error) { + if (error instanceof ClaudeControlRequestError) { + session.options.delete('fastMode') + session.restoreSkippedOptions.add('fastMode') + } + session.confirmedOptions.delete('fastMode') + } } function applyClaudeStartupFacts(session: ClaudeSession, facts: ClaudeStartupFacts): void { @@ -274,7 +287,7 @@ export async function settleClaudeSessionStartup(input: { } } applyClaudeStartupFacts(session, facts) - applyClaudeFreshSessionFastMode(session, facts) + await applyClaudeFreshSessionFastMode(session, facts) if (!superseded()) { input.onStarted({ // `list_models` is answered from this same initialize result, so nothing is re-read. @@ -282,7 +295,8 @@ export async function settleClaudeSessionStartup(input: { session, readClaudeModels(facts.initialization) ), - restoreSkippedOptions: [...session.restoreSkippedOptions] + restoreSkippedOptions: [...session.restoreSkippedOptions], + ...claudeRetiredOptions(session) }) if (session.startup.state === 'pending') { session.startup.state = 'proven' diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index de8ab6a82e92..e8b07b7218a1 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -161,14 +161,13 @@ export function fakeClaude( }, getSettings: async () => { connection.calls.push({ subtype: 'get_settings' }) - // Shape measured from Claude Code 2.1.258: {applied, effective, sources}, - // and the only place the session's current effort is reported. A launch `--effort` - // shows only in `applied`; `effective` holds the settings files' own (2.1.280). - const effort = launch.options.effort ?? 'high' + // Shape measured from Claude Code 2.1.258: {applied, effective, sources}. A launch + // `--effort` shows only in `applied`, with no `effective.effortLevel` (2.1.280). + const launched = launch.options.effort return ( options.settings ?? { - applied: { model: 'claude-sonnet-5', effort, advisor: null, ultracode: false }, - effective: { model: 'claude-sonnet-5', effortLevel: 'high', env: {} }, + applied: { model: 'claude-sonnet-5', effort: launched ?? 'high', ultracode: false }, + effective: { model: 'claude-sonnet-5', ...(launched ? {} : { effortLevel: 'high' }) }, sources: {} } ) diff --git a/src/main/claude/claude-structured-spawn-options.test.ts b/src/main/claude/claude-structured-spawn-options.test.ts index f82411770d26..26aefc0ca6a3 100644 --- a/src/main/claude/claude-structured-spawn-options.test.ts +++ b/src/main/claude/claude-structured-spawn-options.test.ts @@ -108,16 +108,12 @@ describe('a Claude chat launched with its saved options', () => { expect(spawn.skipped).toEqual(['permissionMode']) }) - // Measured on Claude Code 2.1.280: `--permission-mode plan --allow-dangerously-skip-permissions` - // starts in plan and takes `set_permission_mode bypassPermissions` later; without the allow flag - // that write is refused. - it('starts a saved narrower mode under an Agent Permissions bypass with bypass still reachable', () => { + // Known limit: the allow flag that would keep bypass reachable is one older CLIs reject at start. + it('starts a saved narrower mode under an Agent Permissions bypass without any bypass flag', () => { const spawn = launched({ permissionMode: 'acceptEdits' }, { base: BYPASS_LAUNCH }) - expect(spawn.sdkOptions).toMatchObject({ - permissionMode: 'acceptEdits', - allowDangerouslySkipPermissions: true - }) + expect(spawn.sdkOptions.permissionMode).toBe('acceptEdits') + expect(spawn.sdkOptions).not.toHaveProperty('allowDangerouslySkipPermissions') expect(spawn.sdkOptions.extraArgs).toEqual({ 'replay-user-messages': null }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 4fa7e6e581ee..f7f64554c26c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -217,6 +217,8 @@ export type StructuredAgentSessionStartedEvent = { reportedOptions: AgentSessionOptionsResult['current'] /** Saved options the child could not take; the host drops them rather than persist them. */ restoreSkippedOptions: readonly string[] + /** Values the child showed it cannot run: a report naming the same value is not persisted. */ + retiredOptions?: Readonly> } /** A running child showed saved options it cannot run, as a model the provider reports missing. @@ -226,7 +228,8 @@ export type StructuredAgentSessionOptionsSkippedEvent = { sessionId: string fence: number acquisitionGeneration: string - keys: readonly string[] + /** Each saved value the child showed it cannot run; a record holding another value keeps it. */ + options: Readonly> } export type StructuredAgentSessionLifecycleEvent = diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts index d3461a7e9ee3..de9200a16c75 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts @@ -26,6 +26,8 @@ export function nativeSessionOptionsFromReport(input: { reported: AgentSessionOptionsResult['current'] restoreSkipped: readonly string[] priorOptions?: Readonly> + /** Values the child showed it cannot run: its report of the same value is not kept. */ + retired?: Readonly> }): Readonly> { const { reported, priorOptions } = input const restored = priorOptions ? { ...priorOptions } : {} @@ -39,10 +41,16 @@ export function nativeSessionOptionsFromReport(input: { reported.fastMode === undefined ? undefined : encodeStructuredAgentSessionOptionValue('fastMode', reported.fastMode) - return { + const options: Record = { ...restored, model: reported.model, ...(reported.effort ? { effort: reported.effort } : {}), ...(fastMode !== undefined && fastMode !== null ? { fastMode } : {}) } + for (const [key, value] of Object.entries(input.retired ?? {})) { + if (options[key] === value) { + delete options[key] + } + } + return options } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index 1b97f1e61e9d..589bf37c2616 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -78,6 +78,7 @@ async function persistStartedOptions( options: nativeSessionOptionsFromReport({ reported: event.reportedOptions, restoreSkipped: event.restoreSkippedOptions, + ...(event.retiredOptions ? { retired: event.retiredOptions } : {}), ...(record.options ? { priorOptions: record.options } : {}) }), now: context.now() @@ -101,8 +102,11 @@ export function settleStructuredAgentSessionOptionsSkipped( return } const options = { ...record.options } - for (const key of event.keys) { - delete options[key] + for (const [key, value] of Object.entries(event.options)) { + // A pick made since the child launched is the user's, whatever the child showed. + if (options[key] === value) { + delete options[key] + } } try { await store.replaceSessionOptions({ diff --git a/src/main/runtime/claude-structured-retired-saved-model.test.ts b/src/main/runtime/claude-structured-retired-saved-model.test.ts index e92f9b08ae50..138a240984dc 100644 --- a/src/main/runtime/claude-structured-retired-saved-model.test.ts +++ b/src/main/runtime/claude-structured-retired-saved-model.test.ts @@ -4,6 +4,7 @@ // record store and host, with only the CLI process scripted. import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' @@ -75,4 +76,66 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED) }) + + // The CLI's word is about the model it was launched with; a pick made since is the user's. + it('keeps a model the user picked while the heal was on its way', async () => { + const host = await startedWith({ model: RETIRED }) + const record = () => host.deps.store.getRecord(SESSION) + const changed = await host.setOption(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: `${Date.now()}-${'1'.padStart(32, '0')}`, + expectedRuntimeFence: record()?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.setOption', + sessionId: SESSION, + fields: { key: 'model', value: 'opus' } + }) + }, + key: 'model', + value: 'opus' + }) + expect(changed, JSON.stringify(changed)).toMatchObject({ ok: true }) + expect(record()?.options?.model).toBe('opus') + + await host.handleAdapterEvent({ + type: 'options-skipped', + sessionId: SESSION, + fence: record()?.lease.runtimeFence ?? 0, + acquisitionGeneration: 'generation-heal', + options: { model: RETIRED } + }) + + expect(record()?.options?.model).toBe('opus') + }) + + // A slow settings readback lets the turn's error land first; `started` then reports the CLI's + // own model, which is still the retired one, and must not write it back. + it('does not let a start that lands after the heal write the retired model back', async () => { + claude.behave(SESSION, { + sendsNoStartFrame: true, + startupSettingsReadHangs: true, + controlTimeoutMs: 300 + }) + const host = await claude.install() + await expect( + host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: RETIRED } })) + ).resolves.toMatchObject({ ok: true }) + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') + ) + claude.child(SESSION).handlers.onMessage?.({ + type: 'system', + subtype: 'init', + session_id: claudeSessionIdForOrcaSession(SESSION), + model: RETIRED + }) + claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) + + await vi.waitFor( + () => expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready'), + { timeout: 5_000 } + ) + expect(host.deps.store.getRecord(SESSION)?.options).not.toHaveProperty('model') + }) }) From 5ee4b8447eff6be7f281acb8bf11244c9f560b72 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:20:54 -0700 Subject: [PATCH 14/22] fix(native-chat): a new Claude chat reports started before its saved Fast is applied The Fast apply on a new conversation now runs after `started`, so a Stop interrupts a running first turn and an option write is not refused while the round trip is out. A refusal drops the pick through `options-skipped`, in order after `started`; silence keeps it unconfirmed. The launch's unreachable skipped-model branch is gone. --- ...-structured-options-context-window.test.ts | 7 +- .../claude-structured-session-acquisition.ts | 7 +- .../claude-structured-session-adapter.test.ts | 22 +++++-- .../claude-structured-session-startup.ts | 64 ++++++++++++++----- .../claude-structured-session-test-support.ts | 2 +- .../claude/claude-structured-spawn-options.ts | 5 +- 6 files changed, 76 insertions(+), 31 deletions(-) diff --git a/src/main/claude/claude-structured-options-context-window.test.ts b/src/main/claude/claude-structured-options-context-window.test.ts index 5042d5a990c4..c20b73bd37ee 100644 --- a/src/main/claude/claude-structured-options-context-window.test.ts +++ b/src/main/claude/claude-structured-options-context-window.test.ts @@ -53,14 +53,17 @@ describe('the context ring after a session option write', () => { expect(s.respond('turn-b', 2_000)).toMatchObject({ windowTokens: 1_000_000, percentage: 10 }) }) - it('implies no window for a model the child refused or the launch left out', async () => { + it('implies no window for a model the child refused or a mode the launch left out', async () => { const refused = ringSession() refused.setModel.mockRejectedValueOnce(new ClaudeControlRequestError('set_model', 'refused')) await expect(refused.write('model', 'opus[1m]')).rejects.toThrow() expect(refused.respond('turn-a', 1_000)).toBeNull() const skipped = ringSession() - adoptClaudeStructuredSpawnOptions(skipped.session, { options: new Map(), skipped: ['model'] }) + adoptClaudeStructuredSpawnOptions(skipped.session, { + options: new Map([['model', 'sonnet[1m]']]), + skipped: ['permissionMode'] + }) expect(skipped.respond('turn-a', 1_000)).toBeNull() }) diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index bf1f19c393d0..29b3ff0317b6 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -287,13 +287,12 @@ export async function acquireClaudeSession({ }), isCurrent: () => sessions.get(sessionId) === session, fault: (error) => callbacks.handleExit(sessionId, attempt, error), - onStarted: (options) => + report: (event) => emit({ - type: 'started', + ...event, sessionId, fence: input.fence, - acquisitionGeneration: session.acquisitionGeneration, - ...options + acquisitionGeneration: session.acquisitionGeneration }) }) ]) diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 9317b0490f05..cc59dfde3017 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -174,13 +174,12 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { ] as const)( 'settles a saved Fast on a new conversation when the CLI %s its apply', async (_case, failure, kept) => { + let answerApply: (error: Error) => void = () => {} const claude = fakeClaude({ settings: { applied: {}, effective: {}, sources: {} }, initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }], routes: { - apply_flag_settings: () => { - throw failure - } + apply_flag_settings: () => new Promise((_, reject) => (answerApply = reject)) } }) const events: ClaudeStructuredSessionEvent[] = [] @@ -193,11 +192,22 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { options: { model: 'opus', fastMode: 'true' } }) - // A refusal drops the pick, as main's refused restore did; silence keeps it, unconfirmed. + // Started, with the pick, while the apply is still unanswered: nothing waits on it. + expect(claude.connections[0].calls.at(-1)?.subtype).toBe('apply_flag_settings') expect(events.find((event) => event.type === 'started')).toMatchObject({ - reportedOptions: kept ? { fastMode: true } : { model: 'opus' }, - restoreSkippedOptions: kept ? [] : ['fastMode'] + reportedOptions: { fastMode: true }, + restoreSkippedOptions: [] }) + answerApply(failure) + await tick() + + // A refusal drops the pick, as main's refused restore did, from the record too, after + // `started`; silence keeps it wanted and unconfirmed. + const skipped = events.filter((event) => event.type === 'options-skipped') + expect(skipped).toEqual( + kept ? [] : [expect.objectContaining({ fence: 7, options: { fastMode: 'true' } })] + ) + expect(adapter.readOptionRestoreFailures('session-1')).toEqual(kept ? [] : ['fastMode']) const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) expect(options.current.fastMode === true).toBe(kept) expect(options.current.confirmed ?? []).not.toContain('fastMode') diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index 026730181e09..94470828a04c 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -3,7 +3,10 @@ // input at once. Every way the start can fail (exit, auth, a foreign session id) faults the // published session through its exit path; a CLI that never answers is ended by a Stop or a close. -import type { StructuredAgentSessionStartedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + StructuredAgentSessionOptionsSkippedEvent, + StructuredAgentSessionStartedEvent +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' @@ -90,9 +93,14 @@ export function createClaudeInitProof(): ClaudeInitProof { export type StructuredAgentSessionStartedOptions = Pick< StructuredAgentSessionStartedEvent, - 'reportedOptions' | 'restoreSkippedOptions' + 'reportedOptions' | 'restoreSkippedOptions' | 'retiredOptions' > +/** A lifecycle event the start reports, before the session's identity is stamped on it. */ +export type ClaudeStartupReport = + | ({ type: 'started' } & StructuredAgentSessionStartedOptions) + | Pick + export type ClaudeStartupFacts = { init: ClaudeInitObservation | null initProof: ClaudeInitProof @@ -158,21 +166,20 @@ export async function readClaudeStartupFacts(input: { } /** A new conversation is launched without a saved Fast on, which its settings may opt in to per - * session. Those settings now read: an opt-in drops it, as before; otherwise it is applied before - * `started`, and no message waits on that. A refusal drops it as main's restore did; silence keeps - * it wanted and unconfirmed. */ -async function applyClaudeFreshSessionFastMode( + * session. Those settings now read: an opt-in drops it, as before, and so do the guards a live + * Fast write takes. True when it is still to be applied. */ +function admitClaudeFreshSessionFastMode( session: ClaudeSession, facts: ClaudeStartupFacts -): Promise { +): boolean { if (facts.resumesTranscript || session.options.get('fastMode') !== 'true') { - return + return false } if (facts.prepared.fastModePerSessionOptIn === true) { session.options.delete('fastMode') - return + return false } - // The guards a live Fast write takes, over the listing this start already holds. + // Over the listing this start already holds. const listed = listedModels({ models: readClaudeModels(facts.initialization) }) const blocked = session.fastModeDisabledReason !== undefined && @@ -183,19 +190,35 @@ async function applyClaudeFreshSessionFastMode( ) { session.options.delete('fastMode') session.restoreSkippedOptions.add('fastMode') - return + return false } + return true +} + +/** Applies a new conversation's saved Fast on after `started`, so nothing waits on it. A refusal + * drops it as main's refused restore did, from the record too; silence keeps it wanted and + * unconfirmed. A write the user made meanwhile owns the option. */ +async function applyClaudeFreshSessionFastMode( + session: ClaudeSession, + facts: ClaudeStartupFacts, + report: (event: ClaudeStartupReport) => void +): Promise { + const sequence = session.optionMutationSequence try { await session.connection.applyFlagSettings( { fastMode: true }, { timeoutMs: facts.requestTimeoutMs } ) } catch (error) { + if (sequence !== session.optionMutationSequence) { + return + } + session.confirmedOptions.delete('fastMode') if (error instanceof ClaudeControlRequestError) { session.options.delete('fastMode') session.restoreSkippedOptions.add('fastMode') + report({ type: 'options-skipped', options: { fastMode: 'true' } }) } - session.confirmedOptions.delete('fastMode') } } @@ -263,8 +286,9 @@ export async function settleClaudeSessionStartup(input: { facts: Promise isCurrent: () => boolean fault: (error: Error) => void - /** Startup has proven; `options` is what the child now reports, snapshotted from memory. */ - onStarted: (options: StructuredAgentSessionStartedOptions) => void + /** `started` once startup has proven, with what the child now reports, snapshotted from memory; + * then any saved option the child showed it cannot run. */ + report: (event: ClaudeStartupReport) => void }): Promise { const { session } = input const superseded = (): boolean => { @@ -287,9 +311,10 @@ export async function settleClaudeSessionStartup(input: { } } applyClaudeStartupFacts(session, facts) - await applyClaudeFreshSessionFastMode(session, facts) + const appliesFastMode = admitClaudeFreshSessionFastMode(session, facts) if (!superseded()) { - input.onStarted({ + input.report({ + type: 'started', // `list_models` is answered from this same initialize result, so nothing is re-read. reportedOptions: claudeStartedReportedOptions( session, @@ -301,6 +326,13 @@ export async function settleClaudeSessionStartup(input: { if (session.startup.state === 'pending') { session.startup.state = 'proven' } + if (appliesFastMode) { + void applyClaudeFreshSessionFastMode(session, facts, (event) => { + if (input.isCurrent()) { + input.report(event) + } + }) + } } } catch (caught) { const error = caught instanceof Error ? caught : new Error(String(caught)) diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index 8e0e3b3bdb4f..99d0858a2e28 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -187,7 +187,7 @@ export function fakeClaude( }, applyFlagSettings: async (settings) => { connection.calls.push({ subtype: 'apply_flag_settings', params: { settings } }) - routed('apply_flag_settings', { settings }) + await routed('apply_flag_settings', { settings }) }, interrupt: async (interruptOptions) => { connection.calls.push({ diff --git a/src/main/claude/claude-structured-spawn-options.ts b/src/main/claude/claude-structured-spawn-options.ts index 01bf109a3520..2646cd7c9691 100644 --- a/src/main/claude/claude-structured-spawn-options.ts +++ b/src/main/claude/claude-structured-spawn-options.ts @@ -116,8 +116,9 @@ export function adoptClaudeStructuredSpawnOptions( if (model !== undefined) { session.translator?.modelWritten(model) } - // The journal's window was measured under a value this child did not take. - if (spawn.skipped.includes('model') || spawn.skipped.includes('permissionMode')) { + // The journal's window was measured under a mode this child did not take (`opusplan`'s plan + // mode runs Opus); a launch never leaves a model out. + if (spawn.skipped.includes('permissionMode')) { session.translator?.modelMayHaveChanged() } } From 460ad16d76f833f3af8ef1987cb6d67c6f4d9e6e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:50:36 -0700 Subject: [PATCH 15/22] fix(native-chat): a healed Claude chat goes back to the default model live; comments match the no-hold design When the CLI says the launched model does not exist, the live child is also put back on the CLI's own default (set_model with no model, fire-and-forget), so later messages in the same chat run; a user's pick sent after it wins, and a refused or unanswered reset only logs. Comments that still described the start-hold or the option restore now describe the launch options and the handed-over, never-echoed rule. --- .../claude-structured-event-delivery.ts | 8 ++- .../claude/claude-structured-options.test.ts | 3 +- .../claude-structured-retired-model.test.ts | 55 +++++++++++++++++++ .../claude/claude-structured-retired-model.ts | 38 ++++++++++++- .../claude-structured-session-options.ts | 6 +- .../claude/claude-structured-session-state.ts | 4 +- .../claude-structured-session-test-support.ts | 2 +- .../journal-pending-submission-recovery.ts | 6 +- .../agent-session-journal/journal-store.ts | 2 +- ...ctured-agent-session-option-restoration.ts | 2 +- ...red-agent-session-provider-started.test.ts | 4 +- 11 files changed, 112 insertions(+), 18 deletions(-) diff --git a/src/main/claude/claude-structured-event-delivery.ts b/src/main/claude/claude-structured-event-delivery.ts index 200500f73c11..9e543a37a658 100644 --- a/src/main/claude/claude-structured-event-delivery.ts +++ b/src/main/claude/claude-structured-event-delivery.ts @@ -1,5 +1,8 @@ import { claudePromptCardWritten } from './claude-child-work-evidence' -import { retireClaudeLaunchedModel } from './claude-structured-retired-model' +import { + resetClaudeRetiredModel, + retireClaudeLaunchedModel +} from './claude-structured-retired-model' import type { ClaudeSession, ClaudeStructuredSessionAdapterDeps, @@ -9,7 +12,7 @@ import type { type ClaudeEventDelivery = { session: ClaudeSession | null event: ClaudeStructuredSessionEvent - deps: Pick + deps: Pick publishChildWork: ( sessionId: string, session?: ClaudeSession | null, @@ -49,6 +52,7 @@ export function emitClaudeStructuredSessionEvent({ acquisitionGeneration: session.acquisitionGeneration, options: { model: retired } }) + resetClaudeRetiredModel(session, deps, event.sessionId, retired) } publishChildWork(event.sessionId, session, event.type === 'message' ? event.message : null) // A prompt blocks its child only after the journal has written its card. diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index 502f61223e78..52aa7767506e 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -139,8 +139,7 @@ describe('Claude structured Fast mode', () => { } ) - // Turning Fast off needs no support evidence, so it must not pay a catalog round - // trip — restore replays a stored `false` on every acquire. + // Turning Fast off needs no support evidence, so it must not pay a catalog round trip. it('reads no catalog to turn Fast off, but does to turn it on', async () => { const { session } = fastModeSession(true) const listed = session.connection.supportedModels diff --git a/src/main/claude/claude-structured-retired-model.test.ts b/src/main/claude/claude-structured-retired-model.test.ts index d6a065dfa79b..86397cc59f61 100644 --- a/src/main/claude/claude-structured-retired-model.test.ts +++ b/src/main/claude/claude-structured-retired-model.test.ts @@ -8,8 +8,11 @@ import { retireClaudeLaunchedModel } from './claude-structured-retired-model' import { + adapterFor, + claudeFrame, claudeStartupSettled, fakeClaude, + tick, identityFor, PROVIDER_SESSION_ID, recordingJournalSink @@ -148,3 +151,55 @@ describe("the account catalog a child's listing writes through", () => { await adapter.closeAll() }) }) + +describe('a live child still launched with a retired model', () => { + async function healed(holdReset: boolean) { + let answerReset: () => void = () => {} + const claude = fakeClaude({ + initModels: [NATIVE_OPUS, LAUNCHED_ROW], + routes: { + list_models: () => [NATIVE_OPUS], + set_model: (params) => + holdReset && params?.model === undefined + ? new Promise((resolve) => (answerReset = resolve)) + : undefined + } + }) + const adapter = adapterFor(claude) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + options: { model: RETIRED } + }) + claudeFrame(claude.connections[0]!, modelNotFound()) + return { claude, adapter, answerReset: () => answerReset() } + } + + function setModelCalls(claude: ReturnType) { + return claude.connections[0]!.calls.filter((call) => call.subtype === 'set_model') + } + + it("goes back to the CLI's own default, so the next turn runs", async () => { + const { claude, adapter } = await healed(false) + await tick() + + expect(setModelCalls(claude)).toEqual([{ subtype: 'set_model', params: { model: undefined } }]) + const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(options.current.model).not.toBe(RETIRED) + await adapter.closeAll() + }) + + it('keeps a model the user picks before the reset lands', async () => { + const { claude, adapter, answerReset } = await healed(true) + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 }) + answerReset() + await tick() + + // The pick goes out after the reset, so the CLI runs it; the reset's bookkeeping stands aside. + expect(setModelCalls(claude).map((call) => call.params?.model)).toEqual([undefined, 'opus']) + const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(options.current.model).toBe('opus') + await adapter.closeAll() + }) +}) diff --git a/src/main/claude/claude-structured-retired-model.ts b/src/main/claude/claude-structured-retired-model.ts index b418ab451efd..22cf6ab1d8b6 100644 --- a/src/main/claude/claude-structured-retired-model.ts +++ b/src/main/claude/claude-structured-retired-model.ts @@ -3,7 +3,10 @@ // `model_not_found` (Claude Code 2.1.280). That reply is the evidence the saved model cannot run. import type { AgentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import type { ClaudeSession } from './claude-structured-session-state' +import type { + ClaudeSession, + ClaudeStructuredSessionAdapterDeps +} from './claude-structured-session-state' /** The launched model, once, when a root reply says it does not exist and the chat still has it * picked: the session stops holding it, so the record drops it. Null otherwise. */ @@ -26,6 +29,39 @@ export function retireClaudeLaunchedModel( return launched } +/** Puts the live child, still launched with the retired model, back on the CLI's own default (the + * SDK's `set_model` with no model), as a chat that never picked one runs. A user's pick made + * meanwhile owns the model; a refused or unanswered reset changes nothing but the log. */ +export function resetClaudeRetiredModel( + session: ClaudeSession, + deps: Pick, + sessionId: string, + retired: string +): void { + const sequence = session.optionMutationSequence + void session.connection.setModel(undefined, { timeoutMs: deps.requestTimeoutMs }).then( + () => { + if (sequence !== session.optionMutationSequence) { + return + } + // The retired id is no longer what runs; the next turn's own report names the default. + if (session.reportedOptions.model === retired) { + delete session.reportedOptions.model + } + if (session.appliedOptions?.model === retired) { + const { model: _retired, ...applied } = session.appliedOptions + session.appliedOptions = applied + } + }, + (error: unknown) => + deps.logger?.warn('putting a retired Claude model back on the default failed', { + scope: 'claude-retired-model-reset', + sessionId, + error + }) + ) +} + /** The saved values this child showed it cannot run. Only a retired launch model is one: a launch * never skips a model, so a skipped model is that. */ export function claudeRetiredOptions( diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 24f89e29eee9..1718a10e4478 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -325,9 +325,9 @@ function observeClaudeSettingsReadback( } /** The options as main already holds them, over `catalog`; asks the CLI nothing. Startup's - * settings readback and restore's confirmations are applied by the time a start proves, and - * the SDK answers `list_models` from its initialize result, so a started session's snapshot - * passes that result here rather than paying two round trips for what it already read. */ + * settings readback is applied by the time a start proves, and the SDK answers `list_models` + * from its initialize result, so a started session's snapshot passes that result here rather + * than paying two round trips for what it already read. */ export function claudeStructuredSessionOptionsFrom( session: ClaudeSession, catalog: unknown[] | null, diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 4fe87a85c9c2..0963ce56162c 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -68,7 +68,7 @@ export type ClaudeStructuredSessionEvent = fence: number } | { type: 'auth-diagnostic'; sessionId: string; diagnostic: ClaudeAuthDiagnostic } - /** Startup facts applied and saved options restored; held prompts are about to be written. */ + /** Startup facts applied; the child already took any message written to it. */ | StructuredAgentSessionStartedEvent | StructuredAgentSessionOptionsSkippedEvent | { @@ -214,7 +214,7 @@ export type ClaudeSession = { translator: ClaudeJournalTranslator | null events: StructuredAgentSessionEventSink | undefined unbindReadingControl?: () => void - /** Published at spawn; init facts, option restore and queued prompts land when startup does. */ + /** Published at spawn with its saved options; init facts land when startup does. */ startup: ClaudeSessionStartup } diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index 99d0858a2e28..c29c6693591f 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -179,7 +179,7 @@ export function fakeClaude( }, setModel: async (model) => { connection.calls.push({ subtype: 'set_model', params: { model } }) - routed('set_model', { model }) + await routed('set_model', { model }) }, setPermissionMode: async (mode) => { connection.calls.push({ subtype: 'set_permission_mode', params: { mode } }) diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts index b52e3648b72d..9e9b1f6c57cd 100644 --- a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -38,9 +38,9 @@ export async function markJournalPendingSubmissionsUnknown( return unresolved.map((entry) => entry.clientMessageId) } -/** Settles every submission a child that never proved its start left unanswered as `rejected`: - * such a child accepted nothing, so each is provably unwritten and safe to send again. A queued - * submission was never handed to that child; the delivery loop settles it. */ +/** Settles as `rejected` every submission a child handed over and never echoed, when that child + * ended in its start: one that never answered initialize ran nothing, so each is safe to send + * again. A queued submission was never handed to that child; the delivery loop settles it. */ export async function rejectJournalPendingSubmissions( journal: AgentSessionJournal, fence: number, diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 626b77cf6140..c3ced14526c8 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -349,7 +349,7 @@ export class AgentSessionJournal { return markJournalPendingSubmissionsUnknown(this, fence, reason) } - /** Reject unanswered sends after an owner that never proved its start ended: none was written. */ + /** Reject sends a child that ended in its start was handed and never echoed: none ran. */ async rejectPendingSubmissions( fence: number, rejection: AgentJournalDispatchRejection diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts index de9200a16c75..a9cdc2d14183 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts @@ -21,7 +21,7 @@ export async function readNativeSessionOptions(input: { } /** The record's options once the provider has reported: its model, effort and Fast replace the - * saved ones, other saved options stay, and any the restore could not apply are dropped. */ + * saved ones, other saved options stay, and any the child could not take are dropped. */ export function nativeSessionOptionsFromReport(input: { reported: AgentSessionOptionsResult['current'] restoreSkipped: readonly string[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index 916beac14eee..734f262f7b57 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -1,6 +1,6 @@ // A publish-first create proves nothing about the model until Claude answers startup. The record // must never hold the catalog's default in the meantime: an owner handoff or a reopen would -// replay it as a `set_model` and silently move a user whose CLI default is not Sonnet. +// launch it as `--model` and silently move a user whose CLI default is not Sonnet. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -153,7 +153,7 @@ describe('a publish-first Claude create whose init is slow', () => { ok: true }) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(releasedFence) - // The new child's init reports its CLI default; the saved pick is restored over it. + // The new child is launched with the saved pick, whatever its CLI default. expect(store.getRecord(SESSION)?.options?.model).toBe('opus') expect(lastPhase()).toBe('starting') From 6d8be689ded3d0dac0cb6e253cf3721657bf2e7a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:24:29 -0700 Subject: [PATCH 16/22] fix(native-chat): a message handed to a Claude start that never answered is kept as main keeps an unsent one A child that ended before it answered initialize ran nothing it was handed, the same fact as a send accepted and never handed over. Its end now settles those sends exactly as the chat settles a queued send for that end: a quit keeps a person's message as a held card (restart words), a close keeps it as a held card (closed words), a person's Stop withdraws it as cancelled, and a host stop fails the start with one row. --- .../journal-unsent-send-hold.ts | 24 +++-- .../structured-agent-session-child-exit.ts | 33 +++++-- ...ent-session-claude-hung-start-stop.test.ts | 25 +++-- ...gent-session-dead-generation-settlement.ts | 4 +- ...structured-agent-session-event-recovery.ts | 13 +++ .../structured-agent-session-host-lifetime.ts | 1 + .../structured-agent-session-host-types.ts | 2 + ...ueued-message-rig-provider.test-fixture.ts | 3 + ...ured-agent-session-unrun-send-hold.test.ts | 93 +++++++++++++++++++ 9 files changed, 173 insertions(+), 25 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts index f08d8bb05c4c..d14d32c1f4c3 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts @@ -65,17 +65,27 @@ export function unsentSendKeptAsCard( */ export async function holdUnsentSends( journal: AgentSessionJournal, - input: { fence: number; hostInstance: string; hold: UnsentSendHold } + input: { + fence: number + hostInstance: string + hold: UnsentSendHold + /** In place of the queued sends: those a child that ended before it answered its start was + * handed and never echoed. It ran none, so each is unsent as surely as a queued one. */ + unrun?: true + } ): Promise { const { hold } = input const unsent = journal .submissions() - .filter( - (entry) => - isQueuedAgentJournalSubmission(entry) && - (hold.cause === 'hostRestarted' - ? journal.wroteBeforeOpen(entry.acceptedSequence) - : (hold.which?.(entry) ?? true)) + .filter((entry) => + input.unrun + ? !isQueuedAgentJournalSubmission(entry) && + (entry.dispatchState === 'pending' || + (entry.dispatchState === 'unknown' && entry.recovered !== true)) + : isQueuedAgentJournalSubmission(entry) && + (hold.cause === 'hostRestarted' + ? journal.wroteBeforeOpen(entry.acceptedSequence) + : (hold.which?.(entry) ?? true)) ) .sort((a, b) => (a.acceptedSequence ?? 0) - (b.acceptedSequence ?? 0)) if (unsent.length === 0) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index d76a740d9321..45fad626ed1e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -61,6 +61,13 @@ export type StructuredAgentSessionChildExitContext< serialize: (sessionId: string, task: () => Promise) => Promise now: () => number logger: StructuredAgentSessionLogger + /** Settles what a child that ended before it answered its start was handed and never echoed, as + * the chat settles a queued send for the same end (`holdUnsentSends`). */ + holdUnrunSends?: ( + sessionId: string, + fence: number, + cause: 'chatClosed' | 'hostRestarted' + ) => Promise /** Lets the child's sink and the adapter's route for it go; absent leaves both to the next attach. */ route?: { runtimeState: Pick @@ -119,14 +126,21 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< ? agentSessionFailureFact('hostStopped') : undefined : exit.failure - // What a close settles a send the child was handed and never echoed as, when that send cannot - // have run: a person's Stop of a start, or any close before the child answered its start. + // A send the child was handed and never echoed cannot have run when a person's Stop ended its + // start, or any close ended it before it answered its start: settled as the chat settles a queued + // send for the same end. A Stop withdraws it as cancelled; a quit or close keeps a person's + // message as a held card. A host stop fails the start (`startFailure`). const unrunRejection = - startClose === 'user-stop' - ? agentSessionFailureFact('cancelled') - : expected && exit.startupUnanswered && close?.cause !== 'host-stop' - ? agentSessionFailureFact(close?.cause === 'user-close' ? 'chatClosed' : 'notDelivered') - : undefined + startClose === 'user-stop' ? agentSessionFailureFact('cancelled') : undefined + const unrunHold = + expected && + exit.startupUnanswered && + close?.cause !== 'host-stop' && + close?.cause !== 'user-stop' + ? close?.quit + ? ('hostRestarted' as const) + : ('chatClosed' as const) + : undefined const endChild = (): void => { endProviderChild(session, { generation: child.generation, @@ -159,6 +173,11 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< } catch (error) { logExitFailure(context, sessionId, 'exit-lifecycle-barrier', error) } + if (unrunHold && context.holdUnrunSends) { + await context + .holdUnrunSends(sessionId, child.fence, unrunHold) + .catch((error: unknown) => logExitFailure(context, sessionId, 'exit-unrun-hold', error)) + } const unfinishedWork = captureUnfinishedStructuredAgentSessionWork(session.journal) // The host's stop fails only a start something was handed to; an idle one goes quietly. const startFailed = diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts index faa51053e766..b16e3a93d5d8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts @@ -163,7 +163,12 @@ it('writes the message to a start that never answers, and a Stop ends it: stoppe async function sendHello(): Promise { const body = hostTestMessage('hello') - const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + // A person's send, which a close keeps as a held card. + const sent = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body, + userSend: true + }) if (!sent.ok) { throw new Error('send refused') } @@ -181,19 +186,21 @@ async function submission(id: string) { ) } -// A CLI that never answered initialize ran nothing it was handed, however its child ends. -it.each([ - ['user-close', 'The chat closed before this message was sent.'], - ['evict', 'This message was not delivered. Send it again to continue.'] -] as const)( - 'settles a message written to a start that never answered as not sent when a %s ends it', - async (cause, reason) => { +// A CLI that never answered initialize ran nothing it was handed: a close settles it as it does a +// queued send, keeping the person's words as a held card. +it.each(['user-close', 'evict'] as const)( + 'keeps a message written to a start that never answered as a held card when a %s ends it', + async (cause) => { const id = await sendHello() await host.close(SESSION, cause) await vi.waitFor(async () => - expect(await submission(id)).toMatchObject({ dispatchState: 'rejected', reason }) + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: 'The chat closed before this message was sent.', + keptAsQueuedMessageId: id + }) ) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 9ca2b3f2a7cc..8d677c8e9be7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -126,7 +126,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { /** The provider never finished starting: the start that failed, keyed by the child's * generation. Its row is the one the delivery loop writes for the same start. */ exitedDuringStartup?: { generation: string | null } - /** A close ended the child before what it was handed could run: each such send is rejected so. */ + /** A person's Stop ended a starting child before what it was handed could run: each is rejected so. */ unrunRejection?: SubmissionRejectionFact }): Promise { try { @@ -137,7 +137,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { } // A queued message is the delivery loop's to settle: it was never handed to this child. A send // a child still starting was handed and never echoed did not run, and its root is gone: it is - // rejected, with the child's own diagnostic or as the close that ended it. A proven child's + // rejected, with the child's own diagnostic or as the Stop that ended it. A proven child's // handed-over sends stay in doubt. const startupFailure = input.exitedDuringStartup ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 2ed6fe511154..bf4c74bd3086 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -1,5 +1,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { StructuredAgentSessionLifecycleEvent } from './structured-agent-session-adapter' +import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' +import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' import type { StructuredAgentSessionHostDeps, @@ -42,6 +44,17 @@ export class StructuredAgentSessionEventRecovery { return { ...this.context, logger: deps.logger, + holdUnrunSends: async (sessionId, fence, cause) => { + const journal = this.context.sessions.get(sessionId)?.journal + if (journal) { + await holdUnsentSends(journal, { + fence, + hostInstance: structuredAgentSessionHostInstance(), + hold: { cause }, + unrun: true + }) + } + }, route: { runtimeState, acknowledgeRelease: (sessionId) => deps.adapter.acknowledgeSessionRelease?.(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index fda7bc9c27e6..84dec78d0717 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -114,6 +114,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( : Promise.resolve(null) child.close = { cause, + ...('quit' in ending && ending.quit ? { quit: true as const } : {}), reason: ('reason' in ending ? ending.reason : undefined) ?? null, recorded, requestedAt: session.journal.cursor() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 243e2d8bea2f..d90f228ed30b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -51,6 +51,8 @@ export type StructuredAgentSessionProviderChildIdentity = { export type StructuredAgentSessionChildClose = { /** The first stop's, which the child's end keeps however many asks join it. */ readonly cause: StructuredAgentSessionStopCause + /** Asked for by quit, whose leftovers the next open settles as a crash's. */ + readonly quit?: true readonly reason: string | null /** The Stop event that stop wrote, folded before the work it ends is settled, with the settle a * person's close that named no turn opens; a repeated ask reopens it. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts index 8be42b950333..a3c825b51a67 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts @@ -20,6 +20,8 @@ export type QueuedRigProviderOptions = { starting?: true /** The provider's Stop ends its child, as Claude's does. */ stopEndsSession?: true + /** Its child never answers its start, so it runs nothing it is handed. */ + startUnanswered?: true } export function createQueuedRigProvider( @@ -81,6 +83,7 @@ export function createQueuedRigProvider( compact, cancelTurn, ...(options.stopEndsSession ? { stopEndsSession: () => true } : {}), + ...(options.startUnanswered ? { startAnswered: () => false } : {}), answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts new file mode 100644 index 000000000000..107bc8b8ae7a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts @@ -0,0 +1,93 @@ +// A message handed to an agent whose start never answers provably never ran: its CLI takes no +// message before it answers initialize. So when the chat ends then, the message is settled as a +// queued one is for the same end (`journal-unsent-send-hold.ts`): a quit or a close keeps a +// person's words as a held card, a person's Stop withdraws it. Against the real host, store and +// journal, with an agent that stays starting. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' + +const words = (kind: 'hostRestarted' | 'chatClosed' | 'cancelled') => + agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }) +const KEPT = { state: 'waiting', paused: true } + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig({ + restartable: true, + starting: true, + startUnanswered: true, + stopEndsSession: true + }) +}) + +afterEach(async () => { + await rig.dispose() +}) + +/** Sent to the starting agent and handed over to it, never echoed. */ +async function handedToHungStart(text: string): Promise { + // No child, so this send starts one that never answers. + await rig.host.close(SESSION, 'evict') + const sent = rig.send(text) + await sent.result + await eventually(async () => expect((await rig.submission(sent.id))?.handedOverAt).toBeDefined()) + expect(rig.host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') + return sent.id +} + +describe('a message handed to a start that never answered, then the chat ends', () => { + it('is a held card after a quit, rejected as a restart, as a queued one is', async () => { + const id = await handedToHungStart('kept through quit') + await rig.quitRestartHostProcess() + + expect(await rig.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...words('hostRestarted'), + keptAsQueuedMessageId: id + }) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + const [card] = + rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.queuedMessages.list() ?? [] + expect(card).toMatchObject({ holdReason: QUEUED_MESSAGE_PAUSED_KEPT }) + }) + + it.each(['user-close', 'evict'] as const)( + 'is a held card after a %s, rejected as closed, as a queued one is', + async (cause) => { + const id = await handedToHungStart('kept at close') + await rig.host.close(SESSION, cause) + rig.crashRestartHostProcess() + + expect(await rig.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...words('chatClosed'), + keptAsQueuedMessageId: id + }) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + } + ) + + it("is withdrawn by a person's Stop, as a queued one is, and kept as no card", async () => { + const id = await handedToHungStart('stopped') + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + await eventually(async () => + expect(await rig.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...words('cancelled') + }) + ) + expect(await rig.drafts()).toEqual([]) + }) +}) From ba2a171affed880157ee1546d6edf0698989864f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:45:41 -0700 Subject: [PATCH 17/22] fix(native-chat): a quit during a Claude start that never answered offers no resume for the message it keeps as a card The restart snapshot now reads the same never-answered fact the exit does, so a message handed to such a start counts as queued work, not as work to resume. The retired-model reset comment names the default it really applies. --- .../claude/claude-structured-retired-model.ts | 6 +++--- .../journal-unsent-send-hold.ts | 19 ++++++++++++++--- ...session-queued-message-rig.test-fixture.ts | 12 ++++++++++- ...tured-agent-session-restart-resume-host.ts | 1 + ...ed-agent-session-restart-witnesses.test.ts | 1 + ...uctured-agent-session-restart-witnesses.ts | 2 ++ ...ured-agent-session-unrun-send-hold.test.ts | 21 ++++++++++++++++++- ...tured-agent-session-working-at-teardown.ts | 11 ++++++++-- 8 files changed, 63 insertions(+), 10 deletions(-) diff --git a/src/main/claude/claude-structured-retired-model.ts b/src/main/claude/claude-structured-retired-model.ts index 22cf6ab1d8b6..5c25ad461811 100644 --- a/src/main/claude/claude-structured-retired-model.ts +++ b/src/main/claude/claude-structured-retired-model.ts @@ -29,9 +29,9 @@ export function retireClaudeLaunchedModel( return launched } -/** Puts the live child, still launched with the retired model, back on the CLI's own default (the - * SDK's `set_model` with no model), as a chat that never picked one runs. A user's pick made - * meanwhile owns the model; a refused or unanswered reset changes nothing but the log. */ +/** Moves the live child to the CLI's account default (`set_model` with no model), which unlike a + * fresh launch ignores ANTHROPIC_MODEL and a settings `model`. A user's pick made meanwhile owns + * the model; a refused or unanswered reset changes nothing but the log. */ export function resetClaudeRetiredModel( session: ClaudeSession, deps: Pick, diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts index d14d32c1f4c3..a94b3b86fec1 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts @@ -55,6 +55,21 @@ export function unsentSendKeptAsCard( return persons ? body : null } +/** A send handed to the agent that it has neither echoed nor refused. Handed to a child that never + * answered its start, it ran nowhere. */ +export function isUnansweredHandedOverSubmission( + entry: Pick< + AgentJournalSubmission, + 'handoverRecorded' | 'dispatchState' | 'handedOverAt' | 'recovered' + > +): boolean { + return ( + !isQueuedAgentJournalSubmission(entry) && + (entry.dispatchState === 'pending' || + (entry.dispatchState === 'unknown' && entry.recovered !== true)) + ) +} + /** * Settles every send `hold` names. Each is rejected with the hold's cause in its own row, and a * kept one becomes a card in that row's transaction, so a crash between them can never leave @@ -79,9 +94,7 @@ export async function holdUnsentSends( .submissions() .filter((entry) => input.unrun - ? !isQueuedAgentJournalSubmission(entry) && - (entry.dispatchState === 'pending' || - (entry.dispatchState === 'unknown' && entry.recovered !== true)) + ? isUnansweredHandedOverSubmission(entry) : isQueuedAgentJournalSubmission(entry) && (hold.cause === 'hostRestarted' ? journal.wroteBeforeOpen(entry.acceptedSequence) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index 338d6d0e2911..6b3ff21213ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -12,6 +12,7 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journ import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause' import { @@ -44,6 +45,8 @@ export async function createQueuedMessageTestRig( options: QueuedRigProviderOptions & { /** Lets a test sweep idle chats on its own `tick`. */ idleSweep?: { idleMs: number; intervalMs: number } + /** Teardown records its restart offers, which `restartOffers` lists. */ + recoveryCapsule?: true } = {} ) { const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-')) @@ -61,7 +64,8 @@ export async function createQueuedMessageTestRig( claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', now: () => NOW, - ...(options.idleSweep ? { idleSweep: options.idleSweep } : {}) + ...(options.idleSweep ? { idleSweep: options.idleSweep } : {}), + ...(options.recoveryCapsule ? { recoveryCapsule: new AgentSessionRecoveryCapsule(root) } : {}) }) let host = makeHost() expect(await host.attach(QUEUED_RIG_CALLER, hostTestAttachParams(null))).toMatchObject({ @@ -226,6 +230,11 @@ export async function createQueuedMessageTestRig( return page.page.queuePause ?? null } + /** The restart offers this host lists for the chats an earlier one stopped. */ + async function restartOffers() { + return (await host.restartResume.list()).map(({ sessionId, work }) => ({ sessionId, work })) + } + function resume(clientOperationId = hostTestOperationId()) { return host.queuedMessagesResume(QUEUED_RIG_CALLER, { envelope: envelope({}, 'agentSession.queuedMessagesResume', clientOperationId) @@ -267,6 +276,7 @@ export async function createQueuedMessageTestRig( crashRestartHostProcess, quitRestartHostProcess, queuePause, + restartOffers, resume, dispose } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index 5f4e5bb6e797..6a868201baa1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -104,6 +104,7 @@ export function createStructuredAgentSessionRestartResume( sessions, getRecord: deps.store.getRecord, childWork: surfaces.readChildWork, + startAnswered: (sessionId) => deps.adapter.startAnswered?.(sessionId), ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), teardownId: randomUUID(), now: surfaces.now, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts index 739ad2c5a33f..dc71bc09f071 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts @@ -20,6 +20,7 @@ function witnessed() { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: clear reads only the record's provider. AGENTS[sessionId] ? ({ provider: AGENTS[sessionId] } as AgentSessionRecord) : null, childWork: () => undefined, + startAnswered: () => undefined, capsule: { record: async (markers) => void recorded.push([...markers]) }, teardownId: 'teardown', now: () => 1, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts index 683fe79d9e87..648f92b6d595 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts @@ -33,6 +33,7 @@ export function createStructuredAgentSessionRestartWitnesses(deps: { sessions: ReadonlyMap> getRecord: (sessionId: string) => AgentSessionRecord | null childWork: WorkingAtStopInput['childWork'] + startAnswered: NonNullable capsule?: Pick teardownId: string now: () => number @@ -72,6 +73,7 @@ export function createStructuredAgentSessionRestartWitnesses(deps: { session: deps.sessions.get(sessionId), getRecord: deps.getRecord, childWork: deps.childWork, + startAnswered: deps.startAnswered, trigger, teardownId: deps.teardownId, now: deps.now() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts index 107bc8b8ae7a..5112b6ff74de 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts @@ -26,7 +26,8 @@ beforeEach(async () => { restartable: true, starting: true, startUnanswered: true, - stopEndsSession: true + stopEndsSession: true, + recoveryCapsule: true }) }) @@ -59,6 +60,24 @@ describe('a message handed to a start that never answered, then the chat ends', const [card] = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.queuedMessages.list() ?? [] expect(card).toMatchObject({ holdReason: QUEUED_MESSAGE_PAUSED_KEPT }) + // Nothing ran, so there is nothing to resume: the card alone holds the words. + expect(await rig.restartOffers()).toEqual([]) + }) + + it('is offered for resume after a quit once the start answered, as the control', async () => { + await rig.dispose() + rig = await createQueuedMessageTestRig({ + restartable: true, + starting: true, + stopEndsSession: true, + recoveryCapsule: true + }) + const id = await handedToHungStart('may have run') + await rig.quitRestartHostProcess() + + expect(await rig.restartOffers()).toEqual([ + { sessionId: SESSION, work: { kind: 'submission', id } } + ]) }) it.each(['user-close', 'evict'] as const)( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index 9f42431e7730..8279cf83d183 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -49,6 +49,7 @@ import { newestStructuredAgentSessionTurn } from '../../../shared/structured-agent-session-live-turn' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { isUnansweredHandedOverSubmission } from '../agent-session-journal/journal-unsent-send-hold' import { structuredAgentSessionShownStatus } from './structured-agent-session-shown-work' /** A send Orca journaled that the provider has neither opened a turn for nor refused. Mirrors the @@ -172,6 +173,8 @@ export function structuredAgentSessionWorkingAtStop(input: { getRecord: (sessionId: string) => AgentSessionRecord | null /** The session's child records, the same read the status feed publishes. */ childWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined + /** False when the session's live child never answered its start; the exit reads the same fact. */ + startAnswered?: (sessionId: string) => boolean | undefined trigger: AgentSessionResumeTrigger /** Stable teardown identity for continuation deduplication, not launch ancestry. */ teardownId: string @@ -182,9 +185,13 @@ export function structuredAgentSessionWorkingAtStop(input: { return null } const snapshot = session.journal.snapshot() - // A queued message reached no agent, so it is no work to resume: the next open settles it. + // A queued message reached no agent, and one handed to a start that never answered ran nowhere: + // neither is work to resume. The next open or the exit keeps it as unsent. + const unanswered = input.startAnswered?.(sessionId) === false const handedOver = snapshot.submissions.filter( - (submission) => !isQueuedAgentJournalSubmission(submission) + (submission) => + !isQueuedAgentJournalSubmission(submission) && + !(unanswered && isUnansweredHandedOverSubmission(submission)) ) const childWork = input.childWork(sessionId) const status = structuredAgentSessionShownStatus( From 9a981ddc224b93d67faf4aab3f9c07f44247dcc3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:16:42 -0700 Subject: [PATCH 18/22] refactor(native-chat): the saved permission-mode launch helpers live with the spawn options that use them Keeps claude-structured-launch-resolution.ts within max-lines once merged with main, and names the hung-start test envelope's field type. --- .../claude-structured-launch-resolution.ts | 20 -------------- .../claude/claude-structured-spawn-options.ts | 26 +++++++++++++++---- ...ent-session-claude-hung-start-stop.test.ts | 7 +++-- 3 files changed, 26 insertions(+), 27 deletions(-) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 12488410f6f3..d1a7777f2df5 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -94,26 +94,6 @@ export function claudeStructuredPermissionOptions( return mode === 'bypassPermissions' ? { extraArgs: { 'dangerously-skip-permissions': null } } : {} } -/** Whether `options` launch with the Agent Permissions bypass. */ -export function claudeStructuredOptionsBypassPermissions( - options: ClaudeStructuredSdkOptions -): boolean { - return options.extraArgs?.['dangerously-skip-permissions'] !== undefined -} - -/** `options` launched in `mode` instead of the mode they carry; never more than they allow. */ -export function claudeStructuredOptionsWithPermissionMode( - options: ClaudeStructuredSdkOptions, - mode: PermissionMode -): ClaudeStructuredSdkOptions { - if (mode === 'bypassPermissions') { - return options - } - // Known limit: no switch back to bypass later; that needs the allow flag older CLIs reject. - const { 'dangerously-skip-permissions': _bypass, ...extraArgs } = options.extraArgs ?? {} - return { ...options, permissionMode: mode, extraArgs } -} - export type ClaudeStructuredLaunch = { /** Always Orca's resolved user CLI: the SDK's bundled binaries are excluded from the install. */ pathToClaudeCodeExecutable: string diff --git a/src/main/claude/claude-structured-spawn-options.ts b/src/main/claude/claude-structured-spawn-options.ts index 2646cd7c9691..e08d781e352b 100644 --- a/src/main/claude/claude-structured-spawn-options.ts +++ b/src/main/claude/claude-structured-spawn-options.ts @@ -4,11 +4,9 @@ import type { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' -import { - claudeStructuredOptionsBypassPermissions, - claudeStructuredOptionsWithPermissionMode, - type ClaudeStructuredLaunch, - type ClaudeStructuredSdkOptions +import type { + ClaudeStructuredLaunch, + ClaudeStructuredSdkOptions } from './claude-structured-launch-resolution' import type { ClaudeSession } from './claude-structured-session-state' import { restoredClaudeStructuredSessionOptions } from './claude-structured-options' @@ -29,6 +27,24 @@ const PERMISSION_MODES: ReadonlySet = new Set([ 'auto' ]) +/** Whether `options` launch with the Agent Permissions bypass. */ +function claudeStructuredOptionsBypassPermissions(options: ClaudeStructuredSdkOptions): boolean { + return options.extraArgs?.['dangerously-skip-permissions'] !== undefined +} + +/** `options` launched in `mode` instead of the mode they carry; never more than they allow. */ +function claudeStructuredOptionsWithPermissionMode( + options: ClaudeStructuredSdkOptions, + mode: PermissionMode +): ClaudeStructuredSdkOptions { + if (mode === 'bypassPermissions') { + return options + } + // Known limit: no switch back to bypass later; that needs the allow flag older CLIs reject. + const { 'dangerously-skip-permissions': _bypass, ...extraArgs } = options.extraArgs ?? {} + return { ...options, permissionMode: mode, extraArgs } +} + function isEffortLevel(value: string): value is EffortLevel { return EFFORT_LEVELS.has(value) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts index b16e3a93d5d8..595e18177541 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts @@ -104,7 +104,10 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -function envelope(method: 'agentSession.send' | 'agentSession.cancel', fields: object) { +function envelope( + method: 'agentSession.send' | 'agentSession.cancel', + fields: Parameters[0]['fields'] +) { return { sessionId: SESSION, clientOperationId: hostTestOperationId(), @@ -112,7 +115,7 @@ function envelope(method: 'agentSession.send' | 'agentSession.cancel', fields: o payloadFingerprint: computeAgentSessionPayloadFingerprint({ method, sessionId: SESSION, - fields: { ...fields } + fields }) } } From 4ae3666c9b92149325b35874e1dde32afc23a136 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:10:57 -0700 Subject: [PATCH 19/22] fix(native-chat): a Claude chat's saved options take precedence over the agent Arguments' own flags Main now passes the saved agent Arguments to the Claude child, and the SDK writes them after its own options. An Arguments --model or --effort therefore reached the CLI as a second flag after the chat's saved pick (a commander CLI keeps the last), and a saved Fast's launch settings replaced an Arguments --settings file outright. The saved model and effort now stand in for the Arguments' flags, and a saved Fast beside an Arguments --settings is applied by the start instead of at launch. --- .../claude-structured-acquisition-launch.ts | 13 +++- ...claude-structured-dispatch-test-support.ts | 1 + ...-structured-options-context-window.test.ts | 6 +- .../claude-structured-session-publication.ts | 1 + .../claude-structured-session-startup.ts | 70 ++---------------- .../claude/claude-structured-session-state.ts | 2 + .../claude-structured-spawn-options.test.ts | 39 ++++++++++ .../claude/claude-structured-spawn-options.ts | 42 ++++++++--- .../claude-structured-start-fast-mode.test.ts | 34 +++++++++ .../claude-structured-start-fast-mode.ts | 72 +++++++++++++++++++ 10 files changed, 204 insertions(+), 76 deletions(-) create mode 100644 src/main/claude/claude-structured-start-fast-mode.test.ts create mode 100644 src/main/claude/claude-structured-start-fast-mode.ts diff --git a/src/main/claude/claude-structured-acquisition-launch.ts b/src/main/claude/claude-structured-acquisition-launch.ts index 2023df22053d..66f0bf0d6651 100644 --- a/src/main/claude/claude-structured-acquisition-launch.ts +++ b/src/main/claude/claude-structured-acquisition-launch.ts @@ -7,7 +7,10 @@ import { import { stopAgentSessionProviderRoot } from '../native-chat/agent-session-wire/structured-agent-session-provider-exit-proof' import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation' import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' -import { claudeStructuredSpawnOptions } from './claude-structured-spawn-options' +import { + claudeStructuredSpawnOptions, + type ClaudeStructuredSpawnOptions +} from './claude-structured-spawn-options' import { cancelClaudeAcquisitionAttempt, type ClaudeAcquisitionAttempt, @@ -24,7 +27,7 @@ import { /** The launch with the chat's saved options in its spawn options. */ export type ClaudeAcquisitionLaunch = ClaudeStructuredLaunch & { - savedOptions: { options: Map; skipped: readonly string[] } + savedOptions: Omit } export async function resolveClaudeAcquisitionLaunch(args: { @@ -97,7 +100,11 @@ export async function resolveClaudeAcquisitionLaunch(args: { return { ...launch, options: spawn.sdkOptions, - savedOptions: { options: spawn.options, skipped: spawn.skipped } + savedOptions: { + options: spawn.options, + skipped: spawn.skipped, + fastModeAtStart: spawn.fastModeAtStart + } } }) } diff --git a/src/main/claude/claude-structured-dispatch-test-support.ts b/src/main/claude/claude-structured-dispatch-test-support.ts index bd4bdba4af8e..80a774e648bc 100644 --- a/src/main/claude/claude-structured-dispatch-test-support.ts +++ b/src/main/claude/claude-structured-dispatch-test-support.ts @@ -30,6 +30,7 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C confirmedOptions: new Set(), restoreSkippedOptions: new Set(), launchedModel: null, + fastModeAtStart: false, capabilities: [], events: undefined, translator: null, diff --git a/src/main/claude/claude-structured-options-context-window.test.ts b/src/main/claude/claude-structured-options-context-window.test.ts index c20b73bd37ee..fb901fb90852 100644 --- a/src/main/claude/claude-structured-options-context-window.test.ts +++ b/src/main/claude/claude-structured-options-context-window.test.ts @@ -40,7 +40,8 @@ describe('the context ring after a session option write', () => { const s = ringSession() adoptClaudeStructuredSpawnOptions(s.session, { options: new Map([['model', 'opus[1m]']]), - skipped: [] + skipped: [], + fastModeAtStart: false }) expect(s.respond('turn-a', 1_000)).toMatchObject({ windowTokens: 1_000_000, percentage: 10 }) }) @@ -62,7 +63,8 @@ describe('the context ring after a session option write', () => { const skipped = ringSession() adoptClaudeStructuredSpawnOptions(skipped.session, { options: new Map([['model', 'sonnet[1m]']]), - skipped: ['permissionMode'] + skipped: ['permissionMode'], + fastModeAtStart: false }) expect(skipped.respond('turn-a', 1_000)).toBeNull() }) diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index 15d8bdad67ca..8c3b524086b4 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -65,6 +65,7 @@ export function createClaudeSessionPublication(input: { confirmedOptions: new Set(), restoreSkippedOptions: new Set(), launchedModel: null, + fastModeAtStart: false, translator: input.translator, events: input.events, ...(input.unbindReadingControl ? { unbindReadingControl: input.unbindReadingControl } : {}), diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index 94470828a04c..e7e0c12f338e 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -8,7 +8,6 @@ import type { StructuredAgentSessionStartedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' -import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' import { claudeAuthDiagnostic, @@ -22,10 +21,8 @@ import { prepareClaudeStructuredSessionAcquisitionOptions, readClaudeStructuredSessionSettings } from './claude-structured-session-acquisition-options' -import { listedModels } from './claude-structured-model-catalog' import { claudeRetiredOptions } from './claude-structured-retired-model' import { - claudeModelFastModeSupport, claudeStructuredSessionOptionsFrom, observeClaudeSettingsApplied, readClaudeSettingsEffort @@ -35,6 +32,10 @@ import { type ClaudeSessionStartup } from './claude-structured-session-startup-state' import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { + admitClaudeStartFastMode, + applyClaudeStartFastMode +} from './claude-structured-start-fast-mode' /** The CLI's own frame naming the session it runs (system/init or a SessionStart hook). Only a * SessionStart hook sends one before the first turn, so startup takes it when it came and never @@ -165,63 +166,6 @@ export async function readClaudeStartupFacts(input: { } } -/** A new conversation is launched without a saved Fast on, which its settings may opt in to per - * session. Those settings now read: an opt-in drops it, as before, and so do the guards a live - * Fast write takes. True when it is still to be applied. */ -function admitClaudeFreshSessionFastMode( - session: ClaudeSession, - facts: ClaudeStartupFacts -): boolean { - if (facts.resumesTranscript || session.options.get('fastMode') !== 'true') { - return false - } - if (facts.prepared.fastModePerSessionOptIn === true) { - session.options.delete('fastMode') - return false - } - // Over the listing this start already holds. - const listed = listedModels({ models: readClaudeModels(facts.initialization) }) - const blocked = - session.fastModeDisabledReason !== undefined && - !['preference', 'sdk_opt_in_required'].includes(session.fastModeDisabledReason) - if ( - blocked || - (listed.length > 0 && claudeModelFastModeSupport(session, listed).supported !== true) - ) { - session.options.delete('fastMode') - session.restoreSkippedOptions.add('fastMode') - return false - } - return true -} - -/** Applies a new conversation's saved Fast on after `started`, so nothing waits on it. A refusal - * drops it as main's refused restore did, from the record too; silence keeps it wanted and - * unconfirmed. A write the user made meanwhile owns the option. */ -async function applyClaudeFreshSessionFastMode( - session: ClaudeSession, - facts: ClaudeStartupFacts, - report: (event: ClaudeStartupReport) => void -): Promise { - const sequence = session.optionMutationSequence - try { - await session.connection.applyFlagSettings( - { fastMode: true }, - { timeoutMs: facts.requestTimeoutMs } - ) - } catch (error) { - if (sequence !== session.optionMutationSequence) { - return - } - session.confirmedOptions.delete('fastMode') - if (error instanceof ClaudeControlRequestError) { - session.options.delete('fastMode') - session.restoreSkippedOptions.add('fastMode') - report({ type: 'options-skipped', options: { fastMode: 'true' } }) - } - } -} - function applyClaudeStartupFacts(session: ClaudeSession, facts: ClaudeStartupFacts): void { const { init, initialization, settings, prepared } = facts const effort = readClaudeSettingsEffort(settings) @@ -311,7 +255,7 @@ export async function settleClaudeSessionStartup(input: { } } applyClaudeStartupFacts(session, facts) - const appliesFastMode = admitClaudeFreshSessionFastMode(session, facts) + const startFastMode = admitClaudeStartFastMode(session, facts) if (!superseded()) { input.report({ type: 'started', @@ -326,8 +270,8 @@ export async function settleClaudeSessionStartup(input: { if (session.startup.state === 'pending') { session.startup.state = 'proven' } - if (appliesFastMode) { - void applyClaudeFreshSessionFastMode(session, facts, (event) => { + if (startFastMode !== null) { + void applyClaudeStartFastMode(session, facts, startFastMode, (event) => { if (input.isCurrent()) { input.report(event) } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 0963ce56162c..e327e2316219 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -187,6 +187,8 @@ export type ClaudeSession = { restoreSkippedOptions: Set /** The model the child was launched with, as `--model`; null when it runs the CLI's own. */ launchedModel: string | null + /** The launch left the saved Fast out, so the start applies it once the settings are read. */ + fastModeAtStart: boolean /** Absent when the adapter runs without a host catalog store (tests). */ catalogAccess?: AgentModelCatalogSessionAccess /** CLI-advertised protocol capabilities from init; gates interrupt-receipt handling. */ diff --git a/src/main/claude/claude-structured-spawn-options.test.ts b/src/main/claude/claude-structured-spawn-options.test.ts index 26aefc0ca6a3..42780ae49986 100644 --- a/src/main/claude/claude-structured-spawn-options.test.ts +++ b/src/main/claude/claude-structured-spawn-options.test.ts @@ -80,6 +80,7 @@ describe('a Claude chat launched with its saved options', () => { expect(spawn.sdkOptions).not.toHaveProperty('settings') expect(spawn.options.get('fastMode')).toBe('true') + expect(spawn.fastModeAtStart).toBe(true) expect(spawn.skipped).toEqual([]) }) @@ -87,8 +88,46 @@ describe('a Claude chat launched with its saved options', () => { const spawn = launched({ fastMode: 'false' }, { resumesTranscript: false }) expect(spawn.sdkOptions.settings).toEqual({ fastMode: false }) + expect(spawn.fastModeAtStart).toBe(false) }) + // The SDK writes the agent Arguments after its own options, so left in, the Arguments' flag would + // reach the CLI as a second `--model` after the chat's pick. + it("replaces the agent Arguments' model and effort with the chat's saved ones", () => { + const base = { + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, model: 'opus', effort: 'max' } + } + + expect(launched({ model: 'sonnet', effort: 'low' }, { base }).sdkOptions).toMatchObject({ + model: 'sonnet', + effort: 'low', + extraArgs: { 'replay-user-messages': null } + }) + expect(launched({ model: 'sonnet', effort: 'low' }, { base }).sdkOptions.extraArgs).toEqual({ + 'replay-user-messages': null + }) + // With nothing saved, the Arguments decide. + expect(launched({}, { base }).sdkOptions.extraArgs).toEqual(base.extraArgs) + }) + + // One `--settings` reaches the CLI: the Arguments' file is kept, and the start applies Fast. + it.each(['true', 'false'])( + "keeps the agent Arguments' settings and leaves a saved Fast %s to the start", + (fastMode) => { + const base = { + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, settings: '/repo/claude.json' } + } + const spawn = launched({ fastMode }, { base }) + + expect(spawn.sdkOptions).not.toHaveProperty('settings') + expect(spawn.sdkOptions.extraArgs).toEqual(base.extraArgs) + expect(spawn.options.get('fastMode')).toBe(fastMode) + expect(spawn.fastModeAtStart).toBe(true) + } + ) + it('launches a saved bypass under an Agent Permissions bypass with the owned bypass flag', () => { const spawn = launched({ permissionMode: 'bypassPermissions' }, { base: BYPASS_LAUNCH }) diff --git a/src/main/claude/claude-structured-spawn-options.ts b/src/main/claude/claude-structured-spawn-options.ts index e08d781e352b..be3414358646 100644 --- a/src/main/claude/claude-structured-spawn-options.ts +++ b/src/main/claude/claude-structured-spawn-options.ts @@ -45,6 +45,19 @@ function claudeStructuredOptionsWithPermissionMode( return { ...options, permissionMode: mode, extraArgs } } +/** The saved value stands in for the agent Arguments' own flag for it: the chat's pick wins, and + * the CLI is never handed the flag twice. */ +function withoutConfiguredFlag( + options: ClaudeStructuredSdkOptions, + flag: string +): ClaudeStructuredSdkOptions { + if (!options.extraArgs || !Object.hasOwn(options.extraArgs, flag)) { + return options + } + const { [flag]: _configured, ...extraArgs } = options.extraArgs + return { ...options, extraArgs } +} + function isEffortLevel(value: string): value is EffortLevel { return EFFORT_LEVELS.has(value) } @@ -59,6 +72,8 @@ export type ClaudeStructuredSpawnOptions = { options: Map /** Saved options left out: the provider's own value wins and is re-persisted. */ skipped: readonly string[] + /** A saved Fast the launch does not carry, which the start applies. */ + fastModeAtStart: boolean } /** @@ -74,16 +89,17 @@ export function claudeStructuredSpawnOptions(input: { const options = new Map() const skipped: string[] = [] let sdkOptions: ClaudeStructuredSdkOptions = { ...input.launch.options } + let fastModeAtStart = false const model = saved.get('model') if (model !== undefined) { options.set('model', model) - sdkOptions.model = model + sdkOptions = { ...withoutConfiguredFlag(sdkOptions, 'model'), model } } const effort = saved.get('effort') if (effort !== undefined) { if (isEffortLevel(effort)) { options.set('effort', effort) - sdkOptions.effort = effort + sdkOptions = { ...withoutConfiguredFlag(sdkOptions, 'effort'), effort } } else { skipped.push('effort') } @@ -95,10 +111,16 @@ export function claudeStructuredSpawnOptions(input: { skipped.push('fastMode') } else { options.set('fastMode', fastMode) - // A new CLI session may opt in to Fast per session, which only its settings say: the start - // applies a saved Fast on there once it has read them (`applyClaudeFreshSessionFastMode`). - if (!decoded || input.launch.resumesTranscript) { + // A new CLI session may opt in to Fast per session, which only its settings say, and the + // agent Arguments' own `--settings` would be replaced by one carrying Fast: either way the + // start applies the saved Fast once it has read them (`applyClaudeStartFastMode`). + if ( + (!decoded || input.launch.resumesTranscript) && + !Object.hasOwn(sdkOptions.extraArgs ?? {}, 'settings') + ) { sdkOptions.settings = { fastMode: decoded } + } else { + fastModeAtStart = true } } } @@ -116,15 +138,19 @@ export function claudeStructuredSpawnOptions(input: { skipped.push('permissionMode') } } - return { sdkOptions, options, skipped } + return { sdkOptions, options, skipped, fastModeAtStart } } /** The published session takes on what its child was launched with. */ export function adoptClaudeStructuredSpawnOptions( - session: Pick, - spawn: Pick + session: Pick< + ClaudeSession, + 'restoreSkippedOptions' | 'translator' | 'launchedModel' | 'fastModeAtStart' + >, + spawn: Pick ): void { session.launchedModel = spawn.options.get('model') ?? null + session.fastModeAtStart = spawn.fastModeAtStart for (const key of spawn.skipped) { session.restoreSkippedOptions.add(key) } diff --git a/src/main/claude/claude-structured-start-fast-mode.test.ts b/src/main/claude/claude-structured-start-fast-mode.test.ts new file mode 100644 index 000000000000..24057e865fca --- /dev/null +++ b/src/main/claude/claude-structured-start-fast-mode.test.ts @@ -0,0 +1,34 @@ +// A saved Fast the launch leaves out beside the agent Arguments' own `--settings`, applied by the +// start once the child answers. + +import { expect, it, vi } from 'vitest' +import { adapterFor, fakeClaude, identityFor } from './claude-structured-session-test-support' + +it("applies a resumed conversation's saved Fast off after start beside the Arguments' settings", async () => { + const claude = fakeClaude({ + settings: { effective: { fastMode: true, fastModePerSessionOptIn: false } } + }) + const adapter = adapterFor(claude, { + resumesTranscript: true, + continuesChain: true, + options: { extraArgs: { settings: '/repo/claude.json' } } + }) + + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + options: { fastMode: 'false' } + }) + + expect(claude.connections[0].launch.options).not.toHaveProperty('settings') + expect(claude.connections[0].launch.options.extraArgs).toEqual({ + settings: '/repo/claude.json' + }) + await vi.waitFor(() => + expect(claude.connections[0].calls.at(-1)).toEqual({ + subtype: 'apply_flag_settings', + params: { settings: { fastMode: false } } + }) + ) +}) diff --git a/src/main/claude/claude-structured-start-fast-mode.ts b/src/main/claude/claude-structured-start-fast-mode.ts new file mode 100644 index 000000000000..df997380f1a7 --- /dev/null +++ b/src/main/claude/claude-structured-start-fast-mode.ts @@ -0,0 +1,72 @@ +// A saved Fast the Claude child was launched without, applied once its start has read the settings +// that decide whether it may run. + +import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' +import { readClaudeModels } from './claude-structured-init-proof' +import { listedModels } from './claude-structured-model-catalog' +import { claudeModelFastModeSupport } from './claude-structured-session-options' +import type { ClaudeStartupFacts, ClaudeStartupReport } from './claude-structured-session-startup' +import type { ClaudeSession } from './claude-structured-session-state' + +/** A saved Fast the launch left out (`fastModeAtStart`): on for a new conversation, whose settings + * may opt in to it per session, or either value beside the agent Arguments' own `--settings`. + * Those settings now read: a new conversation's opt-in drops it, as before, and so do the guards a + * live Fast write takes. The value still to be applied, or null. */ +export function admitClaudeStartFastMode( + session: ClaudeSession, + facts: ClaudeStartupFacts +): string | null { + const fastMode = session.options.get('fastMode') + if (!session.fastModeAtStart || fastMode === undefined) { + return null + } + if (fastMode !== 'true') { + return fastMode + } + if (!facts.resumesTranscript && facts.prepared.fastModePerSessionOptIn === true) { + session.options.delete('fastMode') + return null + } + // Over the listing this start already holds. + const listed = listedModels({ models: readClaudeModels(facts.initialization) }) + const blocked = + session.fastModeDisabledReason !== undefined && + !['preference', 'sdk_opt_in_required'].includes(session.fastModeDisabledReason) + if ( + blocked || + (listed.length > 0 && claudeModelFastModeSupport(session, listed).supported !== true) + ) { + session.options.delete('fastMode') + session.restoreSkippedOptions.add('fastMode') + return null + } + return fastMode +} + +/** Applies a saved Fast the launch left out after `started`, so nothing waits on it. A refusal + * drops it as main's refused restore did, from the record too; silence keeps it wanted and + * unconfirmed. A write the user made meanwhile owns the option. */ +export async function applyClaudeStartFastMode( + session: ClaudeSession, + facts: ClaudeStartupFacts, + fastMode: string, + report: (event: ClaudeStartupReport) => void +): Promise { + const sequence = session.optionMutationSequence + try { + await session.connection.applyFlagSettings( + { fastMode: fastMode === 'true' }, + { timeoutMs: facts.requestTimeoutMs } + ) + } catch (error) { + if (sequence !== session.optionMutationSequence) { + return + } + session.confirmedOptions.delete('fastMode') + if (error instanceof ClaudeControlRequestError) { + session.options.delete('fastMode') + session.restoreSkippedOptions.add('fastMode') + report({ type: 'options-skipped', options: { fastMode } }) + } + } +} From 6462ce7b0bb47a9afa6ddde8c3bca7e3272fbde3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:14:18 -0700 Subject: [PATCH 20/22] test(native-chat): the hand-built Claude session in the options test carries fastModeAtStart --- src/main/claude/claude-structured-options.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index 52aa7767506e..2843d4f7ddd7 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -39,6 +39,7 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe confirmedOptions: new Set(), restoreSkippedOptions: new Set(), launchedModel: null, + fastModeAtStart: false, capabilities: [], events: undefined, translator: null, From f12847d09819df57d6d4abb6c51d86c92dbceb7b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:52:44 -0700 Subject: [PATCH 21/22] test(native-chat): the queued rig's start spy carries a named Mock type An unannotated vi.fn() inferred @vitest/spy's internal Procedure, which CI's typecheck cannot name in the factories' inferred return types (TS2883). --- ...ed-agent-session-queued-message-rig-provider.test-fixture.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts index a3c825b51a67..ccabd39503e6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts @@ -34,7 +34,7 @@ export function createQueuedRigProvider( state: 'admitted' as const })) // Every start the host asks for; one `holdNextStart` holds stays in its spawn until released. - const starts = vi.fn() + const starts: Mock<() => void> = vi.fn() let startHold: Promise | null = null // The provider's receipt of a /compact; its end arrives later, as `finishCompact` writes it. const compact: Mock> = vi.fn(async () => ({ From dbcc7b5675b356c89f97577c8d340edebe08c09a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:21:20 -0700 Subject: [PATCH 22/22] fix(ci): run the PR's SQLite-backed tests in the Node runtime project Lists the hung-start Stop test, renames the send-during-startup entry from its old name, and carries main's own two entries from #26010 so the boundary test passes before the next merge. --- config/scripts/vitest-sqlite-database-runtime-files.mjs | 2 +- config/scripts/vitest-sqlite-runtime-files.mjs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index a73125eb34ff..dbac2d6cb403 100644 --- a/config/scripts/vitest-sqlite-database-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-database-runtime-files.mjs @@ -29,7 +29,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/runtime/claude-structured-exit-mid-response.test.ts', 'src/main/runtime/claude-structured-failed-start-resume.test.ts', 'src/main/runtime/claude-structured-resumed-start-failure.test.ts', - 'src/main/runtime/claude-structured-send-held-for-startup.test.ts', + 'src/main/runtime/claude-structured-send-during-startup.test.ts', 'src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts', 'src/main/runtime/claude-structured-session-integration.test.ts', 'src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts', diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 109048a5e3b8..ce88338fb7de 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -46,6 +46,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts', @@ -156,6 +157,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/host-terminal-close-persistence-durability.test.ts', 'src/main/runtime/orca-runtime-terminal-close-records.test.ts', 'src/main/runtime/orca-runtime-terminal-surface-close.test.ts', + 'src/main/runtime/orchestration/orchestration-party-location.test.ts', 'src/main/runtime/orchestration/structured-worker-journal-page.test.ts', 'src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts', 'src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts', @@ -168,6 +170,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts', 'src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts', 'src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts', + 'src/main/runtime/runtime-orchestration-sender-names.test.ts', 'src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts', 'src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts', 'src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts',