From d98b8eddb41eb6c6cc5a25e10d9099bb0e023e35 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:50:18 -0700 Subject: [PATCH] fix(chat): keep resumed Claude messages deliverable after settings reports (#30982) * fix(chat): separate applied Claude choices from provider reports * fix(chat): preserve Max effort without settings readback * test(chat): retain Max effort confirmation contract * test(chat): a literal model change no longer pre-flights the catalog * fix(chat): record observed execution independently of accepted choices * fix(chat): fence model reports at prompt input before recording * test(chat): inspect Fast evidence without unsafe outcome access * test(chat): honor input preparation in turn fixtures * test(claude): accept dispatch metadata after stopping a turn * fix: retire deferred chat options at input preparation --- .../acp/acp-structured-session-adapter.ts | 2 + .../claude-stream-json-connection.test.ts | 2 +- .../claude-structured-applied-options.test.ts | 6 +- ...claude-structured-dispatch-test-support.ts | 2 +- ...claude-structured-effort-reporting.test.ts | 6 +- ...laude-structured-execution-observations.ts | 53 ++++++ ...ude-structured-execution-recording.test.ts | 176 ++++++++++++++++++ ...ed-journal-translation-turn-timing.test.ts | 3 +- .../claude-structured-message-options.test.ts | 87 ++++++++- .../claude-structured-message-options.ts | 89 +++++---- ...aude-structured-model-confirmation.test.ts | 2 +- .../claude-structured-model-preflight.test.ts | 76 ++------ ...ude-structured-option-confirmation.test.ts | 2 +- .../claude/claude-structured-options.test.ts | 7 +- src/main/claude/claude-structured-options.ts | 87 ++++----- .../claude-structured-retired-model.test.ts | 13 +- .../claude/claude-structured-retired-model.ts | 4 +- .../claude-structured-session-acquisition.ts | 32 ++-- ...e-structured-session-adapter-turns.test.ts | 6 +- .../claude-structured-session-adapter.ts | 15 +- .../claude-structured-session-options.ts | 39 +--- .../claude-structured-session-publication.ts | 2 +- .../claude-structured-session-startup.ts | 28 +-- .../claude/claude-structured-session-state.ts | 6 +- .../claude/claude-structured-spawn-options.ts | 11 +- src/main/claude/claude-turn-ownership.test.ts | 5 +- .../codex/codex-structured-session-adapter.ts | 1 + .../structured-agent-session-adapter.ts | 4 + .../structured-agent-session-handover.ts | 8 +- .../structured-agent-session-host-delivery.ts | 9 +- ...ured-agent-session-message-choices.test.ts | 43 +++++ .../structured-agent-session-ran-options.ts | 46 ++++- src/main/pi/rpc-message-dispatch.ts | 5 + 33 files changed, 602 insertions(+), 275 deletions(-) create mode 100644 src/main/claude/claude-structured-execution-observations.ts create mode 100644 src/main/claude/claude-structured-execution-recording.test.ts diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index b23068f8ba7..cdcb46ef540 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -189,6 +189,8 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return rejectedAcpDispatch(session) } const { sessionId, fence, signal } = input + signal?.throwIfAborted() + input.onInputPreparation?.() const optionsApplied = await applyAcpMessageOptions( session, input.executionOptions, diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 4ab8d8550e5..746ded29127 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -558,7 +558,7 @@ describe('Claude stream-json connection', () => { const session = { connection, options: new Map(), - reusableOptions: new Map(), + appliedInputOptions: new Map(), reportedOptions: {}, startup: { state: 'proven' } } as unknown as ClaudeSession diff --git a/src/main/claude/claude-structured-applied-options.test.ts b/src/main/claude/claude-structured-applied-options.test.ts index 8f5d8379e65..bb762ec8d29 100644 --- a/src/main/claude/claude-structured-applied-options.test.ts +++ b/src/main/claude/claude-structured-applied-options.test.ts @@ -128,7 +128,7 @@ describe('Claude model before the first turn', () => { // The settings read follows `started`; what it shows is persisted as the later report. const reported = events.find((event) => event.type === 'options-reported') - expect(reported).toMatchObject({ reportedOptions: { model: 'haiku' } }) + expect(reported).toMatchObject({ reportedOptions: { model: 'claude-haiku-4-5-20251001' } }) }) it('shows the applied effort but never persists it as the session options', async () => { @@ -136,7 +136,7 @@ describe('Claude model before the first turn', () => { const adapter = await acquired(startedWithoutATurn(SETTINGS.noOverride), {}, events) const started = await claudeOpened(adapter, 'session-1') - expect(started).toMatchObject({ reportedOptions: { model: 'opus[1m]' } }) + expect(started.reportedOptions).not.toHaveProperty('model') // Saved, it would be restored on every reopen past a later settings.json change. expect(started.reportedOptions).not.toHaveProperty('effort') const reported = events.find((event) => event.type === 'options-reported') @@ -174,7 +174,7 @@ describe('Claude model before the first turn', () => { // `started` is persisted as the session's options; opus's effort must not ride along onto haiku. const started = await claudeOpened(adapter, 'session-1') - expect(started).toMatchObject({ reportedOptions: { model: 'haiku' } }) + expect(started.reportedOptions).not.toHaveProperty('model') expect(started.reportedOptions.effort).toBeUndefined() }) }) diff --git a/src/main/claude/claude-structured-dispatch-test-support.ts b/src/main/claude/claude-structured-dispatch-test-support.ts index 97fb9fa6d83..fc222a561fc 100644 --- a/src/main/claude/claude-structured-dispatch-test-support.ts +++ b/src/main/claude/claude-structured-dispatch-test-support.ts @@ -29,7 +29,7 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C reportedOptions: {}, reportedModelMutation: 0, confirmedOptions: new Set(), - reusableOptions: new Map(), + appliedInputOptions: new Map(), launchedModel: null, fastModeAtStart: false, capabilities: [], diff --git a/src/main/claude/claude-structured-effort-reporting.test.ts b/src/main/claude/claude-structured-effort-reporting.test.ts index 98042025ecc..68040c3ebcf 100644 --- a/src/main/claude/claude-structured-effort-reporting.test.ts +++ b/src/main/claude/claude-structured-effort-reporting.test.ts @@ -29,7 +29,7 @@ function sessionWith( optionMutationSequence: 0, reportedModelMutation: 0, confirmedOptions: new Set(), - reusableOptions: new Map(), + appliedInputOptions: new Map(), connection: { supportedModels: async () => { calls.push('list_models') @@ -195,7 +195,7 @@ describe('Claude effort against the model that must run it', () => { }) it('sends `max`, which the readback cannot report, when the model advertises it', async () => { - // UNREPORTED_EFFORTS still governs: no get_settings, so no false disagreement. + // The underlying setting may differ from a transient execution effort. const { session, calls } = sessionWith('high', [], { model: 'sonnet', catalog: [SONNET] }) await expect( @@ -203,7 +203,7 @@ describe('Claude effort against the model that must run it', () => { ).resolves.toEqual({ model: 'sonnet', effort: 'max' }) expect(calls).toEqual(['list_models', 'apply:max']) await readClaudeStructuredSessionOptions(session, undefined) - expect(session.reusableOptions.get('effort')).toBe('max') + expect(session.appliedInputOptions.get('effort')).toBe('max') expect(session.confirmedOptions.has('effort')).toBe(false) }) diff --git a/src/main/claude/claude-structured-execution-observations.ts b/src/main/claude/claude-structured-execution-observations.ts new file mode 100644 index 00000000000..a6f6f0b4cb3 --- /dev/null +++ b/src/main/claude/claude-structured-execution-observations.ts @@ -0,0 +1,53 @@ +import type { ClaudeSession } from './claude-structured-session-state' + +/** Provider observations only; requested choices and picker defaults cannot vouch for a run. */ +export function claudeExecutionObservations(session: ClaudeSession): Record { + const model = + session.reportedModelMutation === session.optionMutationSequence + ? (session.reportedOptions.model ?? session.appliedOptions?.model) + : session.appliedOptions?.model + const effort = session.appliedOptions?.effort + const fast = + session.fastModeReportedMutation === session.optionMutationSequence && + session.fastModeState === 'on' && + session.reportedOptions.fastMode !== false + return { + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + ...(fast ? { fastMode: 'true' } : {}) + } +} + +/** Retire affected evidence before a control can change the child, even if its answer is lost. */ +export function beginClaudeOptionMutation(session: ClaudeSession, key: string): number { + const previous = session.optionMutationSequence++ + const sequence = session.optionMutationSequence + if (key === 'model') { + delete session.appliedOptions + } else { + if (session.reportedModelMutation === previous) { + session.reportedModelMutation = sequence + } + if (key === 'effort' && session.appliedOptions) { + const { effort: _old, ...applied } = session.appliedOptions + session.appliedOptions = applied + } + } + if (key === 'model' || key === 'fastMode') { + delete session.fastModeReportedMutation + } else if (session.fastModeReportedMutation === previous) { + session.fastModeReportedMutation = sequence + } + return sequence +} + +/** An init names the model that runs; a control answer only acknowledges its input. */ +export function observeClaudeExecutionModel(session: ClaudeSession, model: string): boolean { + const previous = claudeExecutionObservations(session).model + if (previous !== model) { + delete session.appliedOptions + } + session.reportedOptions.model = model + session.reportedModelMutation = session.optionMutationSequence + return previous !== model +} diff --git a/src/main/claude/claude-structured-execution-recording.test.ts b/src/main/claude/claude-structured-execution-recording.test.ts new file mode 100644 index 00000000000..95aa49edf59 --- /dev/null +++ b/src/main/claude/claude-structured-execution-recording.test.ts @@ -0,0 +1,176 @@ +import { expect, it } from 'vitest' +import { + acquired, + claudeFrame, + fakeClaude, + USER_MESSAGE +} from './claude-structured-session-test-support' +import { recordAgentSessionRanOptions } from '../runtime/agent-session-record-options' +import { + createQueuedMessageTestRig, + eventually +} from '../native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' + +it.each(['low', 'high', undefined])( + 'admits accepted high independently of resolved effort (effective %s)', + async (effective) => { + const models = [ + { + value: 'sonnet', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'], + supportsFastMode: true + } + ] + const claude = fakeClaude({ + initModels: models, + routes: { list_models: () => models }, + settings: { + ...(effective ? { applied: { model: 'sonnet', effort: 'low' } } : {}), + effective: { effortLevel: effective, fastMode: false } + } + }) + const adapter = await acquired(claude) + const rig = await createQueuedMessageTestRig() + const choices = { model: 'sonnet', effort: 'high', fastMode: 'true' } as const + try { + rig.dispatch.mockImplementation((input) => + adapter.dispatch({ ...input, sessionId: 'session-1', fence: 7 }) + ) + const first = rig.send('first', undefined, { carried: choices }) + expect(await first.result).toMatchObject({ ok: true }) + await eventually(() => + expect(rig.store.getRecord(SESSION)?.options).toEqual({ + model: 'sonnet', + ...(effective ? { effort: 'low' } : {}) + }) + ) + const outcome = await rig.dispatch.mock.results[0]!.value + expect(outcome).toEqual({ + state: 'admitted', + ranOptions: effective ? { model: 'sonnet', effort: 'low' } : {} + }) + expect(outcome.ranOptions).not.toHaveProperty('fastMode') + const record = rig.store.getRecord(SESSION)! + expect( + recordAgentSessionRanOptions( + { ...record, options: { model: 'sonnet', effort: 'high' } }, + { + sessionId: SESSION, + fence: record.lease.runtimeFence, + model: 'sonnet', + options: outcome.ranOptions!, + models: undefined, + now: record.updatedAt + } + ).options + ).toEqual({ model: 'sonnet', effort: 'high' }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'again', + body: USER_MESSAGE, + executionOptions: choices, + fence: 7 + }) + expect( + claude.connections[0]!.calls.filter( + (call) => + call.subtype === 'apply_flag_settings' && JSON.stringify(call.params).includes('high') + ) + ).toHaveLength(1) + expect(claude.connections[0]!.sent).toHaveLength(2) + } finally { + await adapter.closeAll() + await rig.dispose() + } + } +) + +it('retires earlier Fast routing when an effort read reports Fast off', async () => { + let fastMode = true + const models = [ + { + value: 'sonnet', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'], + supportsFastMode: true + } + ] + const claude = fakeClaude({ + initModels: models, + routes: { list_models: () => models }, + settings: { + applied: { model: 'sonnet', effort: 'low' }, + get effective() { + return { effortLevel: 'high', fastMode } + } + } + }) + const adapter = await acquired(claude) + const send = (effort: string) => + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: effort, + body: USER_MESSAGE, + executionOptions: { model: 'sonnet', effort, fastMode: 'true' }, + fence: 7 + }) + try { + await send('high') + claudeFrame(claude.connections[0]!, { + type: 'system', + subtype: 'status', + fast_mode_state: 'on' + }) + fastMode = false + expect(await send('low')).toMatchObject({ state: 'admitted', ranOptions: { effort: 'low' } }) + expect(await send('low')).toEqual({ state: 'admitted', ranOptions: { effort: 'low' } }) + } finally { + await adapter.closeAll() + } +}) + +it('never pairs an earlier unknown-model low run with the next prepared model', async () => { + const applied: { model?: string; effort: string } = { effort: 'low' } + const claude = fakeClaude({ initModel: '', settings: { applied } }) + const events: Parameters[2] = [] + const adapter = await acquired(claude, {}, events) + const rig = await createQueuedMessageTestRig() + try { + rig.dispatch.mockImplementation((input) => + adapter.dispatch({ ...input, sessionId: 'session-1', fence: 7 }) + ) + const first = rig.send('first') + await first.result + await eventually(async () => + expect((await rig.submission(first.id))?.handedOverAt).toBeDefined() + ) + expect(await rig.dispatch.mock.results[0]!.value).toEqual({ + state: 'admitted', + ranOptions: { effort: 'low' } + }) + expect(rig.store.getRecord(SESSION)?.options).toBeUndefined() + await rig.settleAccepted(first.id, 'a') + claude.routes.set_model = async () => { + claudeFrame(claude.connections[0]!, { type: 'system', subtype: 'init', model: 'B' }) + const report = events.findLast((event) => event.type === 'options-reported')! + await rig.host.handleAdapterEvent({ + ...report, + sessionId: SESSION, + fence: rig.store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: 'generation-1' + }) + expect(rig.store.getRecord(SESSION)?.options).toBeUndefined() + Object.assign(applied, { model: 'B', effort: 'high' }) + } + await rig.send('second', undefined, { carried: { model: 'B', effort: 'high' } }).result + await eventually(() => + expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'B', effort: 'high' }) + ) + expect(claude.connections[0]!.sent).toHaveLength(2) + } finally { + await adapter.closeAll() + await rig.dispose() + } +}) diff --git a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts index 35f3422c187..8d6f59e83eb 100644 --- a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts +++ b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts @@ -296,14 +296,13 @@ describe('Claude structured turn timing', () => { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'ship it' }] }, fence: 7 }) - await Promise.resolve() + await dispatch const connection = claude.connections[0]! connection.handlers.onMessage?.({ ...connection.sent[0], uuid: 'turn-1', timestamp: '2001-01-01T00:00:00.000Z' }) - await dispatch connection.handlers.onMessage?.({ type: 'assistant', uuid: 'assistant-1', diff --git a/src/main/claude/claude-structured-message-options.test.ts b/src/main/claude/claude-structured-message-options.test.ts index dabdf71be3f..39eb600de1a 100644 --- a/src/main/claude/claude-structured-message-options.test.ts +++ b/src/main/claude/claude-structured-message-options.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { acquired, + adapterFor, claudeFrame, fakeClaude, + identityFor, + recordingJournalSink, USER_MESSAGE } from './claude-structured-session-test-support' import { captureStructuredExecutionOptions } from '../native-chat/agent-session-wire/structured-agent-session-execution-options' @@ -26,6 +29,85 @@ const MODELS = [ ] describe('Claude accepted message choices', () => { + it.each([false, true])( + 'delivers resumed choices despite optional reports (alias listed: %s)', + async (listed) => { + const saved = { model: 'opus[1m]', effort: 'high', fastMode: 'false' } as const + const models = listed ? [...MODELS, { value: saved.model }] : MODELS + const claude = fakeClaude({ initModels: models, routes: { list_models: () => models } }) + const adapter = adapterFor(claude, { resumesTranscript: true }) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'resume', + options: saved, + events: recordingJournalSink() + }) + const connection = claude.connections[0]! + claudeFrame(connection, { type: 'system', subtype: 'init', model: 'claude-opus-5' }) + connection.calls.length = 0 + for (const clientMessageId of ['restart', 'followup']) { + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId, + body: USER_MESSAGE, + executionOptions: saved, + fence: 7 + }) + ).resolves.toMatchObject({ state: 'admitted' }) + await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + } + expect( + connection.calls.filter((call) => + ['set_model', 'apply_flag_settings'].includes(call.subtype) + ) + ).toEqual([]) + for (const clientMessageId of ['changed', 'unchanged']) { + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId, + body: USER_MESSAGE, + executionOptions: { ...saved, model: 'custom[1m]', effort: 'low' }, + fence: 7 + }) + ).resolves.toMatchObject({ state: 'admitted' }) + } + expect( + connection.calls.filter((call) => + ['set_model', 'apply_flag_settings'].includes(call.subtype) + ) + ).toEqual([ + { subtype: 'set_model', params: { model: 'custom[1m]' } }, + { subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'low' } } } + ]) + const failure = new Error('control refused') + connection.setModel = async () => { + throw failure + } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: `refused-${listed}`, + body: USER_MESSAGE, + executionOptions: { model: 'another' }, + fence: 7 + }) + ).resolves.toMatchObject({ state: 'rejected' }) + expect(connection.sent).toHaveLength(4) + expect(warn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ error: failure }) + ) + } finally { + warn.mockRestore() + } + } + ) + it.each([ ['the effort the CLI applied over the settings level', 'low', { effort: 'low' }], ['no effort where the CLI applies none, whatever the settings say', null, {}] @@ -57,7 +139,10 @@ describe('Claude accepted message choices', () => { executionOptions: {}, fence: 7 }) - expect(outcome).toEqual({ state: 'admitted', ranOptions }) + expect(outcome).toEqual({ + state: 'admitted', + ranOptions: applied ? { model: 'claude-sonnet-5', ...ranOptions } : {} + }) }) it('applies a held message A before its prompt, then message B on the existing child', async () => { diff --git a/src/main/claude/claude-structured-message-options.ts b/src/main/claude/claude-structured-message-options.ts index 0e6c35fbd72..38610e964bb 100644 --- a/src/main/claude/claude-structured-message-options.ts +++ b/src/main/claude/claude-structured-message-options.ts @@ -1,12 +1,20 @@ +import { claudeExecutionObservations } from './claude-structured-execution-observations' import { dispatchClaudeTurn } from './claude-structured-dispatch' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { + createStructuredAgentSessionLogger, + neverThrowingStructuredAgentSessionLogger, + type StructuredAgentSessionLogger +} from '../native-chat/agent-session-wire/structured-agent-session-logger' import { listedModels } from './claude-structured-model-catalog' import { setClaudeStructuredOption } from './claude-structured-options' import type { ClaudeSession } from './claude-structured-session-state' import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +const defaultLogger = createStructuredAgentSessionLogger() + /** Applies the accepted message's choices while its prompt is still in the SDK input queue. */ export async function applyClaudeMessageOptions( session: ClaudeSession, @@ -16,7 +24,7 @@ export async function applyClaudeMessageOptions( ): Promise { throwIfSignalAborted(signal) const model = options.model ?? (await defaultModelForMessage(session, timeoutMs, signal)) - if (model && session.reusableOptions.get('model') !== model) { + if (model && session.appliedInputOptions.get('model') !== model) { await waitForPromiseWithSignal( setClaudeStructuredOption(session, { key: 'model', value: model }, timeoutMs), signal @@ -25,28 +33,24 @@ export async function applyClaudeMessageOptions( throwIfSignalAborted(signal) const effort = options.effort ?? session.configuredDefaults?.effort - if (effort && session.reusableOptions.get('effort') !== effort) { + if (effort && session.appliedInputOptions.get('effort') !== effort) { await waitForPromiseWithSignal( setClaudeStructuredOption(session, { key: 'effort', value: effort }, timeoutMs), signal ) } else if (!effort && session.options.has('effort')) { + // Omission restores configuration; without its value a live control cannot clear the override. throw new AgentSessionOptionRejectedError('Claude did not report its configured effort') } throwIfSignalAborted(signal) const fastMode = options.fastMode - if (fastMode !== undefined && session.reusableOptions.get('fastMode') !== fastMode) { + if (fastMode !== undefined && session.appliedInputOptions.get('fastMode') !== fastMode) { await waitForPromiseWithSignal( setClaudeStructuredOption(session, { key: 'fastMode', value: fastMode }, timeoutMs), signal ) } - for (const [key, value] of Object.entries({ model, effort, fastMode })) { - if (value !== undefined && session.reusableOptions.get(key) !== value) { - throw new AgentSessionOptionRejectedError(`Claude did not apply ${key} ${value}`) - } - } throwIfSignalAborted(signal) } @@ -74,50 +78,45 @@ async function defaultModelForMessage( return fallback } -/** What the turn runs on, from the CLI's own answers: the message's choices it applied, and the - * effort the CLI resolved for the model it applies (never the settings level alone). */ -function claudeRanOptions( - session: ClaudeSession, - options: AgentSessionExecutionOptions -): Record { - const resolvedHere = options.model === undefined || options.model === session.launchedModel - const ran = { - model: options.model, - effort: options.effort ?? (resolvedHere ? session.configuredDefaults?.effort : undefined), - fastMode: options.fastMode - } - return Object.fromEntries( - Object.entries(ran).flatMap(([key, value]) => - value !== undefined && session.reusableOptions.get(key) === value ? [[key, value]] : [] - ) - ) -} - export async function dispatchClaudeMessage( session: ClaudeSession, input: Parameters[0], - timeoutMs: number | undefined + timeoutMs: number | undefined, + logger: StructuredAgentSessionLogger = defaultLogger ): ReturnType { const messageOptions = input.executionOptions - const outcome = await dispatchClaudeTurn( - session, - input, - messageOptions === undefined - ? input.beforeDispatch - : async () => { - await input.beforeDispatch?.() - try { - await applyClaudeMessageOptions(session, messageOptions, timeoutMs, input.signal) - input.signal?.throwIfAborted() - } catch (error) { - throw error instanceof AgentSessionOptionRejectedError - ? error - : new AgentSessionOptionRejectedError(error) - } + let ranOptions: Record = {} + const outcome = await dispatchClaudeTurn(session, input, async () => { + await input.beforeDispatch?.() + try { + input.signal?.throwIfAborted() + input.onInputPreparation?.() + if (messageOptions !== undefined) { + await applyClaudeMessageOptions(session, messageOptions, timeoutMs, input.signal) + } + input.signal?.throwIfAborted() + input.onExecutionSnapshot?.() + ranOptions = claudeExecutionObservations(session) + if (messageOptions?.model && ranOptions.model !== messageOptions.model) { + delete ranOptions.model + } + } catch (error) { + neverThrowingStructuredAgentSessionLogger(logger).warn( + 'applying Claude message choices failed', + { + scope: 'claude-message-options', + sessionId: input.sessionId, + clientMessageId: input.clientMessageId, + error } - ) + ) + throw error instanceof AgentSessionOptionRejectedError + ? error + : new AgentSessionOptionRejectedError(error) + } + }) // Always, even empty: the settings effort the CLI reports is never evidence of a run. return outcome.state === 'accepted' || outcome.state === 'admitted' - ? { ...outcome, ranOptions: claudeRanOptions(session, messageOptions ?? {}) } + ? { ...outcome, ranOptions } : outcome } diff --git a/src/main/claude/claude-structured-model-confirmation.test.ts b/src/main/claude/claude-structured-model-confirmation.test.ts index dd821c426a3..a0087ec2063 100644 --- a/src/main/claude/claude-structured-model-confirmation.test.ts +++ b/src/main/claude/claude-structured-model-confirmation.test.ts @@ -186,7 +186,7 @@ describe('Claude effort the settings readback cannot report', () => { reportedOptions: {}, optionMutationSequence: 0, confirmedOptions: new Set(), - reusableOptions: new Map(), + appliedInputOptions: new Map(), connection: { supportedModels: async () => { calls.push('list_models') diff --git a/src/main/claude/claude-structured-model-preflight.test.ts b/src/main/claude/claude-structured-model-preflight.test.ts index a2eb902e8dd..de94e3e74e6 100644 --- a/src/main/claude/claude-structured-model-preflight.test.ts +++ b/src/main/claude/claude-structured-model-preflight.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { setClaudeStructuredOption } from './claude-structured-options' import type { ClaudeSession } from './claude-structured-session-state' @@ -22,7 +21,7 @@ function sessionWith(catalog: readonly Record[] | 'unavailable' optionMutationSequence: 0, reportedModelMutation: 0, confirmedOptions: new Set(), - reusableOptions: new Map(), + appliedInputOptions: new Map(), connection: { supportedModels: async () => { calls.push('list_models') @@ -41,65 +40,16 @@ function sessionWith(catalog: readonly Record[] | 'unavailable' } } -describe('Claude model pre-flight against the catalog the CLI listed', () => { - it('refuses a model the provider does not list', async () => { - const { session, calls } = sessionWith([DEFAULT_ROW, SONNET, HAIKU]) - - await expect( - setClaudeStructuredOption(session, { key: 'model', value: 'not-a-real-model-xyz' }, undefined) - ).rejects.toBeInstanceOf(AgentSessionOptionRejectedError) - // Measured on Claude Code 2.1.260: set_model resolves for an unlisted id and - // every later turn returns is_error with zero tokens. Nothing undoes the - // write, so the refusal has to land before it. - expect(calls).toEqual(['list_models']) - expect(session.options.has('model')).toBe(false) - }) - - it('applies a model the provider lists', async () => { - const { session, calls } = sessionWith([DEFAULT_ROW, SONNET, HAIKU]) - - await expect( - setClaudeStructuredOption(session, { key: 'model', value: 'haiku' }, undefined) - ).resolves.toEqual({ model: 'haiku' }) - expect(calls).toEqual(['list_models', 'set_model:haiku']) - }) - - it('applies a resolved model id the catalog carries only under its alias', async () => { - const { session, calls } = sessionWith([DEFAULT_ROW, SONNET]) - - await expect( - setClaudeStructuredOption(session, { key: 'model', value: 'claude-sonnet-5' }, undefined) - ).resolves.toEqual({ model: 'claude-sonnet-5' }) - expect(calls).toEqual(['list_models', 'set_model:claude-sonnet-5']) - }) - - it('refuses nothing when list_models is unavailable', async () => { - // A CLI predating list_models would otherwise have every model refused. - const { session, calls } = sessionWith('unavailable') - - await expect( - setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined) - ).resolves.toEqual({ model: 'sonnet' }) - expect(calls).toEqual(['list_models', 'set_model:sonnet']) - }) - - it('refuses nothing when the listed catalog is empty', async () => { - // An empty answer identifies no model, so it is not evidence against one. - const { session, calls } = sessionWith([]) - - await expect( - setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined) - ).resolves.toEqual({ model: 'sonnet' }) - expect(calls).toEqual(['list_models', 'set_model:sonnet']) - }) - - it('refuses nothing when the catalog carries only the synthetic default row', async () => { - // listedModels drops that row, leaving a list that identifies no model. - const { session, calls } = sessionWith([DEFAULT_ROW]) - - await expect( - setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined) - ).resolves.toEqual({ model: 'sonnet' }) - expect(calls).toEqual(['list_models', 'set_model:sonnet']) - }) +describe('Claude literal model controls', () => { + it.each(['opus[1m]', 'claude-sonnet-5'])( + 'passes %s through an incomplete catalog', + async (model) => { + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET, HAIKU]) + await expect( + setClaudeStructuredOption(session, { key: 'model', value: model }, undefined) + ).resolves.toEqual({ model }) + expect(calls).toEqual([`set_model:${model}`]) + expect(session.appliedInputOptions.get('model')).toBe(model) + } + ) }) diff --git a/src/main/claude/claude-structured-option-confirmation.test.ts b/src/main/claude/claude-structured-option-confirmation.test.ts index 26b5e790d8f..5cd42112f85 100644 --- a/src/main/claude/claude-structured-option-confirmation.test.ts +++ b/src/main/claude/claude-structured-option-confirmation.test.ts @@ -168,7 +168,7 @@ describe('confirmation never outlives the write it belongs to', () => { reportedOptions: {}, optionMutationSequence: 0, confirmedOptions: new Set(), - reusableOptions: new Map(), + appliedInputOptions: new Map(), connection: { supportedModels: async () => CATALOG, applyFlagSettings: async (s: { effortLevel?: string }) => { diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index 4fa6d0f5c27..d8afe582879 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -38,7 +38,7 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe reportedOptions: {}, reportedModelMutation: 0, confirmedOptions: new Set(), - reusableOptions: new Map(), + appliedInputOptions: new Map(), launchedModel: null, fastModeAtStart: false, capabilities: [], @@ -296,6 +296,7 @@ describe('Claude structured Fast mode', () => { setClaudeStructuredOption(session, { key: 'model', value: 'haiku' }, undefined) ).resolves.toMatchObject({ model: 'haiku', fastMode: 'false' }) expect(applyFlagSettings).toHaveBeenCalledWith({ fastMode: false }, { timeoutMs: undefined }) + expect(session.appliedInputOptions.get('fastMode')).toBe('false') }) it('keeps Fast on across a model switch while support discovery is transient', async () => { @@ -346,8 +347,8 @@ describe('Claude structured Fast mode', () => { ).resolves.toMatchObject({ model: 'haiku', fastMode: 'false' }) expect(session.reportedOptions.fastMode).toBe(true) expect(session.confirmedOptions.has('fastMode')).toBe(false) - expect(session.reusableOptions.get('model')).toBe('haiku') - expect(session.reusableOptions.has('fastMode')).toBe(false) + expect(session.appliedInputOptions.get('model')).toBe('haiku') + expect(session.appliedInputOptions.has('fastMode')).toBe(false) }) it('treats an unrecognized provider disabled reason as unavailable', async () => { diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index d49296f07b0..d6612a75917 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -2,10 +2,10 @@ import type { EffortLevel } from '@anthropic-ai/claude-agent-sdk' import { ClaudeControlRequestError } from './claude-stream-json-connection' import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { - claudeCatalogAdmitsModel, claudeModelEffortLevels, claudeModelFastModeSupport, readClaudeCurrentModel, + observeClaudeSettingsApplied, readClaudeListedModels, readClaudeSettingsEffort, readClaudeSettingsFastMode @@ -13,15 +13,9 @@ import { import type { ClaudeSession } from './claude-structured-session-state' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' -const OPTION_ORDER = ['model', 'effort', 'fastMode'] as const +import { beginClaudeOptionMutation } from './claude-structured-execution-observations' -/** - * Efforts the settings readback cannot report. `max` applies for the rest of the - * session and is excluded from the persisted `effortLevel` by contract, so - * `get_settings` answers with the level underneath it — an absence of evidence - * that must not be read as the child refusing a level its own catalog offers. - */ -const UNREPORTED_EFFORTS: ReadonlySet = new Set(['max']) +const OPTION_ORDER = ['model', 'effort', 'fastMode'] as const export function restoredClaudeStructuredSessionOptions( options: Readonly> | undefined @@ -81,7 +75,7 @@ export async function setClaudeStructuredOption( // 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. const needsCatalog = - input.key === 'model' || + (input.key === 'model' && session.options.get('fastMode') === 'true') || (input.key === 'fastMode' && fastMode === true) || (input.key === 'effort' && readClaudeCurrentModel(session).id !== undefined) const listed = needsCatalog ? await readClaudeListedModels(session, timeoutMs) : [] @@ -102,7 +96,7 @@ export async function setClaudeStructuredOption( } 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 + // rule used for unknown capability evidence — otherwise a CLI that cannot answer has Fast // refused on every model. A catalog that did list the model and stayed silent // about Fast is still not positive evidence, so that case keeps refusing. if (fastMode && listed.length > 0 && support.supported !== true) { @@ -120,42 +114,25 @@ 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. - 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) : null - const modelWasConfirmed = readClaudeCurrentModel(session).confirmed - session.reusableOptions.delete(input.key) - if (input.key === 'model') { - session.reusableOptions.delete('effort') - session.reusableOptions.delete('fastMode') - } - const mutationSequence = ++session.optionMutationSequence + session.appliedInputOptions.delete(input.key) + const mutationSequence = beginClaudeOptionMutation(session, input.key) // Read with the fence bump, so a write that lands after an earlier one's bookkeeping compares against it. const held = session.options.get(input.key) - // Only a model write can stale the model report — an effort or Fast 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 - // effort write, a level the model actually running advertises. - if (modelWasConfirmed && input.key !== 'model') { - session.reportedModelMutation = mutationSequence - } try { await apply() + if (mutationSequence === session.optionMutationSequence) { + session.appliedInputOptions.set(input.key, input.value) + } // Ahead of the fence checks: the child applied this write even if a newer one supersedes its bookkeeping. if (input.key === 'model' && input.value !== held) { session.translator?.modelMayHaveChanged() } if (input.key === 'model') { session.translator?.modelWritten(input.value) - // It described the model this write replaced; the next readback re-reads it. - delete session.appliedOptions } if ( input.key === 'model' && @@ -165,13 +142,20 @@ export async function setClaudeStructuredOption( if (mutationSequence !== session.optionMutationSequence) { return Object.fromEntries(session.options) } - session.reusableOptions.set('model', input.value) session.options.set('model', input.value) session.options.set('fastMode', 'false') session.confirmedOptions.delete('effort') session.confirmedOptions.delete('fastMode') // The requested model is already accepted; a cleanup failure cannot reject that write. - await session.connection.applyFlagSettings({ fastMode: false }, { timeoutMs }).catch(() => {}) + session.appliedInputOptions.delete('fastMode') + await session.connection.applyFlagSettings({ fastMode: false }, { timeoutMs }).then( + () => { + if (mutationSequence === session.optionMutationSequence) { + session.appliedInputOptions.set('fastMode', 'false') + } + }, + () => {} + ) return Object.fromEntries(session.options) } } catch (error) { @@ -180,29 +164,30 @@ export async function setClaudeStructuredOption( } throw error } - // apply_flag_settings answers `success` for an effort it then ignores, so the - // absence of a throw proves nothing. Ask what the child actually holds. - const adopted = - (input.key === 'effort' && !UNREPORTED_EFFORTS.has(input.value)) || input.key === 'fastMode' - ? await session.connection - .getSettings({ timeoutMs }) - .then((settings) => - input.key === 'fastMode' - ? readClaudeSettingsFastMode(settings) - : readClaudeSettingsEffort(settings) - ) - .catch(() => null) + // Readback confirms presentation; accepted input remains independent of optional reports. + // Max is session-only; settings readback reports the underlying level. + const settings = + (input.key === 'effort' && input.value !== 'max') || input.key === 'fastMode' + ? await session.connection.getSettings({ timeoutMs }).catch(() => null) : null if (mutationSequence !== session.optionMutationSequence) { return Object.fromEntries(session.options) } - if (input.key === 'model' || (input.key === 'effort' && UNREPORTED_EFFORTS.has(input.value))) { - session.reusableOptions.set(input.key, input.value) - } else if (adopted !== null) { - session.reusableOptions.set(input.key, String(adopted)) + if (settings !== null) { + observeClaudeSettingsApplied(session, settings) + if (readClaudeSettingsFastMode(settings) === false) { + delete session.fastModeReportedMutation + } } + const adopted = + input.key === 'fastMode' + ? readClaudeSettingsFastMode(settings) + : readClaudeSettingsEffort(settings) if (input.key === 'fastMode' && typeof adopted === 'boolean') { session.reportedOptions.fastMode = adopted + if (!adopted) { + delete session.fastModeReportedMutation + } } // A disagreement stops main vouching for the value, it does not veto the write: // the pre-flight guard already refused levels the model advertises no control for, diff --git a/src/main/claude/claude-structured-retired-model.test.ts b/src/main/claude/claude-structured-retired-model.test.ts index d846553af1d..d7a5d71d4c1 100644 --- a/src/main/claude/claude-structured-retired-model.test.ts +++ b/src/main/claude/claude-structured-retired-model.test.ts @@ -12,7 +12,8 @@ import { tick, identityFor, PROVIDER_SESSION_ID, - recordingJournalSink + recordingJournalSink, + USER_MESSAGE } from './claude-structured-session-test-support' const RETIRED = 'claude-retired-1' @@ -154,6 +155,16 @@ describe('a live child still launched with a retired model', () => { 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 expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'saved-choice', + body: USER_MESSAGE, + executionOptions: { model: RETIRED }, + fence: 7 + }) + ).resolves.toMatchObject({ state: 'admitted' }) + expect(setModelCalls(claude).map((call) => call.params?.model)).toEqual([undefined, RETIRED]) await adapter.closeAll() }) diff --git a/src/main/claude/claude-structured-retired-model.ts b/src/main/claude/claude-structured-retired-model.ts index 3bbe76dc06e..91a9fd31176 100644 --- a/src/main/claude/claude-structured-retired-model.ts +++ b/src/main/claude/claude-structured-retired-model.ts @@ -2,6 +2,7 @@ // 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 { beginClaudeOptionMutation } from './claude-structured-execution-observations' import type { ClaudeSession, ClaudeStructuredSessionAdapterDeps @@ -37,7 +38,8 @@ export function resetClaudeRetiredModel( sessionId: string, retired: string ): void { - const sequence = session.optionMutationSequence + session.appliedInputOptions.delete('model') + const sequence = beginClaudeOptionMutation(session, 'model') void session.connection.setModel(undefined, { timeoutMs: deps.requestTimeoutMs }).then( () => { if (sequence !== session.optionMutationSequence) { diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index c3f4d52a0f9..9b48879d000 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -18,6 +18,7 @@ import { claudeConfigDirEnvPatch } from './claude-config-dir-pin' import { claudeProcessIdentity } from './claude-structured-owner-identity' import { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { adoptClaudeStructuredSpawnOptions } from './claude-structured-spawn-options' +import { observeClaudeExecutionModel } from './claude-structured-execution-observations' import { observeClaudeFastModeFacts } from './claude-structured-session-options' import { createClaudeSessionPublication } from './claude-structured-session-publication' import { @@ -80,7 +81,6 @@ export async function acquireClaudeSession({ ? liveSession.backgroundTasks.goalBackgroundWork : 'unverifiable' ) - const onMessage = (message: Record): void => { const init = readClaudeInit(message) if (readClaudeFrameString(message, 'session_id') !== expectedProviderSessionId) { @@ -91,23 +91,10 @@ export async function acquireClaudeSession({ } return } + const modelChanged = + liveSession && init?.model ? observeClaudeExecutionModel(liveSession, init.model) : false if (init) { initProof.resolve(init) - // Every turn opens with an init frame naming the model the CLI is actually - // running; set_model answers success for a model it never resolves, so this - // report is the session's only adoption evidence. - if (liveSession && init.model) { - if (liveSession.reusableOptions.get('model') !== init.model) { - liveSession.reusableOptions.delete('model') - liveSession.reusableOptions.delete('effort') - liveSession.reusableOptions.delete('fastMode') - } - if (liveSession.optionMutationSequence === 0) { - liveSession.reusableOptions.set('model', init.model) - } - liveSession.reportedOptions.model = init.model - liveSession.reportedModelMutation = liveSession.optionMutationSequence - } if (liveSession) { liveSession.capabilities = readClaudeCapabilities(liveSession.capabilities, init.message) } @@ -140,7 +127,16 @@ export async function acquireClaudeSession({ startsTurn || message.type === 'result' ? { observedAt: deps.now?.() ?? Date.now() } : {} const requestedAt = turnOrigin?.requestedAt const startedSend = !startsTurn && liveSession ? claudeStartedUnechoedSend(liveSession) : null - callbacks.deliver(attempt, sessionId, () => + callbacks.deliver(attempt, sessionId, () => { + if (modelChanged && liveSession && init?.model) { + callbacks.emit(liveSession, input.events, { + type: 'options-reported', + sessionId, + fence: liveSession.fence, + acquisitionGeneration: liveSession.acquisitionGeneration, + reportedOptions: { model: init.model } + }) + } callbacks.emit(liveSession, input.events, { type: 'message', sessionId, @@ -151,7 +147,7 @@ export async function acquireClaudeSession({ ...(startedSend ? { startedSend } : {}), ...observedAt }) - ) + }) for (const settle of settlements) { settle() } diff --git a/src/main/claude/claude-structured-session-adapter-turns.test.ts b/src/main/claude/claude-structured-session-adapter-turns.test.ts index 16cf7c96b48..75d86ba6ab6 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -103,11 +103,9 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { await expect( adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'sonnet', fence: 7 }) ).resolves.toEqual({ model: 'sonnet' }) - // The model write pre-flights the catalog first; this CLI lists nothing, which - // identifies no model and so refuses none. Then it asks for the new model's window. - expect(claude.connections[0].calls.slice(-4)).toEqual([ + // A literal model goes straight to the CLI; then it asks for the new model's window. + expect(claude.connections[0].calls.slice(-3)).toEqual([ { subtype: 'interrupt', params: {} }, - { subtype: 'list_models' }, { subtype: 'set_model', params: { model: 'sonnet' } }, { subtype: 'get_context_usage' } ]) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 60c52bff0ef..417bb364397 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -55,11 +55,6 @@ export type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' -function backgroundTaskState(session: ClaudeSession): AgentSessionBackgroundTaskState | null { - const state = session.backgroundTasks.state - return state ? { ...state, supportsTaskStop: true } : null -} - export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAdapter { private readonly sessions = new Map() private readonly acquisitions = new ClaudeAcquisitionRegistry() @@ -165,7 +160,12 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => - dispatchClaudeMessage(this.session(input.sessionId), input, this.deps.requestTimeoutMs) + dispatchClaudeMessage( + this.session(input.sessionId), + input, + this.deps.requestTimeoutMs, + this.deps.logger + ) // A rewind forks the kept conversation into a new session, which the next launch makes. forkForRewind = claudeForkForRewind @@ -207,7 +207,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda * read from the host's child records. */ backgroundTaskState = (sessionId: string): AgentSessionBackgroundTaskState | null | undefined => { const session = this.sessions.get(sessionId) - return session ? backgroundTaskState(session) : undefined + const state = session?.backgroundTasks.state + return session && (state ? { ...state, supportsTaskStop: true } : null) } backgroundTaskStops: NonNullable = ( sessionId diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index f1cc170f4e6..0226d21819d 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -54,22 +54,6 @@ export function observeClaudeSettingsApplied(session: ClaudeSession, settings: u } } -/** Applied effort describes execution; effective effort describes the underlying settings. */ -export function observeClaudeSettingsExecutionEffort( - session: ClaudeSession, - settings: unknown -): void { - const applied = readClaudeSettingsApplied(settings) - const effort = applied ? applied.effort : readClaudeSettingsEffort(settings) - if (session.reusableOptions.get('effort') !== 'max') { - if (effort) { - session.reusableOptions.set('effort', effort) - } else if (applied) { - session.reusableOptions.delete('effort') - } - } -} - export function readClaudeSettingsFastMode(settings: unknown): boolean | null { const value = record(record(settings)?.effective)?.fastMode return typeof value === 'boolean' ? value : null @@ -103,6 +87,7 @@ export function observeClaudeFastModeFacts(session: ClaudeSession, value: unknow const facts = readClaudeFastModeFacts(value) if (facts.state) { session.fastModeState = facts.state + session.fastModeReportedMutation = session.optionMutationSequence } if (facts.disabledReason) { session.fastModeDisabledReason = facts.disabledReason @@ -208,22 +193,6 @@ function decodedFastMode(session: ClaudeSession): boolean | undefined { return typeof decoded === 'boolean' ? decoded : undefined } -/** - * Whether the catalog admits the model, matched by alias or resolved id so a pick - * stored as either one is found. The permissive case lives here rather than at the - * call site: every caller must treat an unidentified catalog the same way, and one - * that forgot would refuse every model on a CLI that cannot answer. - */ -export function claudeCatalogAdmitsModel(models: readonly ListedModel[], modelId: string): boolean { - // An empty list identifies no model, so it is not evidence against one — a live - // CLI predating `list_models` would otherwise have every model refused under it. - // Do not turn this into a refusal. - return ( - models.length === 0 || - models.some((model) => model.id === modelId || model.resolvedModel === modelId) - ) -} - /** The built-in models a running child lists when the CLI gives it none; a chat at rest with no * catalog lists the same. */ export function claudeFallbackModelOptions(): WireClaudeModel[] { @@ -253,13 +222,14 @@ function observeClaudeSettingsReadback( const fastMode = readClaudeSettingsFastMode(settings) const perSessionOptIn = readClaudeSettingsFastModePerSessionOptIn(settings) observeClaudeSettingsApplied(session, settings) - observeClaudeSettingsExecutionEffort(session, settings) if (effort) { session.reportedOptions.effort = effort } if (fastMode !== null) { - session.reusableOptions.set('fastMode', String(fastMode)) session.reportedOptions.fastMode = fastMode + if (!fastMode) { + delete session.fastModeReportedMutation + } if (decodedFastMode(session) !== undefined) { session.options.set('fastMode', String(fastMode)) } @@ -312,7 +282,6 @@ export function claudeStructuredSessionOptionsFrom( readMutationSequence === session.optionMutationSequence ) { session.options.set('fastMode', 'false') - session.reusableOptions.delete('fastMode') session.confirmedOptions.delete('fastMode') desiredFastMode = false } diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index 7447a97c9e5..b646bbd5aac 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -66,7 +66,7 @@ export function createClaudeSessionPublication(input: { reportedModelMutation: 0, confirmedOptions: new Set(), // Only choices carried in this child's launch can skip their first write. - reusableOptions: new Map( + appliedInputOptions: new Map( [...(input.options ?? [])].filter(([key]) => key === 'model' || key === 'effort') ), launchedModel: null, diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index 13d8e3ccc7e..96d6877dabe 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -22,11 +22,11 @@ import { claudeResolvedCatalogListing, claudeStructuredSessionOptionsFrom, observeClaudeSettingsApplied, - observeClaudeSettingsExecutionEffort, readClaudeSettingsApplied, readClaudeFastModeFacts, readClaudeSettingsEffort } from './claude-structured-session-options' +import { claudeExecutionObservations } from './claude-structured-execution-observations' import type { ClaudeAuthDiagnostic, ClaudeSession, @@ -126,18 +126,14 @@ function applyClaudeInitializeFacts(session: ClaudeSession, facts: ClaudeInitial const { init, initialization } = facts // A turn's own init frame may already have reported the running model. if (init?.model && session.reportedOptions.model === undefined) { - if (session.optionMutationSequence === 0) { - session.reusableOptions.set('model', init.model) - } else if (session.reusableOptions.get('model') !== init.model) { - session.reusableOptions.delete('model') - session.reusableOptions.delete('effort') - session.reusableOptions.delete('fastMode') - } session.reportedOptions.model = init.model session.reportedModelMutation = session.optionMutationSequence } const fastModeFacts = readClaudeFastModeFacts(initialization) - session.fastModeState ??= fastModeFacts.state + if (session.fastModeState === undefined && fastModeFacts.state) { + session.fastModeState = fastModeFacts.state + session.fastModeReportedMutation = 0 + } session.fastModeDisabledReason ??= fastModeFacts.disabledReason session.capabilities = readClaudeCapabilities(session.capabilities, initialization, init?.message) // A catalog frame that streamed in after publish is newer than the initialize answer. @@ -163,7 +159,6 @@ function applyClaudeSettingsFacts( ...(!session.options.has('effort') && applied?.effort ? { effort: applied.effort } : {}) } } - observeClaudeSettingsApplied(session, settings) if (published.fastModePerSessionOptIn !== null) { session.fastModePerSessionOptIn = published.fastModePerSessionOptIn } @@ -171,7 +166,7 @@ function applyClaudeSettingsFacts( session.events?.publish() return } - observeClaudeSettingsExecutionEffort(session, settings) + 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 @@ -187,8 +182,10 @@ function applyClaudeSettingsFacts( session.confirmedOptions.add('effort') } if (published.fastMode !== null) { - session.reusableOptions.set('fastMode', String(published.fastMode)) session.reportedOptions.fastMode = published.fastMode + if (!published.fastMode) { + delete session.fastModeReportedMutation + } if (agrees('fastMode', String(published.fastMode))) { session.confirmedOptions.add('fastMode') } @@ -210,10 +207,13 @@ function claudeStartedReportedOptions( readMutationSequence ) const listing = catalogListing ? { catalogListing } : {} + const { model: _projected, ...rest } = current + const model = claudeExecutionObservations(session).model + const observed = { ...rest, ...(model ? { model } : {}) } if (session.options.has('effort') || session.reportedOptions.effort !== undefined) { - return { reportedOptions: current, ...listing } + return { reportedOptions: observed, ...listing } } - const { effort: _displayOnly, ...persisted } = current + const { effort: _displayOnly, ...persisted } = observed return { reportedOptions: persisted, ...listing } } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index a2913e1503c..33973db3528 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -170,6 +170,8 @@ export type ClaudeSession = { appliedOptions?: { model?: string; effort?: string } /** CLI settings resolved before a turn changed them, when no launch override hid that value. */ configuredDefaults?: { model?: string; effort?: string } + /** Mutation at which the provider last reported its Fast routing state. */ + fastModeReportedMutation?: number fastModeState?: AgentSessionFastModeState fastModeDisabledReason?: string fastModePerSessionOptIn?: boolean @@ -178,8 +180,8 @@ export type ClaudeSession = { reportedModelMutation: number /** Options whose recorded value the provider reported, not merely accepted. */ confirmedOptions: Set - /** Value-bound execution evidence, revoked before any possible write. */ - reusableOptions: Map + /** Inputs carried by launch or accepted controls, independent of provider reports. */ + appliedInputOptions: Map /** The model the child was launched with, as `--model`; null when it runs the CLI's own. */ launchedModel: string | null /** A launched model the CLI answered `model_not_found` for; reported until a new pick. */ diff --git a/src/main/claude/claude-structured-spawn-options.ts b/src/main/claude/claude-structured-spawn-options.ts index da5417039e7..7222b458cc2 100644 --- a/src/main/claude/claude-structured-spawn-options.ts +++ b/src/main/claude/claude-structured-spawn-options.ts @@ -1,4 +1,4 @@ -// Saved choices seed the CLI launch; message choices are applied again before input. +// Saved choices seed the CLI launch; changed message choices apply before input. import type { EffortLevel } from '@anthropic-ai/claude-agent-sdk' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' @@ -88,11 +88,18 @@ export function claudeStructuredSpawnOptions(input: { /** The published session takes on what its child was launched with. */ export function adoptClaudeStructuredSpawnOptions( - session: Pick, + session: Pick< + ClaudeSession, + 'translator' | 'launchedModel' | 'fastModeAtStart' | 'appliedInputOptions' + >, spawn: Pick ): void { session.launchedModel = spawn.options.get('model') ?? null session.fastModeAtStart = spawn.fastModeAtStart + const fastMode = spawn.options.get('fastMode') + if (fastMode !== undefined && !spawn.fastModeAtStart) { + session.appliedInputOptions.set('fastMode', fastMode) + } const model = spawn.options.get('model') if (model !== undefined) { session.translator?.modelWritten(model) diff --git a/src/main/claude/claude-turn-ownership.test.ts b/src/main/claude/claude-turn-ownership.test.ts index 56ad10c1ad3..2ae051b5d65 100644 --- a/src/main/claude/claude-turn-ownership.test.ts +++ b/src/main/claude/claude-turn-ownership.test.ts @@ -198,11 +198,12 @@ describe('Claude turn ownership', () => { }) expect(runningTurnId(bodies)).toBe('echo-turn') const sendFirst = connection.send - connection.send = async (message) => { + connection.send = async (message, beforeDispatch) => { if (connection.sent.length > 0) { + await beforeDispatch?.() throw new Error('input pump stopped') } - await sendFirst(message) + await sendFirst(message, beforeDispatch) } await expect( diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index e59eaef45a1..92e112d8f6f 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -240,6 +240,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap try { await input.beforeDispatch?.() input.signal?.throwIfAborted() + input.onInputPreparation?.() const turnOptions = await waitForPromiseWithSignal( resolvedCodexMessageOptions(session, input.executionOptions), input.signal 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 3c1d810c9ce..61dcbba56bc 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 @@ -287,6 +287,10 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & requestedAt?: number /** Revalidate after preparation, immediately before writing to the provider. */ beforeDispatch?: () => Promise + /** Local preparation boundary before choices can mutate the provider. */ + onInputPreparation?: () => void + /** In-process checkpoint after applying choices, before capturing execution evidence. */ + onExecutionSnapshot?: () => void }): Promise /** How this session narrows its agent's declared rewind; `agent` answers for one with no child * running. The router applies the declaration first, so an adapter's answer never widens it. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts index bcb9f82e898..95bfaee26f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts @@ -6,7 +6,7 @@ import { providerDiagnosticOf } from '../../../shared/agent-session-failure' import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' -import type { StructuredAgentSessionHandedOptions } from './structured-agent-session-ran-options' +import type { StructuredAgentSessionRanRecorder } from './structured-agent-session-ran-options' import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' import { abortSignalReason } from '../../../shared/abort-signal-reason' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' @@ -37,7 +37,7 @@ export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverC deliveryOrder?: StructuredAgentSessionDeliveryOrder workChanged?: () => void /** A message the agent took: a chat keeps, where it has none saved, the options it ran. */ - recordRanOptions?: (handed: StructuredAgentSessionHandedOptions) => Promise + recordRanOptions?: ReturnType } /** A handover whose pending row and abort handle are committed: the provider call runs outside the @@ -212,7 +212,9 @@ async function dispatchSafely( ...(executionOptions ? { executionOptions } : {}), ...(signal ? { signal } : {}), fence: ctx.fence, - requestedAt + requestedAt, + onInputPreparation: ctx.recordRanOptions?.prepareInput, + onExecutionSnapshot: ctx.recordRanOptions?.checkpoint }) return signal?.aborted && outcome.state === 'rejected' ? cancelledDispatch() : outcome } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index ab580d7f61b..9f75648ba39 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -30,7 +30,7 @@ import { retireOutgrownStructuredAgentSessionChild } from './structured-agent-se import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' import { acceptedNoLaterThan } from './structured-agent-session-start-failure-row' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' -import { recordStructuredAgentSessionRanOptions } from './structured-agent-session-ran-options' +import { createStructuredAgentSessionRanRecorder } from './structured-agent-session-ran-options' export type StructuredAgentSessionConversationDelivery = { loop: StructuredAgentSessionDeliveryLoop @@ -142,12 +142,11 @@ export function createStructuredAgentSessionConversationDelivery(input: { stopping: input.clientDelivery.readStopping, now: () => deps.now?.() ?? Date.now(), acquireAborts: input.acquireAborts, - recordRanOptions: (sessionId, fence) => (handed) => - recordStructuredAgentSessionRanOptions( + recordRanOptions: (sessionId, fence) => + createStructuredAgentSessionRanRecorder( { deps, sessions, now: () => deps.now?.() ?? Date.now() }, sessionId, - fence, - handed + fence ) }) const adoptOpened = async ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts index 098ece1e75a..e8040e74858 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts @@ -76,6 +76,11 @@ it('records what a later readback reports when the start named only a filled-in expect(await first.result).toMatchObject({ ok: true }) await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(1)) expect(rig.store.getRecord(SESSION)?.options).toBeUndefined() + await rig.settleAccepted(first.id, 'a') + const preparation = Promise.withResolvers() + rig.dispatch.mockImplementationOnce(() => preparation.promise) + await rig.send('rejected before preparation').result + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2)) await rig.host.handleAdapterEvent({ type: 'options-reported', sessionId: SESSION, @@ -83,6 +88,7 @@ it('records what a later readback reports when the start named only a filled-in acquisitionGeneration: 'generation-1', reportedOptions: { model: 'opus[1m]', effort: 'high' } }) + preparation.reject(new Error('content preparation failed')) await eventually(() => expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'opus[1m]', effort: 'high' }) ) @@ -195,3 +201,40 @@ it('steers a matching choice but holds a different one for a new turn', async () effort: 'low' }) }) + +it.each(['before', 'during', 'after'])( + 'uses only a model observed after input, including reports %s finalization', + async (when) => { + await rig.dispose() + rig = await createQueuedMessageTestRig({ reportsModel: 'old-model', reportsEffort: 'high' }) + const report = (model: string) => + rig.host.handleAdapterEvent({ + type: 'options-reported', + sessionId: SESSION, + fence: rig.store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: 'generation-1', + reportedOptions: { model, effort: 'low', fastMode: true, fastModeState: 'on' } + }) + rig.dispatch.mockImplementationOnce(async (input) => { + if (when === 'before') { + await report('previous-model') + } + input.onInputPreparation?.() + input.onExecutionSnapshot?.() + if (when === 'during') { + await report('actual-model') + } + return { state: 'admitted', ranOptions: {} } + }) + const first = rig.send('first') + expect(await first.result).toMatchObject({ ok: true }) + await eventually(() => expect(rig.dispatch).toHaveBeenCalledOnce()) + if (when !== 'during') { + expect(rig.store.getRecord(SESSION)?.options).toBeUndefined() + await report('actual-model') + } + await eventually(() => + expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'actual-model' }) + ) + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts index 93db19c9960..e7a405fa556 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts @@ -74,8 +74,12 @@ export async function noteStructuredAgentSessionReportedOptions( } const runs = childRuns.get(child) childRuns.set(child, { reported: ran }) - if (runs?.owed) { - await recordStructuredAgentSessionRanOptions(context, sessionId, fence, runs.owed) + if (runs?.owed && ran.model) { + const owed = runs.owed + await recordStructuredAgentSessionRanOptions(context, sessionId, fence, { + ...owed, + ...(owed.executed ? { executed: { ...owed.executed, model: ran.model } } : {}) + }) } } @@ -83,14 +87,43 @@ export async function noteStructuredAgentSessionReportedOptions( export type StructuredAgentSessionRanRecorder = ( sessionId: string, fence: number -) => (handed: StructuredAgentSessionHandedOptions) => Promise +) => ((handed: StructuredAgentSessionHandedOptions) => Promise) & { + prepareInput?: () => void + checkpoint?: () => void +} + +/** Keeps observation identity at input, so an early report is neither lost nor an old fallback. */ +export function createStructuredAgentSessionRanRecorder( + context: RanOptionsContext, + sessionId: string, + fence: number +): ReturnType { + let checkpoint: { reported?: RanOptions } | undefined + return Object.assign( + (handed: StructuredAgentSessionHandedOptions) => + recordStructuredAgentSessionRanOptions(context, sessionId, fence, handed, checkpoint), + { + prepareInput: () => { + const child = currentChild(context, sessionId, fence) + if (child) { + delete childRuns.get(child)?.owed + } + }, + checkpoint: () => { + const child = currentChild(context, sessionId, fence) + checkpoint = { reported: child ? childRuns.get(child)?.reported : undefined } + } + } + ) +} /** After a turn was handed over; bookkeeping, so a failure is logged and never refuses the send. */ export async function recordStructuredAgentSessionRanOptions( context: RanOptionsContext, sessionId: string, fence: number, - handed: StructuredAgentSessionHandedOptions + handed: StructuredAgentSessionHandedOptions, + checkpoint?: { reported?: RanOptions } ): Promise { const child = currentChild(context, sessionId, fence) const { store, logger, agents } = context.deps @@ -100,7 +133,10 @@ export async function recordStructuredAgentSessionRanOptions( } const { message, executed } = handed const reported = childRuns.get(child)?.reported - const model = executed?.model ?? message?.model ?? reported?.model + const newerModel = checkpoint && reported !== checkpoint.reported ? reported?.model : undefined + const model = executed + ? (executed.model ?? message?.model ?? newerModel) + : (message?.model ?? reported?.model) childRuns.set(child, { ...(reported ? { reported } : {}), ...(model ? {} : { owed: handed }) }) if (!model) { return diff --git a/src/main/pi/rpc-message-dispatch.ts b/src/main/pi/rpc-message-dispatch.ts index a0dbf15fa67..b3afb19c576 100644 --- a/src/main/pi/rpc-message-dispatch.ts +++ b/src/main/pi/rpc-message-dispatch.ts @@ -52,6 +52,11 @@ export async function dispatchPiRpcMessage( ) } } + input.signal?.throwIfAborted() + if (!isCurrent() || session.connection.closed) { + return exited() + } + input.onInputPreparation?.() const model = input.executionOptions?.model ?? session.initialOptions.model if (!input.executionOptions) { input.signal?.throwIfAborted()