diff --git a/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts index 21982498711..eb8e0d41ff8 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts @@ -282,7 +282,7 @@ function listingLandsLaterClient() { } describe('Approve for me on a phone chat opened before its account was listed', () => { - it('is offered once the listing lands', async () => { + it('is offered before and after the listing lands', async () => { const host = listingLandsLaterClient() const options = await mountOptions({ client: host.client, @@ -290,10 +290,10 @@ describe('Approve for me on a phone chat opened before its account was listed', optionStamp: '', agent: 'claude' }) - expect(options.offered()).toEqual(['ask', 'accept-edits', 'full-access']) + expect(options.offered()).toEqual(['ask', 'accept-edits', 'auto', 'full-access']) host.land() await options.rerender({}) - expect(options.offered()).toContain('auto') + expect(options.offered()).toEqual(['ask', 'accept-edits', 'auto', 'full-access']) await options.unmount() }) }) diff --git a/src/main/claude/claude-journal-translator-contract.ts b/src/main/claude/claude-journal-translator-contract.ts index 3b916b15377..31610eae151 100644 --- a/src/main/claude/claude-journal-translator-contract.ts +++ b/src/main/claude/claude-journal-translator-contract.ts @@ -40,6 +40,7 @@ export type ClaudeJournalTranslator = { commandInterruptRequested: (turnId: string) => void /** Root work since cycle init makes the open turn eligible for folded sends. */ readonly openTurnInLiveProviderCycle: boolean + readonly openTurnHasInputWork: boolean flush: () => void childToolOwner?: ClaudeChildToolQueries['childToolOwner'] childActivity?: ClaudeChildToolQueries['childActivity'] diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index 86365c68fed..76341a0a917 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -35,6 +35,8 @@ export class ClaudeOpenTurn { private reopenSuppressed = false /** Track root work since cycle init; background output may open a turn before that init. */ private cycleWorkObserved = false + /** Input choices follow actual work; correlation also includes API errors. */ + inputChoiceWorkObserved = false private startedSend: ClaudeStartedSend | null = null private readonly opener: ( frame: Record, @@ -96,6 +98,7 @@ export class ClaudeOpenTurn { observeProviderCycleStart(): void { this.cycleWorkObserved = false + this.inputChoiceWorkObserved = false } observeProviderCycleWork(): void { @@ -147,6 +150,7 @@ export class ClaudeOpenTurn { forgetCommand(turnId: string): void { if (this.current?.command && this.current.turnId === turnId) { this.usageLimitEvidence = null + this.inputChoiceWorkObserved = false this.current = null } } @@ -168,6 +172,7 @@ export class ClaudeOpenTurn { this.usageLimitEvidence?.cause === 'quota' this.suppressReopenOnFailure(quotaEnd) this.cycleWorkObserved = false + this.inputChoiceWorkObserved = false this.usageLimitEvidence = null // End suppressed work even when no turn is open. this.deps.endOpenWork(end.completedAt) diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index eba53fde03d..cd598ee6bde 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -287,6 +287,7 @@ describe('answerClaudePrompt', () => { session.translator = { handle: vi.fn(), openTurnInLiveProviderCycle: false, + openTurnHasInputWork: false, journalPrompts: { resolve: resolvePrompt, handOver: () => () => {}, diff --git a/src/main/claude/claude-structured-execution-recording.test.ts b/src/main/claude/claude-structured-execution-recording.test.ts index 95aa49edf59..55022b5179a 100644 --- a/src/main/claude/claude-structured-execution-recording.test.ts +++ b/src/main/claude/claude-structured-execution-recording.test.ts @@ -12,6 +12,10 @@ import { } 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' +/** Claude ends a turn; a message sent before this joins the running turn on its choices. */ +const claudeTurnEnds = (connection: Parameters[0]) => + claudeFrame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + it.each(['low', 'high', undefined])( 'admits accepted high independently of resolved effort (effective %s)', async (effective) => { @@ -40,11 +44,11 @@ it.each(['low', 'high', undefined])( ) const first = rig.send('first', undefined, { carried: choices }) expect(await first.result).toMatchObject({ ok: true }) + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(1)) await eventually(() => - expect(rig.store.getRecord(SESSION)?.options).toEqual({ - model: 'sonnet', - ...(effective ? { effort: 'low' } : {}) - }) + expect(rig.store.getRecord(SESSION)?.options).toEqual( + effective ? { model: 'sonnet', effort: 'low' } : undefined + ) ) const outcome = await rig.dispatch.mock.results[0]!.value expect(outcome).toEqual({ @@ -118,6 +122,7 @@ it('retires earlier Fast routing when an effort read reports Fast off', async () }) try { await send('high') + claudeTurnEnds(claude.connections[0]!) claudeFrame(claude.connections[0]!, { type: 'system', subtype: 'status', @@ -125,6 +130,7 @@ it('retires earlier Fast routing when an effort read reports Fast off', async () }) fastMode = false expect(await send('low')).toMatchObject({ state: 'admitted', ranOptions: { effort: 'low' } }) + claudeTurnEnds(claude.connections[0]!) expect(await send('low')).toEqual({ state: 'admitted', ranOptions: { effort: 'low' } }) } finally { await adapter.closeAll() @@ -152,6 +158,7 @@ it('never pairs an earlier unknown-model low run with the next prepared model', }) expect(rig.store.getRecord(SESSION)?.options).toBeUndefined() await rig.settleAccepted(first.id, 'a') + claudeTurnEnds(claude.connections[0]!) claude.routes.set_model = async () => { claudeFrame(claude.connections[0]!, { type: 'system', subtype: 'init', model: 'B' }) const report = events.findLast((event) => event.type === 'options-reported')! @@ -174,3 +181,37 @@ it('never pairs an earlier unknown-model low run with the next prepared model', await rig.dispose() } }) + +it('does not save a deferred model with a live unknown-model turn’s observed effort through handover', async () => { + // Same partial get_settings shape as the CLI capture: applied effort without model. + const claude = fakeClaude({ + initModel: '', + settings: { applied: { effort: 'low' }, effective: { effortLevel: 'low' } } + }) + const adapter = await acquired(claude) + const rig = await createQueuedMessageTestRig() + try { + rig.dispatch.mockImplementation((input) => + adapter.dispatch({ ...input, sessionId: 'session-1', fence: 7 }) + ) + const first = rig.send('open the turn') + await first.result + await eventually(async () => + expect((await rig.submission(first.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(first.id, 'first') + const followup = rig.send('keep working', undefined, { carried: { model: 'opus[1m]' } }) + await followup.result + await eventually(() => expect(claude.connections[0]!.sent).toHaveLength(2)) + await rig.host.collaboratorsForTests().conversationDelivery.loop.wake(SESSION) + expect(await rig.dispatch.mock.results[1]!.value).toEqual({ + state: 'admitted', + ranOptions: { effort: 'low' } + }) + expect(claude.connections[0]!.calls.filter((call) => call.subtype === 'set_model')).toEqual([]) + expect(rig.store.getRecord(SESSION)?.options).toBeUndefined() + } finally { + await adapter.closeAll() + await rig.dispose() + } +}) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 0afab199d6a..123fee16125 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -200,6 +200,10 @@ export function createClaudeJournalTranslator( event.message.type === 'stream_event' ) { turn.observeProviderCycleWork() + turn.inputChoiceWorkObserved ||= + event.message.type !== 'assistant' || + event.message.is_api_error_message !== true || + event.startedSend != null } } } @@ -276,6 +280,9 @@ export function createClaudeJournalTranslator( get openTurnInLiveProviderCycle() { return turn.openedInLiveProviderCycle }, + get openTurnHasInputWork() { + return turn.isOpen && turn.inputChoiceWorkObserved + }, flush, childToolOwner: childQueries.childToolOwner, childActivity: childQueries.childActivity, diff --git a/src/main/claude/claude-structured-message-options.test.ts b/src/main/claude/claude-structured-message-options.test.ts index 06d890e470c..5a3fcbed6e0 100644 --- a/src/main/claude/claude-structured-message-options.test.ts +++ b/src/main/claude/claude-structured-message-options.test.ts @@ -11,8 +11,13 @@ import { import { captureStructuredExecutionOptions } from '../native-chat/agent-session-wire/structured-agent-session-execution-options' import { claudeAndCodexAgents } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import * as dispatchContent from './claude-structured-dispatch-content' import { ClaudeControlRequestError } from './claude-stream-json-connection' +/** Claude ends a turn; a message sent before this joins the running turn on its choices. */ +const claudeTurnEnds = (connection: Parameters[0]) => + claudeFrame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + const MODELS = [ { value: 'default', resolvedModel: 'claude-sonnet-5' }, { @@ -57,6 +62,7 @@ describe('Claude accepted message choices', () => { fence: 7 }) ).resolves.toMatchObject({ state: 'admitted' }) + claudeTurnEnds(connection) await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) } expect( @@ -74,6 +80,7 @@ describe('Claude accepted message choices', () => { fence: 7 }) ).resolves.toMatchObject({ state: 'admitted' }) + claudeTurnEnds(connection) } expect( connection.calls.filter((call) => @@ -204,6 +211,7 @@ describe('Claude accepted message choices', () => { release() await expect(a).resolves.toMatchObject({ state: 'admitted' }) expect(order).toEqual(['model:sonnet', 'effort:{"effortLevel":"low"}', 'prompt']) + claudeTurnEnds(connection) order.length = 0 await expect( @@ -329,6 +337,7 @@ describe('Claude accepted message choices', () => { fence: 7 }) ).resolves.toMatchObject({ state: 'admitted' }) + claudeTurnEnds(connection) } expect(captured.options.fastMode).toBe('true') expect(connection.calls.filter((call) => call.subtype === 'apply_flag_settings')).toEqual( @@ -371,6 +380,7 @@ describe('Claude accepted message choices', () => { fence: 7 }) const connection = claude.connections[0]! + claudeTurnEnds(connection) claudeFrame(connection, { type: 'system', subtype: 'init', model: 'claude-opus-5' }) connection.calls.length = 0 await expect( @@ -390,4 +400,103 @@ describe('Claude accepted message choices', () => { ]) expect(connection.sent).toHaveLength(2) }) + + it('keeps a running turn on its choices and applies new ones at the next turn', async () => { + const claude = fakeClaude({ + initModels: MODELS.map((model) => ({ ...model, supportsFastMode: true })), + routes: { list_models: () => MODELS } + }) + const adapter = await acquired(claude) + const connection = claude.connections[0]! + const send = (clientMessageId: string, model: string, body = USER_MESSAGE) => + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId, + body, + executionOptions: { + model, + effort: model === 'sonnet' ? 'high' : 'low', + fastMode: model === 'sonnet' ? 'false' : 'true' + }, + fence: 7 + }) + const models = () => + connection.calls.flatMap((call) => (call.subtype === 'set_model' ? [call.params?.model] : [])) + await expect(send('opens', 'sonnet')).resolves.toMatchObject({ state: 'admitted' }) + expect(models()).toEqual(['sonnet']) + const settings = () => connection.calls.filter((call) => call.subtype === 'apply_flag_settings') + const liveSettings = [...settings()] + // Typed while that turn runs, after the pick changed: it joins the turn as it is. + await expect(send('joins', 'opus')).resolves.toMatchObject({ state: 'admitted' }) + expect(models()).toEqual(['sonnet']) + expect(settings()).toEqual(liveSettings) + claudeTurnEnds(connection) + const mail = { + ...USER_MESSAGE, + from: { + kind: 'agent' as const, + senders: [], + orchestration: { + message: 'mail-notice' as const, + mailbox: 'agent:worker', + dispatchId: null, + messages: [] + } + } + } + await expect(send('mail', 'opus', mail)).resolves.toMatchObject({ state: 'admitted' }) + expect(models()).toEqual(['sonnet']) + expect(settings()).toEqual(liveSettings) + const held = Promise.withResolvers() + const entered = Promise.withResolvers() + const original = dispatchContent.claudeDispatchMessageContent + const content = vi + .spyOn(dispatchContent, 'claudeDispatchMessageContent') + .mockImplementationOnce(async (body) => { + entered.resolve() + await held.promise + return original(body) + }) + const delayed = send('next', 'opus') + try { + await entered.promise + claudeTurnEnds(connection) + held.resolve() + await expect(delayed).resolves.toMatchObject({ state: 'admitted' }) + } finally { + held.resolve() + content.mockRestore() + } + expect(models()).toEqual(['sonnet', 'opus']) + expect(settings()).toContainEqual({ + subtype: 'apply_flag_settings', + params: { settings: { effortLevel: 'low' } } + }) + expect(settings()).toContainEqual({ + subtype: 'apply_flag_settings', + params: { settings: { fastMode: true } } + }) + expect(connection.sent).toHaveLength(4) + claudeTurnEnds(connection) + const settingsAfterTurn = [...settings()] + const originalSend = connection.send + connection.send = async (message, prepare) => { + await prepare?.() + connection.sent.push(message) + } + try { + await expect(send('starts', 'opus')).resolves.toMatchObject({ state: 'admitted' }) + claudeFrame(connection, { + type: 'command_lifecycle', + state: 'started', + command_uuid: connection.sent.at(-1)?.uuid + }) + await expect(send('before-echo', 'sonnet')).resolves.toMatchObject({ state: 'admitted' }) + expect(models()).toEqual(['sonnet', 'opus']) + expect(settings()).toEqual(settingsAfterTurn) + } finally { + connection.send = originalSend + await adapter.closeAll() + } + }) }) diff --git a/src/main/claude/claude-structured-message-options.ts b/src/main/claude/claude-structured-message-options.ts index fca83b0a4b6..3a7b0b927fd 100644 --- a/src/main/claude/claude-structured-message-options.ts +++ b/src/main/claude/claude-structured-message-options.ts @@ -1,3 +1,4 @@ +import { claudeStartedUnechoedSend } from './claude-command-lifecycle' 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' @@ -87,12 +88,20 @@ export async function dispatchClaudeMessage( timeoutMs: number | undefined, logger: StructuredAgentSessionLogger = defaultLogger ): ReturnType { - const messageOptions = input.executionOptions let ranOptions: Record = {} const outcome = await dispatchClaudeTurn(session, input, async () => { await input.beforeDispatch?.() try { input.signal?.throwIfAborted() + // Input joining a running turn, and Orca mail notices, run on the choices Claude already has; + // new choices apply at the next new turn. + const messageOptions = + session.translator?.openTurnHasInputWork || + session.translator?.commandTurnId || + claudeStartedUnechoedSend(session) || + input.body.from?.orchestration?.message === 'mail-notice' + ? undefined + : input.executionOptions input.onInputPreparation?.() if (messageOptions !== undefined) { await applyClaudeMessageOptions(session, messageOptions, timeoutMs, input.signal) 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 dcab9e73439..49b16c3ea36 100644 --- a/src/main/claude/claude-structured-options-context-window.test.ts +++ b/src/main/claude/claude-structured-options-context-window.test.ts @@ -34,6 +34,7 @@ describe('the context ring after a session option write', () => { it('sizes a new session from the model it was launched with', () => { const s = ringSession() adoptClaudeStructuredSpawnOptions(s.session, { + sdkOptions: { model: 'opus[1m]' }, options: new Map([['model', 'opus[1m]']]), fastModeAtStart: false }) diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index e7482f8deac..be57fc0432b 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -7,12 +7,35 @@ import { readClaudeSettingsFastMode } from './claude-structured-session-options' import type { ClaudeSession } from './claude-structured-session-state' +import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' import { beginClaudeOptionMutation } from './claude-structured-execution-observations' const OPTION_ORDER = ['model', 'effort', 'fastMode'] as const +/** Approval mode is fixed at startup: a fallback mode is dropped only by reopening for a newly picked model. + * Compares with what Orca itself launched, so the reopened child never matches again. */ +export function claudeModelTransitionRestart( + sessions: ReadonlyMap +): NonNullable { + return (input) => { + const session = sessions.get(input.sessionId) + const model = input.executionOptions?.model + const opened = session?.firstRootInit?.permissionMode + const requested = session?.launchIntent?.permissionMode + return Boolean( + session?.fence === input.fence && + model !== undefined && + model !== session.launchIntent?.model && + requested !== undefined && + opened !== undefined && + opened !== null && + opened !== requested + ) + } +} + export function restoredClaudeStructuredSessionOptions( options: Readonly> | undefined ): Map { diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 4255429b458..82057591ef3 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -19,12 +19,7 @@ import { adoptClaudeStructuredSpawnOptions } from './claude-structured-spawn-opt import { observeClaudeExecutionModel } from './claude-structured-execution-observations' import { observeClaudeFastModeFacts } from './claude-structured-session-options' import { createClaudeSessionPublication } from './claude-structured-session-publication' -import { - mintClaudeAcquisitionGeneration, - type ClaudeSession, - type ClaudeSessionAcquisitionInput, - type ClaudeLateDispatchOutcome -} from './claude-structured-session-state' +import * as sessionState from './claude-structured-session-state' import { resolveClaudeAcquisitionError } from './claude-structured-session-close' import { withObservedProviderExit } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { readClaudeTranscriptEntryUuid } from './claude-transcript-entry-uuid' @@ -35,6 +30,7 @@ import { bindClaudeConnectionJournalControls, createClaudeSessionJournalTranslator } from './claude-structured-session-journal-control' +import { isRootClaudeFrame } from './claude-turn-opening' export async function acquireClaudeSession({ input, @@ -43,12 +39,13 @@ export async function acquireClaudeSession({ acquisitions, exits, callbacks -}: ClaudeSessionAcquisitionInput): Promise { +}: sessionState.ClaudeSessionAcquisitionInput): Promise { const sessionId = input.identity.sessionId const prompts = new ClaudePromptRegistry() const { previous, attempt } = acquisitions.start(sessionId, prompts) let unbindReadingControl: (() => void) | undefined - let liveSession: ClaudeSession | null = null + let liveSession: sessionState.ClaudeSession | null = null + let firstRootInit: sessionState.ClaudeSession['firstRootInit'] let observedLeafUuid: string | null = null, expectedProviderSessionId: string | null = null // Preserve the CLI diagnostic when SDK cleanup reports a generic error. @@ -70,7 +67,7 @@ export async function acquireClaudeSession({ ) const contextActivity = translator?.contextActivity ?? 0 const recordTurnEnd = claudeTurnResumePointWriter(sessionId, sessions, deps) - const observedOptions: ClaudeSession['reportedOptions'] = {} + const observedOptions: sessionState.ClaudeSession['reportedOptions'] = {} const onMessage = (message: Record): void => { const init = readClaudeInit(message) if (readClaudeFrameString(message, 'session_id') !== expectedProviderSessionId) { @@ -80,6 +77,12 @@ export async function acquireClaudeSession({ } return } + if (message.type === 'system' && message.subtype === 'init' && isRootClaudeFrame(message)) { + firstRootInit ??= { permissionMode: readClaudeFrameString(message, 'permissionMode') } + if (liveSession) { + liveSession.firstRootInit = firstRootInit + } + } const permissionChanged = observeClaudeReportedPermissionMode(observedOptions, message) const modelChanged = liveSession && init?.model ? observeClaudeExecutionModel(liveSession, init.model) : false @@ -99,7 +102,7 @@ export async function acquireClaudeSession({ } } // Publish the echoed turn before settling its send to avoid transient idle status. - const settlements: ClaudeLateDispatchOutcome[] = [] + const settlements: sessionState.ClaudeLateDispatchOutcome[] = [] const turnOrigin = liveSession ? resolveClaudeReplayTurn(liveSession, message, (settlement) => settlements.push(settlement)) : null @@ -239,7 +242,7 @@ export async function acquireClaudeSession({ new Error(`claude stream-json for session ${sessionId} exited while being acquired`) ) } - const publication = createClaudeSessionPublication({ + const { session, acquisition } = createClaudeSessionPublication({ connection, providerSessionId: launch.providerSessionId, leafUuid: observedLeafUuid, @@ -252,7 +255,7 @@ export async function acquireClaudeSession({ events: input.events, ...(unbindReadingControl ? { unbindReadingControl } : {}), process, - acquisitionGeneration: mintClaudeAcquisitionGeneration(deps), + acquisitionGeneration: sessionState.mintClaudeAcquisitionGeneration(deps), options: launch.savedOptions.options, initialization: { proof: initProof, @@ -270,10 +273,13 @@ export async function acquireClaudeSession({ ...(deps.mintLinkId ? { linkId: deps.mintLinkId() } : {}), observedAt: deps.now?.() ?? Date.now() }) - const session = publication.session session.reportedOptions = observedOptions liveSession = session - adoptClaudeStructuredSpawnOptions(session, launch.savedOptions) + session.firstRootInit = firstRootInit + adoptClaudeStructuredSpawnOptions(session, { + ...launch.savedOptions, + sdkOptions: launch.options + }) acquisitions.deleteIfCurrent(sessionId, attempt) await withAgentSessionCreatePhase('publish', input.recordPhase, async () => { sessions.set(sessionId, session) @@ -288,7 +294,7 @@ export async function acquireClaudeSession({ exits.get(sessionId)?.error ?? new Error('claude session ended before acquisition returned') ) } - return publication.acquisition + return acquisition } catch (error) { unbindReadingControl?.() throw await resolveClaudeAcquisitionError({ diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index ae9592b29ad..60901684c57 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -10,7 +10,7 @@ import { claudeHeldSends, claudeHoldsDispatch } from './claude-command-lifecycle import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' import { acquireClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' -import { setClaudeStructuredSessionOption } from './claude-structured-options' +import * as claudeOptions from './claude-structured-options' import { dispatchClaudeMessage } from './claude-structured-message-options' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' import { @@ -190,6 +190,9 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda this.sessions.get(sessionId)?.commands.commands readonly atRestCommands: ClaudeStructuredSessionAdapterDeps['atRestCommands'] holdsDispatch = (sessionId: string): boolean => claudeHoldsDispatch(this.sessions.get(sessionId)) + requiresModelTransitionRestart = claudeOptions.claudeModelTransitionRestart(this.sessions) + liveTurnId = (sessionId: string): string | null | undefined => + this.sessions.get(sessionId)?.translator?.currentTurnId heldSends = (sessionId: string): ReadonlySet | undefined => { const session = this.sessions.get(sessionId) ?? this.exits.get(sessionId)?.session const held = session && claudeHeldSends(session) @@ -206,7 +209,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => settleClaudePromptFreeingChild(this.asker(request), answerClaudeStructuredPrompt) setOption: StructuredAgentSessionAdapter['setOption'] = (input) => - setClaudeStructuredSessionOption( + claudeOptions.setClaudeStructuredSessionOption( this.session(input.sessionId), input, this.deps.requestTimeoutMs diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 201d32a001c..3554f2fdb08 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -4,10 +4,7 @@ import type { AgentJournalDispatchRejection } from '../../shared/agent-session-f import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { - StructuredAgentSessionAcquireInput, - StructuredAgentSessionOptionsReportedEvent -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type * as SessionAdapter from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection, openClaudeStreamJsonConnection @@ -63,7 +60,7 @@ export type ClaudeStructuredSessionEvent = fence: number } | { type: 'auth-diagnostic'; sessionId: string; diagnostic: ClaudeAuthDiagnostic } - | StructuredAgentSessionOptionsReportedEvent + | SessionAdapter.StructuredAgentSessionOptionsReportedEvent | { type: 'ended' sessionId: string @@ -185,6 +182,9 @@ export type ClaudeSession = { appliedInputOptions: Map /** The model the child was launched with, as `--model`; null when it runs the CLI's own. */ launchedModel: string | null + /** What Orca put on this process's command line, and the first root init's answer; this process only. */ + launchIntent?: { permissionMode?: string; model?: string } + firstRootInit?: { permissionMode: string | null } /** A launched model the CLI answered `model_not_found` for; reported until a new pick. */ retiredModel?: string /** Fast was omitted from launch settings and must be applied before message input. */ @@ -352,7 +352,7 @@ export async function cancelClaudeAcquisitionAttempt( } export type ClaudeSessionAcquisitionInput = { - input: StructuredAgentSessionAcquireInput + input: SessionAdapter.StructuredAgentSessionAcquireInput deps: ClaudeStructuredSessionAdapterDeps sessions: Map acquisitions: ClaudeAcquisitionRegistry diff --git a/src/main/claude/claude-structured-spawn-options.ts b/src/main/claude/claude-structured-spawn-options.ts index 32ad57c25f9..20144285fc6 100644 --- a/src/main/claude/claude-structured-spawn-options.ts +++ b/src/main/claude/claude-structured-spawn-options.ts @@ -80,11 +80,20 @@ export function claudeStructuredSpawnOptions(input: { export function adoptClaudeStructuredSpawnOptions( session: Pick< ClaudeSession, - 'translator' | 'launchedModel' | 'fastModeAtStart' | 'appliedInputOptions' + 'translator' | 'launchedModel' | 'launchIntent' | 'fastModeAtStart' | 'appliedInputOptions' >, - spawn: Pick + spawn: ClaudeStructuredSpawnOptions ): void { session.launchedModel = spawn.options.get('model') ?? null + const { extraArgs, permissionMode } = spawn.sdkOptions + // A configured `--model` in the agent's arguments survives when the chat saved none. + const configuredModel = typeof extraArgs?.model === 'string' ? extraArgs.model : undefined + session.launchIntent = { + model: spawn.sdkOptions.model ?? configuredModel, + permissionMode: Object.hasOwn(extraArgs ?? {}, 'dangerously-skip-permissions') + ? 'bypassPermissions' + : permissionMode + } session.fastModeAtStart = spawn.fastModeAtStart const fastMode = spawn.options.get('fastMode') if (fastMode !== undefined && !spawn.fastModeAtStart) { diff --git a/src/main/claude/claude-transcript-catch-up.test.ts b/src/main/claude/claude-transcript-catch-up.test.ts index 79abad29722..c0a5984cefc 100644 --- a/src/main/claude/claude-transcript-catch-up.test.ts +++ b/src/main/claude/claude-transcript-catch-up.test.ts @@ -221,6 +221,28 @@ describe('Claude chat catch-up', () => { } ) + it('never leaves a turn it imports running when its reply was already saved', async () => { + // An update killed the chat after Claude saved its reply but before the prompt's echo. + const c = await chat() + await c.live({ interrupted: true }) + const saved = new Set(c.journal.snapshot().items.map((item) => item.itemId)) + await c.write([ + prompt('leaf', null, 'earlier request', 0), + prompt('unechoed', 'leaf', 'ask 1', 1), + reply('a1', 'unechoed', 'msg_a', { type: 'text', text: 'answer 1' }, 2) + ]) + expect(await c.open('leaf')).toBe(true) + const imported = c.journal + .snapshot() + .items.filter((item) => item.body.kind === 'turn' && !saved.has(item.itemId)) + expect(imported).toHaveLength(1) + // It ends where it opened: in the thread, not inside the saved turn its reply matched. + expect(imported[0]).toMatchObject({ + body: { state: 'completed' }, + turnScope: { kind: 'thread' } + }) + }) + it('completes interrupted text and a result-only tool once at their original positions', async () => { const c = await chat() await c.live({ diff --git a/src/main/claude/claude-transcript-import-plan.ts b/src/main/claude/claude-transcript-import-plan.ts index f5e85bb3677..d545b9613d2 100644 --- a/src/main/claude/claude-transcript-import-plan.ts +++ b/src/main/claude/claude-transcript-import-plan.ts @@ -88,7 +88,8 @@ export function createClaudeTranscriptImportPlan(input: { const identity = aliases.get(agentJournalItemKey(rawIdentity)) ?? rawIdentity const key = agentJournalItemKey(identity) const existing = saved(key) - if (projected.kind === 'turn' && (existing || historicalScope)) { + // A turn this import opened is history too: it must also take its end, never stay running. + if (projected.kind === 'turn' && (existing || (historicalScope && !mutations.has(key)))) { return { accepted: true } as const } let body = projected @@ -98,7 +99,7 @@ export function createClaudeTranscriptImportPlan(input: { body = untimed } let turnScope = - projected.kind === 'message' && projected.role === 'user' + projected.kind === 'turn' || (projected.kind === 'message' && projected.role === 'user') ? options.turnScope : (historicalScope ?? options.turnScope) if (existing) { diff --git a/src/main/claude/claude-turn-ownership.test.ts b/src/main/claude/claude-turn-ownership.test.ts index 95accc9cc80..47df1796846 100644 --- a/src/main/claude/claude-turn-ownership.test.ts +++ b/src/main/claude/claude-turn-ownership.test.ts @@ -87,6 +87,7 @@ function sessionHoldingTurn(turnId: string | null): ReturnType () => {}, 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 e801d146bc3..d1b8f8065d5 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 @@ -115,6 +115,14 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi holdsDispatch = (sessionId: string): boolean => this.liveOwnerOrNull(sessionId)?.holdsDispatch?.(sessionId) ?? false + liveTurnId = (sessionId: string): string | null | undefined => + this.liveOwnerOrNull(sessionId)?.liveTurnId?.(sessionId) + + requiresModelTransitionRestart: NonNullable< + StructuredAgentSessionAdapter['requiresModelTransitionRestart'] + > = (input) => + this.liveOwnerOrNull(input.sessionId)?.requiresModelTransitionRestart?.(input) ?? false + capturePendingOutput: StructuredAgentSessionAdapter['capturePendingOutput'] = (input) => this.liveOwnerOrNull(input.sessionId)?.capturePendingOutput?.(input) 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 672f647d520..99226e77040 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 @@ -241,6 +241,14 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & }): Promise<{ ok: true } | { ok: false; rejected: string }> /** Derive unanswered retained work from the live child; false without one. */ holdsDispatch?(sessionId: string): boolean + /** The root turn the live child is running, null when idle; absent when the agent cannot say. */ + liveTurnId?(sessionId: string): string | null | undefined + /** The live child opened in another approval mode than requested and this input picks another model. */ + requiresModelTransitionRestart?(input: { + sessionId: string + fence: number + executionOptions?: AgentSessionExecutionOptions + }): boolean /** Captures only this input's pending answer/output in the supplied live acquisition. */ capturePendingOutput?(input: { sessionId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts index acbb040159a..4f352ab121f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts @@ -3,7 +3,7 @@ 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 { afterEach, assert, beforeEach, expect, it, vi } from 'vitest' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { agentSessionSendSubmission } from '../../../shared/agent-session-wire' @@ -34,6 +34,11 @@ import { recordingStructuredAgentSessionLogger } from './structured-agent-sessio import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import { DISPATCH_DOUBT_PROVIDER_IDLE } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' +import * as unheld from './structured-agent-session-unheld-turns' import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue' const CALLER = { callerKey: 'client-1' } @@ -45,19 +50,36 @@ let adapter: ClaudeStructuredSessionAdapter let store: AgentSessionRecordStore let claude: ReturnType -beforeEach(async () => { +let startupGate: ReturnType> +const session = () => host.collaboratorsForTests().sessions.get(SESSION)! +const loop = () => host.collaboratorsForTests().conversationDelivery.loop +const ready = () => + vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) +const init = (fields: Record) => + frame(claude.connections.at(-1)!, { type: 'system', subtype: 'init', ...fields }) +const idle = () => init({ subtype: 'session_state_changed', state: 'idle' }) +function echo() { + const connection = claude.connections.at(-1)! + frame(connection, { ...connection.sent.at(-1)!, isReplay: true, parent_tool_use_id: null }) +} + +beforeEach(async ({ task }) => { + const imported = task.name.includes('history items') + const selections = task.name.includes('fallback') + startupGate = Promise.withResolvers() root = await mkdtemp(join(tmpdir(), 'orca-claude-opening-send-')) resetHostTestOperationIds() const log = recordingStructuredAgentSessionLogger() claude = fakeClaude({ replayUuid: null, + ...(selections ? { initModel: 'claude-haiku-4-5' } : {}), routes: { interrupt: () => ({ still_queued: [], cancelled: [] }) } }) const lifecycle: Promise[] = [] adapter = new ClaudeStructuredSessionAdapter({ - resolveLaunch: async () => ({ + resolveLaunch: async ({ permissionMode }) => ({ pathToClaudeCodeExecutable: 'claude', - options: {}, + options: { permissionMode: permissionMode === 'auto' ? 'auto' : 'default' }, cwd: root, claudeConfigDir: join(root, 'claude-home'), providerSessionId: PROVIDER_SESSION_ID, @@ -87,18 +109,30 @@ beforeEach(async () => { logger: log.logger, now: () => NOW }) + if (imported) { + const open = adapter.open.bind(adapter) + vi.spyOn(adapter, 'open').mockImplementation(async (input) => { + await startupGate.promise + return open(input) + }) + } const params = hostTestAttachParams(null, { provider: 'claude', agent: 'claude', + ...(selections ? { options: { permissionMode: 'auto', model: 'haiku' } } : {}), 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 }) - await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) - await Promise.all(lifecycle) + if (!imported) { + await ready() + await Promise.all(lifecycle) + } }) afterEach(async () => { + startupGate.resolve() + vi.restoreAllMocks() await adapter.closeAll() await host.flushAllStreamedEvents() await rm(root, { recursive: true, force: true }) @@ -107,8 +141,12 @@ afterEach(async () => { const eventually = (assertion: () => T | Promise) => vi.waitFor(assertion, { timeout: 10_000 }) -async function send(text: string): Promise { - const body = hostTestMessage(text) +async function send( + text: string, + options?: Record, + from?: AgentJournalMessageItem['from'] +): Promise { + const body = { ...hostTestMessage(text), ...(from ? { from } : {}) } const sent = await host.send(CALLER, { envelope: { sessionId: SESSION, @@ -117,10 +155,11 @@ async function send(text: string): Promise { payloadFingerprint: computeAgentSessionPayloadFingerprint({ method: 'agentSession.send', sessionId: SESSION, - fields: { body } + fields: { body: hostTestMessage(text), ...(options ? { options } : {}) } }) }, - body + body, + ...(options ? { options } : {}) }) if (!sent.ok) { throw new Error(JSON.stringify(sent.refusal)) @@ -445,3 +484,162 @@ it('sends a message that joins an unproven Stop close to the resumed child, and frame(resumed, { ...written(resumed, 'Carry on.')!, isReplay: true, parent_tool_use_id: null }) await eventually(() => expect(written(resumed, 'And this.')).toBeDefined()) }) + +async function sendChoice( + text: string, + options: Record, + from?: AgentJournalMessageItem['from'] +) { + await send(text, options, from) + await eventually(() => expect(written(claude.connections.at(-1)!, text)).toBeDefined()) + echo() + await host.flushStreamedEvents(SESSION) + await eventually(() => expect(loop().isRunning(SESSION)).toBe(false)) +} + +it('preserves saved Auto through fallback, reopens once for a changed captured model, then reuses', async () => { + const first = claude.connections[0]! + init({ parent_tool_use_id: 'child', permissionMode: 'auto' }) + init({ permissionMode: 'default', model: 'claude-haiku-4-5' }) + init({ permissionMode: 'auto', model: 'claude-opus-5-5[1m]' }) + await sendChoice('same fallback', { model: 'haiku' }) + expect(claude.connections).toHaveLength(1) + const picked = { model: 'opus[1m]', effort: 'high', fastMode: 'true' } + await sendChoice('busy choice', picked) + expect(claude.connections).toHaveLength(1) + idle() + await sendChoice('mail notice', picked, { + kind: 'agent', + senders: [], + orchestration: { + message: 'mail-notice', + mailbox: 'agent:worker', + dispatchId: null, + messages: [] + } + }) + expect(claude.connections).toHaveLength(1) + idle() + await sendChoice('new choice', picked) + expect(first.closeCount).toBe(1) + const replacement = claude.connections[1]! + expect(replacement.launch.options).toMatchObject({ + permissionMode: 'auto', + model: 'opus[1m]', + effort: 'high' + }) + expect(replacement.launch.options.settings).toEqual({ fastMode: true }) + idle() + await sendChoice('reuse choice', picked) + expect(claude.connections).toHaveLength(2) + expect(replacement.sent).toHaveLength(2) + expect(store.getRecord(SESSION)?.permissionMode).toBe('auto') +}) + +it('does not infer fallback from unknown init or a missing captured model', async () => { + init({ model: 'claude-haiku-4-5' }) + init({ permissionMode: 'default' }) + await sendChoice('unknown permission', { model: 'opus[1m]' }) + expect( + adapter.requiresModelTransitionRestart({ + sessionId: SESSION, + fence: session().child!.fence + }) + ).toBe(false) + expect(claude.connections).toHaveLength(1) +}) + +it.each([10, 100])( + 'recovers imported running roots before readiness and drains waiting input once (%i history items)', + async (n) => { + const { journal, child } = session() + expect(adapter.liveTurnId(SESSION)).toBeNull() + const { fence, generation } = child! + assert(typeof generation === 'string') + const turn = (id: string, ownFence = fence, agentId?: string) => + journal.appendItem( + { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: id }, + { kind: 'turn', turnId: id, state: 'running', startedAt: 1 }, + { + fence, + ownerFence: ownFence, + turnScope: AGENT_JOURNAL_THREAD_SCOPE, + ...(agentId ? { agentId } : {}) + } + ) + for (let i = 0; i < n; i++) { + await journal.appendItem( + { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: `history-${i}` }, + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: `Earlier reply ${i}` }] + }, + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + const stale = await turn('imported-root') + const foreign = await turn('foreign-root', fence - 1) + const subagent = await turn('child-root', fence, 'child') + let visits = 0 + let scanning = false + const settle = unheld.settleUnheldStructuredAgentSessionTurns + const scans = vi + .spyOn(unheld, 'settleUnheldStructuredAgentSessionTurns') + .mockImplementation(async (...args) => { + scanning = true + try { + await settle(...args) + } finally { + scanning = false + } + }) + const visit = journal.visitItemsWithLinkage.bind(journal) + vi.spyOn(journal, 'visitItemsWithLinkage').mockImplementation((visitor) => + visit((...args) => { + if (scanning) { + visits++ + } + visitor(...args) + }) + ) + const batch = vi.spyOn(journal, 'appendPlannedLifecycleBatch') + expect(await send('after the update')).toBeTruthy() + expect(claude.connections[0]!.sent).toHaveLength(0) + const before = visits + startupGate.resolve() + await ready() + await vi.waitFor(() => expect(claude.connections[0]!.sent).toHaveLength(1)) + echo() + await vi.waitFor(() => expect(journal.submissions()[0]?.dispatchState).toBe('accepted')) + await vi.waitFor(() => expect(loop().isRunning(SESSION)).toBe(false)) + expect(journal.item(stale.itemId)?.body).toMatchObject({ state: 'interrupted' }) + expect(journal.item(foreign.itemId)?.body).toMatchObject({ state: 'running' }) + expect(journal.item(subagent.itemId)?.body).toMatchObject({ state: 'running' }) + expect(batch).toHaveBeenCalledTimes(1) + expect(batch.mock.calls[0]![0].plan().mutations).toHaveLength(1) + // Count the actual startup scan, independently of pre-existing current-work reads. + expect(visits - before).toBe(n + 4) + const reconcile = (liveId: string | null | undefined) => + unheld.settleUnheldStructuredAgentSessionTurns(journal, SESSION, fence, generation, liveId) + const scanned = visits + const cursor = journal.cursor() + await reconcile(undefined) + expect(visits).toBe(scanned) + const live = adapter.liveTurnId(SESSION) + expect(live).toBeTruthy() + await reconcile(live) + expect(journal.cursor()).toEqual(cursor) + expect(batch).toHaveBeenCalledTimes(1) + const scanCount = scans.mock.calls.length + const finalVisits = visits + idle() + await host.flushStreamedEvents(SESSION) + expect(scans).toHaveBeenCalledTimes(scanCount) + expect(visits).toBe(finalVisits) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(claude.connections[0]!.sent).toHaveLength(1) + expect(host.currentWork(SESSION)?.activeTurnId()).toBeNull() + expect(host.readStatusSummary(SESSION)?.status).not.toBe('working') + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop-deps.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop-deps.ts index 1d24b91e097..dfd55afe4dc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop-deps.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop-deps.ts @@ -1,3 +1,4 @@ +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { UnsentSendHold } from '../agent-session-journal/journal-unsent-send-hold' import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode' import type { StructuredAgentSessionStartFlights } from './structured-agent-session-start-flight' @@ -19,7 +20,7 @@ import type { } from './structured-agent-session-host-types' export type StructuredAgentSessionDeliveryLoopDeps = { - startFlights?: Pick + startFlights?: Pick deliveryOrder: StructuredAgentSessionDeliveryOrder sessions: ReadonlyMap adapter: StructuredAgentSessionAdapter @@ -29,7 +30,11 @@ export type StructuredAgentSessionDeliveryLoopDeps = { trackStart: (start: Promise) => Promise startProviderChild: (sessionId: string, startedFor: string) => StructuredAgentSessionStartClaim /** Retire mismatched login/permission children under serialization. */ - retireOutgrownChild: (sessionId: string, continuingId?: string) => Promise + retireOutgrownChild: ( + sessionId: string, + continuingId?: string, + next?: AgentJournalSubmission + ) => Promise immediatePermission?: (sessionId: string) => AgentChatPermissionChoice | null retainedWork?: (sessionId: string, continuingId: string) => boolean readiness: ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts index 9bd311b3852..6ed7fabc334 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts @@ -1,6 +1,18 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' +import { + acceptedDispatch, + createRestTestRig, + foundRestTestChat, + REST_TEST_CALLER as CALLER, + REST_TEST_SESSION as SESSION, + restTestSend, + type RestTestRig +} from './structured-agent-session-rest-test-rig' +import * as handover from './structured-agent-session-handover' +import type { StructuredAgentSessionStartFlight } from './structured-agent-session-start-flight' import type { StructuredAgentSessionChildEndCause, StructuredAgentSessionEndedChild @@ -63,3 +75,196 @@ describe('what a child end means for the messages queued behind it', () => { } ) }) + +describe('delivery iteration settlement and ownership', () => { + let rig: RestTestRig + const loop = () => rig.host.collaboratorsForTests().conversationDelivery.loop + const journal = () => rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal + const fence = () => rig.store.getRecord(SESSION)!.lease.runtimeFence + const parked = () => vi.waitFor(() => expect(loop().isRunning(SESSION)).toBe(false)) + const timer = () => new Promise((resolve) => setTimeout(resolve, 0)) + beforeEach(async () => { + rig = await createRestTestRig() + await foundRestTestChat(rig) + await parked() + }) + afterEach(async () => { + vi.restoreAllMocks() + loop().dispose() + await rig.dispose() + }) + function noWrite() { + // Old rollback-shaped boundary: no provider admission and no journal commit. + // Bound a broken loop before it can starve timers on this shared machine. + let attempts = 0 + return vi.spyOn(handover, 'beginSubmissionHandover').mockImplementation(async () => { + if (attempts++ > 8) { + throw new Error('iteration bound') + } + return { run: async (commit) => commit(async () => {}) } + }) + } + async function pending() { + expect(await rig.host.send(CALLER, restTestSend('still owed', fence()))).toMatchObject({ + ok: true + }) + await parked() + return journal().submissions().at(-1)! + } + + it('parks a no-op dispatch/resolver, preserves unwritten input, then delivers once on a legitimate wake', async () => { + const blocked = noWrite() + const row = await pending() + expect(blocked.mock.calls.length).toBeLessThanOrEqual(2) + expect(row).toMatchObject({ dispatchState: 'pending', handoverRecorded: true }) + expect(row.handedOverAt).toBeUndefined() + await timer() + expect(rig.adapter.dispatch).toHaveBeenCalledTimes(1) + blocked.mockRestore() + const current = rig.host.collaboratorsForTests().sessions.get(SESSION)! + current.child!.phase = 'starting' + const ready = Promise.withResolvers<'ready'>() + const readiness = vi + .spyOn(loop()['deps'], 'readiness') + .mockReturnValue({ wait: () => ready.promise }) + const draining = loop().wake(SESSION) + await vi.waitFor(() => expect(readiness).toHaveBeenCalledTimes(1)) + const replacement = { ...current.child!, generation: 'replacement', phase: 'ready' as const } + current.child = replacement + current.lastEndedChild = ended('user-stop', true) + rig.adapter.dispatch.mockImplementationOnce(async () => { + expect(current.child).toBe(replacement) + return acceptedDispatch() + }) + ready.resolve('ready') + await draining + expect(journal().submissions().at(-1)?.dispatchState).toBe('accepted') + expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2) + await loop().wake(SESSION) + expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2) + }) + + it.each(['unchanged', 'completed', 'replacement', 'immediate'] as const)( + 'consumes only completion of the flight actually joined (%s)', + async (schedule) => { + const blocked = noWrite() + const flights = rig.host.collaboratorsForTests().runtimeState.startFlights + const done = Promise.withResolvers<{ ok: true }>() + const flight: StructuredAgentSessionStartFlight = { + attemptId: 'F', + outcome: done.promise, + abort() {} + } + flights.register(SESSION, flight) + const start = vi.spyOn(loop()['deps'], 'startProviderChild') + if (schedule === 'immediate') { + start.mockImplementation(() => ({ outcome: Promise.resolve({ ok: true }) })) + } + expect(await rig.host.send(CALLER, restTestSend('join F', fence()))).toMatchObject({ + ok: true + }) + await vi.waitFor(() => expect(start).toHaveBeenCalledTimes(1)) + if (schedule === 'completed') { + flights.end(SESSION, 'F') + } + const next = Promise.withResolvers<{ ok: true }>() + if (schedule === 'replacement') { + flights.register(SESSION, { ...flight, attemptId: 'G', outcome: next.promise }) + } + done.resolve({ ok: true }) + if (schedule === 'replacement') { + await vi.waitFor(() => expect(start).toHaveBeenCalledTimes(2)) + expect(loop().isRunning(SESSION)).toBe(true) + next.resolve({ ok: true }) + } + await parked() + await timer() + expect(start).toHaveBeenCalledTimes(schedule === 'replacement' ? 2 : 1) + expect(blocked).toHaveBeenCalledTimes(schedule === 'completed' ? 1 : 0) + expect(journal().submissions().at(-1)?.handedOverAt).toBeUndefined() + } + ) + + it('covers dispatch journal settlement, resolver, release and a wake during final settlement together', async () => { + const held = Promise.withResolvers() + const entered = Promise.withResolvers() + const boundary = vi + .spyOn(handover, 'beginSubmissionHandover') + .mockImplementation(async (ctx) => ({ + run: async (commit) => { + try { + // Dispatch itself writes, before the serialized resolver exists. + await ctx.journal.appendItem( + { provider: 'codex', threadId: 'thread', turnId: 'settled', ordinal: 0 }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'settled' }] }, + { fence: ctx.fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + entered.resolve() + await held.promise + await commit(async () => { + void loop().wake(SESSION) + }) + } finally { + boundary.mockImplementation(async () => { + if (boundary.mock.calls.length > 8) { + throw new Error('iteration bound') + } + return { run: async (commit) => commit(async () => {}) } + }) + } + } + })) + const sending = rig.host.send(CALLER, restTestSend('settlement wake', fence())) + await entered.promise + expect(await sending).toMatchObject({ ok: true }) + held.resolve() + await parked() + expect(boundary).toHaveBeenCalledTimes(2) + expect(journal().submissions().at(-1)?.handedOverAt).toBeUndefined() + // A wake at serialized parking creates a successor before the old finalizer runs. + const deps = loop()['deps'] + const park = deps.deliveryOrder.parkSends.bind(deps.deliveryOrder) + const successorHeld = Promise.withResolvers() + const successorEntered = Promise.withResolvers() + let successor: Promise | undefined + vi.spyOn(deps.deliveryOrder, 'parkSends').mockImplementationOnce((id) => { + park(id) + boundary.mockImplementationOnce(async () => ({ + run: async (commit) => { + successorEntered.resolve() + await successorHeld.promise + await commit(async () => {}) + } + })) + successor = loop().wake(id) + }) + await loop().wake(SESSION) + await successorEntered.promise + expect(loop().isRunning(SESSION)).toBe(true) + loop().dispose() + expect(loop()['running'].size).toBe(0) + successorHeld.resolve() + await successor + expect(loop()['runs'].size).toBe(0) + expect(journal().submissions().at(-1)?.handedOverAt).toBeUndefined() + await loop().wake(SESSION) + expect(loop().isRunning(SESSION)).toBe(false) + }) + + it('reclaims N and 10N parked runs with linear boundary fact reads', async () => { + const sessions = rig.host.collaboratorsForTests().sessions + const cursor = vi.spyOn(journal(), 'cursor') + for (const n of [10, 100]) { + cursor.mockClear() + for (let i = 0; i < n; i++) { + const id = `scale-${i}` + sessions.set(id, { ...sessions.get(SESSION)! }) + await loop().wake(id) + sessions.delete(id) + } + expect(cursor).toHaveBeenCalledTimes(2 * n) + expect(loop()['running'].size).toBe(0) + expect(loop()['runs'].size).toBe(0) + } + }) +}) 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 2a75de892d5..a5ea88e83db 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 @@ -21,6 +21,7 @@ import { holdRestartedStructuredAgentSessionSends } from './structured-agent-ses import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' import { waitsForCodexTurnOptions } from './structured-agent-session-message-options' import { sameProviderChild } from './structured-agent-session-provider-child' +import { DeliveryProgress } from './structured-agent-session-delivery-progress' export type { StructuredAgentSessionDeliveryLoopDeps } from './structured-agent-session-delivery-loop-deps' @@ -39,7 +40,7 @@ type Prepared = type StartFailure = NonNullable> export class StructuredAgentSessionDeliveryLoop { - private readonly running = new Set() + private readonly running = new Map() private readonly runs = new Map>() private disposed = false @@ -51,6 +52,7 @@ export class StructuredAgentSessionDeliveryLoop { dispose(): void { this.disposed = true + this.running.clear() this.deps.deliveryOrder.dispose() } @@ -59,6 +61,7 @@ export class StructuredAgentSessionDeliveryLoop { if (this.disposed) { return Promise.resolve() } + this.running.get(sessionId)?.woke() this.deps.deliveryOrder.reconcile( sessionId, this.deps.sessions.get(sessionId)?.journal.submissions() ?? [] @@ -66,22 +69,29 @@ export class StructuredAgentSessionDeliveryLoop { if (this.running.has(sessionId)) { return this.runs.get(sessionId) ?? Promise.resolve() } - this.running.add(sessionId) - const run = this.run(sessionId).finally(() => { + const progress = new DeliveryProgress() + this.running.set(sessionId, progress) + const run = this.run(sessionId, progress).finally(() => { if (this.runs.get(sessionId) === run) { this.runs.delete(sessionId) + if (this.running.get(sessionId) === progress) { + this.stop(sessionId) + } } }) this.runs.set(sessionId, run) return run } - private async run(sessionId: string): Promise { + private async run(sessionId: string, progress: DeliveryProgress): Promise { + // Only this run's current owner may act; a stopped or replaced run reads 'stop'. + const owned = (task: () => Promise) => + this.deps.serialize(sessionId, async () => + this.running.get(sessionId) === progress ? task() : ('stop' as const) + ) try { for (;;) { - const prepared = await this.deps.trackStart( - this.deps.serialize(sessionId, () => this.prepare(sessionId)) - ) + const prepared = await this.deps.trackStart(owned(() => this.prepare(sessionId, progress))) if (prepared === 'stop') { return } @@ -102,15 +112,13 @@ export class StructuredAgentSessionDeliveryLoop { } ) if (!completed) { - await this.deps - .serialize(sessionId, async () => this.stop(sessionId)) - .catch((error: unknown) => - this.deps.logger.warn('parking delivery after a failed start failed', { - scope: 'delivery-loop-start', - sessionId, - error - }) - ) + await owned(async () => this.stop(sessionId)).catch((error: unknown) => + this.deps.logger.warn('parking delivery after a failed start failed', { + scope: 'delivery-loop-start', + sessionId, + error + }) + ) return } // The flight settled its requester; joiners rederive their own work. @@ -120,7 +128,7 @@ export class StructuredAgentSessionDeliveryLoop { await prepared.readiness.wait() } await prepared.before - const handed = await this.deps.serialize(sessionId, () => + const handed = await owned(() => this.handOver( sessionId, prepared.awaited, @@ -145,30 +153,29 @@ export class StructuredAgentSessionDeliveryLoop { error }) const cause = { hostFault: true } as const - await this.deps - .serialize(sessionId, async () => { - try { - const session = this.deps.sessions.get(sessionId) - const selected = session && oldestQueuedSubmission(session) - await this.fail(sessionId, { startKey: null, cause }, selected?.clientMessageId ?? null) - } finally { - this.stop(sessionId) - } - }) - .catch((failure: unknown) => { - // Keep failed-settlement leftovers for the next accept/open recovery. - this.deps.logger.warn('recording a failed delivery failed', { - scope: 'delivery-loop-fail', - sessionId, - error: failure - }) + await owned(async () => { + try { + const session = this.deps.sessions.get(sessionId) + const selected = session && oldestQueuedSubmission(session) + await this.fail(sessionId, { startKey: null, cause }, selected?.clientMessageId ?? null) + } finally { + this.stop(sessionId) + } + }).catch((failure: unknown) => { + // Keep failed-settlement leftovers for the next accept/open recovery. + this.deps.logger.warn('recording a failed delivery failed', { + scope: 'delivery-loop-fail', + sessionId, + error: failure }) + }) } } - private async prepare(sessionId: string): Promise { + private async prepare(sessionId: string, progress: DeliveryProgress): Promise { const session = this.deps.sessions.get(sessionId) - if (!session || this.disposed) { + // A step that proved no change since the last one would repeat forever; park until a commit or wake. + if (!session || this.disposed || progress.decide(sessionId, this.deps).kind === 'park') { return this.stop(sessionId) } // Retry failed open recovery before delivery; never hand over earlier-process sends. @@ -187,19 +194,17 @@ export class StructuredAgentSessionDeliveryLoop { if (!oldest || (session.child && work && structuredAgentSessionCommandRunning(work))) { return this.stop(sessionId) } - if (this.deps.startFlights?.has(sessionId)) { - return { start: this.deps.startProviderChild(sessionId, oldest.clientMessageId) } + if (this.deps.startFlights?.get(sessionId)) { + return { start: progress.claim(sessionId, this.deps, oldest.clientMessageId) } } const failedStart = startThatFailedWhileQueued(session, oldest) if (failedStart) { return this.fail(sessionId, failedStart, oldest.clientMessageId) } - await this.deps.retireOutgrownChild(sessionId) + await this.deps.retireOutgrownChild(sessionId, undefined, oldest) const child = this.deps.sessions.get(sessionId)?.child if (!child || child.close) { - return { - start: this.deps.startProviderChild(sessionId, oldest.clientMessageId) - } + return { start: progress.claim(sessionId, this.deps, oldest.clientMessageId) } } return { ok: true, @@ -253,7 +258,7 @@ export class StructuredAgentSessionDeliveryLoop { } if (waited) { // Verify awaited-child identity before rechecking launch permissions. - await this.deps.retireOutgrownChild(sessionId) + await this.deps.retireOutgrownChild(sessionId, undefined, next) if (session.child !== awaitedChild) { return 'continue' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-progress.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-progress.ts new file mode 100644 index 00000000000..db963ad60be --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-progress.ts @@ -0,0 +1,63 @@ +import type { StructuredAgentSessionDeliveryLoopDeps } from './structured-agent-session-delivery-loop-deps' +import type { StructuredAgentSessionStartFlight } from './structured-agent-session-start-flight' + +function sample(sessionId: string, reads: StructuredAgentSessionDeliveryLoopDeps, wakes: number) { + const session = reads.sessions.get(sessionId) + const child = session?.child + return { + journal: session?.journal.cursor(), + child: [child?.generation, child?.fence, child?.phase, child?.close !== undefined] as const, + wakes + } +} + +function transition(witness: T) { + return { kind: 'transition', witness } as const +} + +/** Owned by one active run; each serialized decision consumes every preceding iteration fact. */ +export class DeliveryProgress { + private wakes = 0 + private before?: ReturnType + // The loop decides again only after awaiting this joined claim, so it has settled by then. + private joined?: StructuredAgentSessionStartFlight + + woke(): void { + this.wakes += 1 + } + + /** Read any existing flight before this step can register its own; only a joined claim proves startup. */ + claim(sessionId: string, reads: StructuredAgentSessionDeliveryLoopDeps, startedFor: string) { + const flight = reads.startFlights?.get(sessionId) + const claim = reads.startProviderChild(sessionId, startedFor) + this.joined = flight?.outcome === claim.outcome ? flight : undefined + return claim + } + + decide(sessionId: string, reads: StructuredAgentSessionDeliveryLoopDeps) { + const now = sample(sessionId, reads, this.wakes) + const before = this.before + const joined = this.joined + this.before = now + this.joined = undefined + if (!before) { + return { kind: 'initial' } as const + } + if ( + before.journal?.epoch !== now.journal?.epoch || + before.journal?.sequence !== now.journal?.sequence + ) { + return transition({ kind: 'journal', before, now } as const) + } + if (before.child.some((value, index) => value !== now.child[index])) { + return transition({ kind: 'child', before, now } as const) + } + if (joined && reads.startFlights?.get(sessionId) !== joined) { + return transition({ kind: 'startup', flight: joined } as const) + } + if (before.wakes !== now.wakes) { + return transition({ kind: 'wake', before: before.wakes, after: now.wakes } as const) + } + return { kind: 'park' } as const + } +} 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 bf690f80d91..b88a7347c82 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 @@ -104,12 +104,20 @@ export function createStructuredAgentSessionConversationDelivery(input: { serialize: input.serialize, trackStart: input.trackStart, startProviderChild: input.startProviderChild, - retireOutgrownChild: async (sessionId, continuingId) => { + retireOutgrownChild: async (sessionId, continuingId, next) => { // Defer retirement while personal Stop holds handover; its next step decides. const session = input.clientDelivery.readStopping(sessionId) ? undefined : sessions.get(sessionId) await retireOutgrownStructuredAgentSessionChild(sessionId, session, { + next, + requiresModelTransitionRestart: (submission) => + session?.child?.phase === 'ready' && + deps.adapter.requiresModelTransitionRestart?.({ + sessionId, + fence: session.child.fence, + executionOptions: submission.options + }) === true, work: retainedWork(sessionId, continuingId), launchPermission: async () => { const record = deps.store.getRecord(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts index 45b08393992..f4db09af8cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts @@ -1,6 +1,5 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire' import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' @@ -10,101 +9,22 @@ function record(provider: string, saved: Record | undefined): Ag return { sessionId: 'session-1', provider, options, permissionMode } as AgentSessionRecord } -const listing = ( - models: { id: string; isDefault?: boolean; supportsAutoMode?: boolean }[] -): AgentSessionModelCatalogResult => ({ - origin: 'probe', - fetchedAt: 1, - models: models.map((model) => ({ - label: model.id, - efforts: [], - isDefault: false, - ...model - })) -}) - -function launchPermission( - provider: string, - options: Record | undefined, - answer: AgentSessionModelCatalogResult | Error = { origin: 'unknown' } -) { - const read = vi.fn(async () => { - if (answer instanceof Error) { - throw answer - } - return answer - }) - const modelCatalog = { - peek: () => null, - read, - retainedLiveModelIds: vi.fn(async () => []), - recordLiveListing: vi.fn(), - prewarm: vi.fn(async () => undefined), - stop: vi.fn(), - providerStarted: vi.fn() - } - return { - read, - result: structuredAgentSessionLaunchPermission( - { agents: claudeAndCodexDeclared(), modelCatalog }, - record(provider, options) - ) - } +function launchPermission(provider: string, options?: Record) { + return structuredAgentSessionLaunchPermission( + { agents: claudeAndCodexDeclared() }, + record(provider, options) + ) } describe('the permission mode a chat starts with', () => { - it('launches a saved mode that needs no model as it is, without reading the catalog', async () => { - const claude = launchPermission('claude', { permissionMode: 'accept-edits' }) - await expect(claude.result).resolves.toBe('accept-edits') - expect(claude.read).not.toHaveBeenCalled() - const codex = launchPermission('codex', { permissionMode: 'auto' }) - await expect(codex.result).resolves.toBe('auto') - expect(codex.read).not.toHaveBeenCalled() - }) - - it('asks for a chat saved before modes existed', async () => { - await expect(launchPermission('claude', undefined).result).resolves.toBe('ask') - }) - - it('runs Claude auto on a model the saved catalog lists as supporting it', async () => { - const supported = launchPermission( - 'claude', - { permissionMode: 'auto', model: 'opus' }, - listing([{ id: 'opus', supportsAutoMode: true }]) - ) - await expect(supported.result).resolves.toBe('auto') - expect(supported.read).toHaveBeenCalledWith(expect.objectContaining({ savedOnly: true })) - }) - - it('matches a chat with no model to the listed default', async () => { - await expect( - launchPermission( - 'claude', - { permissionMode: 'auto' }, - listing([ - { id: 'sonnet', isDefault: true, supportsAutoMode: false }, - { id: 'opus', supportsAutoMode: true } - ]) - ).result - ).resolves.toBe('ask') - }) - - it('asks when the catalog cannot confirm the model runs auto', async () => { - for (const answer of [ - { origin: 'unknown' } as const, - listing([{ id: 'opus' }]), - listing([{ id: 'sonnet', supportsAutoMode: true }]), - new Error('catalog unavailable') - ]) { - await expect( - launchPermission('claude', { permissionMode: 'auto', model: 'opus' }, answer).result - ).resolves.toBe('ask') + it('delegates saved own settings', async () => { + for (const provider of ['claude', 'codex']) { + await expect(launchPermission(provider, { permissionMode: 'own' })).resolves.toBe('own') } }) - it('has none for an agent with no modes', async () => { - await expect(launchPermission('pi', { permissionMode: 'full-access' }).result).resolves.toBe( - null - ) + it('has none for an unregistered agent or one with no modes', async () => { + await expect(launchPermission('grok', { permissionMode: 'full-access' })).resolves.toBe(null) + await expect(launchPermission('pi', { permissionMode: 'full-access' })).resolves.toBe(null) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts index 77e63b23db9..5c56f16ad21 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts @@ -5,48 +5,20 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentChatPermissionModes, savedChatPermissionMode, - chatModelSupportsAutoMode, - chatPermissionModeNeedsModelSupport, - effectiveChatPermissionMode, type AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' -/** Use only the saved, folder-scoped catalog so startup never waits on a listing. */ -async function listedAutoModeSupport( - catalog: StructuredAgentSessionHostDeps['modelCatalog'], - record: Pick -): Promise { - const listing = await catalog - ?.read({ - agent: record.provider, - sessionId: record.sessionId, - savedOnly: true, - inSessionWorkspace: true - }) - .catch(() => null) - return ( - listing !== null && - listing !== undefined && - listing.origin !== 'unknown' && - chatModelSupportsAutoMode(listing.models, record.options?.model) - ) -} - /** null for unregistered agents or agents without permission modes. */ export async function structuredAgentSessionLaunchPermission( - deps: Pick, + deps: Pick, record: Pick ): Promise { const support = agentChatPermissionModes(record.provider) if (!support || !deps.agents.definition(record.provider)) { return null } - const intent = record.permissionMode - const autoSupported = - chatPermissionModeNeedsModelSupport(support, intent) && - (await listedAutoModeSupport(deps.modelCatalog, record)) - return effectiveChatPermissionMode(support, intent, autoSupported) + return savedChatPermissionMode(support, record.permissionMode) } /** A synchronous admission read for providers whose modes need no model-catalog evidence. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts index ccf55aa9819..2b8d88d3ce4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts @@ -14,10 +14,7 @@ import { retireOutgrownStructuredAgentSessionChild, structuredAgentSessionChildReportedSignedOut } from './structured-agent-session-outgrown-child' -import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' -import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import type { AgentJournalItemBody, @@ -128,6 +125,7 @@ const conversation = ( visit(item.itemId, index, item.body, item.agentId ? { agentId: item.agentId } : {}) ), itemFence: (itemId: string) => items.find((item) => item.itemId === itemId)?.fence, + itemBody: (itemId: string) => items.find((item) => item.itemId === itemId)?.body ?? null, newestTurn: (): AgentJournalTurnLifecycle | null => input.newestTurnEndedAt === undefined ? null @@ -293,87 +291,6 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { ).not.toHaveBeenCalled() }) - describe('against the mode a start launches with', () => { - // Model Claude’s saved catalog: resolved default alias, auto on every model except haiku. - const listed = [ - { id: 'opus[1m]', isDefault: true, supportsAutoMode: true }, - { id: 'sonnet', isDefault: false, supportsAutoMode: true }, - { id: 'haiku', isDefault: false } - ] - const catalog = (rows: typeof listed) => ({ - peek: () => null, - read: vi.fn(async () => ({ - origin: 'probe' as const, - fetchedAt: 1, - models: rows.map((row) => ({ ...row, label: row.id, efforts: [] })) - })), - retainedLiveModelIds: vi.fn(async () => []), - recordLiveListing: vi.fn(), - prewarm: vi.fn(async () => undefined), - stop: vi.fn(), - providerStarted: vi.fn() - }) - const launch = ({ permissionMode, ...options }: Record, rows = listed) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch mode reads only the record's id, agent, options and mode. - const record = { - sessionId: 'session', - provider: 'claude', - options, - permissionMode - } as AgentSessionRecord - return () => - structuredAgentSessionLaunchPermission( - { agents: claudeAndCodexDeclared(), modelCatalog: catalog(rows) }, - record - ) - } - const stoppedAfterLaunch = async ( - launched: AgentChatPermissionChoice, - current: () => Promise - ) => - (await retire({ child: childAt({ permissionMode: launched }) }, {}, undefined, current)) - .stopAgent.mock.calls.length > 0 - - it('restarts Full access or Accept edits into Ask when auto is not confirmed', async () => { - const unconfirmed = [ - launch({ model: 'haiku', permissionMode: 'auto' }), - launch({ permissionMode: 'auto' }, [{ id: 'opus[1m]', isDefault: true }]) - ] - for (const autoPick of unconfirmed) { - await expect(autoPick()).resolves.toBe('ask') - expect(await stoppedAfterLaunch('full-access', autoPick)).toBe(true) - expect(await stoppedAfterLaunch('accept-edits', autoPick)).toBe(true) - expect(await stoppedAfterLaunch('ask', autoPick)).toBe(false) - } - }) - - it('never restarts a child it just started, from an alias or a resolved id', async () => { - const records: Record[] = [ - { permissionMode: 'auto' }, - { model: 'default', permissionMode: 'auto' }, - { model: 'opus[1m]', permissionMode: 'auto' }, - { model: 'sonnet', permissionMode: 'auto' }, - { model: 'haiku', permissionMode: 'auto' }, - { model: 'claude-opus-5-5[1m]', permissionMode: 'auto' } - ] - const runsAuto = [true, true, true, true, false, false] - for (const [index, options] of records.entries()) { - const current = launch(options) - const launched = await current() - expect(launched).toBe(runsAuto[index] ? 'auto' : 'ask') - expect(await stoppedAfterLaunch(launched!, current)).toBe(false) - } - }) - - // A resolved ID initially launches Ask; the reported catalog alias enables auto after one restart. - it('settles after one restart once a resolved id is reported as its alias', async () => { - const first = await launch({ model: 'claude-opus-5-5[1m]', permissionMode: 'auto' })() - const reported = launch({ model: 'opus[1m]', permissionMode: 'auto' }) - expect(await stoppedAfterLaunch(first!, reported)).toBe(true) - expect(await stoppedAfterLaunch((await reported())!, reported)).toBe(false) - }) - }) - it('never reads the mode for a child launched with none', async () => { const current = now('full-access') expect((await retire({}, {}, undefined, current)).stopAgent).not.toHaveBeenCalled() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts index 76326c3a253..0583009be07 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts @@ -12,9 +12,13 @@ import { } from './structured-agent-session-idle-sweep' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' type OutgrownChildReading = Pick & { - journal: Pick & { + journal: Pick< + AgentSessionJournal, + 'visitItemsWithLinkage' | 'itemFence' | 'newestTurn' | 'itemBody' + > & { submissions(): readonly Pick[] } } @@ -81,6 +85,23 @@ async function launchedWithOtherPermissionMode( ) } +/** Orca mail notices are not the user's turn: they keep the running process and its choices. */ +function picksNewLaunchModel( + session: OutgrownChildReading, + next: AgentJournalSubmission | undefined, + requiresModelTransitionRestart: ((next: AgentJournalSubmission) => boolean) | undefined +): boolean { + if (!next || !requiresModelTransitionRestart) { + return false + } + const body = session.journal.itemBody(agentJournalSubmissionKey(next.clientMessageId)) + return ( + body?.kind === 'message' && + body.from?.orchestration?.message !== 'mail-notice' && + requiresModelTransitionRestart(next) + ) +} + /** Retire under serialization only when no work is owed; failed Stop leaves a close for startup to join. */ export async function retireOutgrownStructuredAgentSessionChild( sessionId: string, @@ -91,6 +112,9 @@ export async function retireOutgrownStructuredAgentSessionChild( childWork: () => readonly AgentChildWorkView[] | undefined } launchPermission?: () => Promise + /** The send about to be handed over; a new model pick may need the requested approval mode. */ + next?: AgentJournalSubmission + requiresModelTransitionRestart?: (next: AgentJournalSubmission) => boolean permissionRequiresAcquisition?: (mode: AgentChatPermissionChoice) => boolean stopAgent: (sessionId: string) => Promise logger: StructuredAgentSessionLogger @@ -100,6 +124,7 @@ export async function retireOutgrownStructuredAgentSessionChild( !session || !( structuredAgentSessionChildReportedSignedOut(session) || + picksNewLaunchModel(session, deps.next, deps.requiresModelTransitionRestart) || (await launchedWithOtherPermissionMode( session.child, deps.launchPermission, 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 ca0a18e46c3..f1276532a58 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 @@ -22,6 +22,7 @@ import { import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import { recordAgentSessionProviderHandle } from '../../runtime/agent-session/ownership/agent-session-provider-handle-transition' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { settleUnheldStructuredAgentSessionTurns } from './structured-agent-session-unheld-turns' import { applyStructuredRewindRecovery, readStructuredRewindRecovery, @@ -38,6 +39,8 @@ export type StructuredAgentSessionProviderStartedContext = { startupAttempts: Pick acquireAborts: Pick } + /** Commits the child's buffered lifecycle events. */ + flushLifecycle?: (sessionId: string) => Promise /** Release startup’s delivery barrier and drain all currently handable sends. */ wakeDelivery: (sessionId: string) => Promise stopStartingChild: (sessionId: string, child: StructuredAgentSessionProviderChildIdentity) => void @@ -56,6 +59,8 @@ export async function settleStructuredAgentSessionProviderStarted( ): Promise { // Read provider state outside serialization so sends can still be accepted. const rewind = await readRewindAtStart(context, event, signal) + // Restored history is committed and the child's own events drained before its turns are judged. + await context.flushLifecycle?.(event.sessionId) // Follow attach publication so its proven lease has committed. await context.serialize(event.sessionId, async () => { signal?.throwIfAborted() @@ -84,6 +89,19 @@ export async function settleStructuredAgentSessionProviderStarted( ) { return null } + await settleUnheldStructuredAgentSessionTurns( + session.journal, + event.sessionId, + child.fence, + event.acquisitionGeneration, + context.deps.adapter.liveTurnId?.(event.sessionId) + ).catch((error: unknown) => + context.deps.logger.warn('settling turns the agent is not running failed', { + scope: 'unheld-turns', + sessionId: event.sessionId, + error + }) + ) context.runtimeState.startupAttempts.ready(event.sessionId, child) noteStructuredAgentSessionRuntimePermission(session.child, event.reportedOptions, true) context.publishStatus?.(event.sessionId) 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 f075186b3e3..07be40b9d31 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 @@ -146,7 +146,7 @@ export async function recordStructuredAgentSessionRanOptions( const reported = childRuns.get(child)?.reported // A report only completes an agent's own evidence of the dispatch, never stands in for it. const newerModel = checkpoint && reported !== checkpoint.reported ? reported?.model : undefined - const model = executed ? (executed.model ?? message?.model ?? newerModel) : message?.model + const model = executed ? (executed.model ?? newerModel) : message?.model childRuns.set(child, { ...childRuns.get(child), owed: executed && !model ? handed : undefined }) if (!model) { return diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unheld-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unheld-turns.ts new file mode 100644 index 00000000000..c636dfe489f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unheld-turns.ts @@ -0,0 +1,50 @@ +// A turn row reading running under the live child that the child itself is not running is over. +// Earlier children's rows are swept before attach; this covers rows that reach the live fence +// without the child's own lifecycle, such as restored history. + +import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { runningTurnLifecycleRevisions } from './structured-agent-session-stale-turn-verdict' + +/** `liveTurnId` undefined means the agent cannot say, so nothing is settled. */ +export async function settleUnheldStructuredAgentSessionTurns( + journal: Pick< + AgentSessionJournal, + 'visitItemsWithLinkage' | 'itemFence' | 'item' | 'appendPlannedLifecycleBatch' + >, + sessionId: string, + fence: number, + generation: string, + liveTurnId: string | null | undefined +): Promise { + if (liveTurnId === undefined) { + return + } + const unheld: AgentJournalRenderItem[] = [] + journal.visitItemsWithLinkage((itemId, _sequence, body, attribution) => { + const item = + body.kind === 'turn' && + body.state === 'running' && + body.turnId !== liveTurnId && + isRootAgentJournalItem(attribution) && + journal.itemFence(itemId) === fence + ? journal.item(itemId) + : null + if (item) { + unheld.push(item) + } + }) + if (unheld.length === 0) { + return + } + await journal.appendPlannedLifecycleBatch({ + settlementId: `unheld-turns:${sessionId}:${fence}:${generation}`, + fence, + recovered: true, + plan: () => ({ + mutations: runningTurnLifecycleRevisions(unheld, { state: 'interrupted' }), + dispatches: [] + }) + }) +} 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 28270f04581..32d609284eb 100644 --- a/src/main/runtime/claude-structured-retired-saved-model.test.ts +++ b/src/main/runtime/claude-structured-retired-saved-model.test.ts @@ -58,65 +58,89 @@ async function startedWith(options: Record): Promise { - it('keeps the saved pick while the live child retires its missing model', 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) + it.each([undefined, 'opus[1m]'])( + 'keeps the saved pick and applies the replacement %s after an unsolicited model error', + async (replacement) => { + 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 host.flushStreamedEvents(SESSION) - expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({ - model: RETIRED, - effort: 'high' - }) - const live = await host.deps.adapter.readOptions?.({ - sessionId: SESSION, - fence: host.deps.store.getRecord(SESSION)!.lease.runtimeFence - }) - expect(live).toMatchObject({ current: { model: undefined } }) - const answer = await vi.waitFor(async () => { - const read = await host.readOptions(SESSION, { readsWithoutModel: true }) - expect(read.current.model).not.toBe(RETIRED) - expect(read.current).toMatchObject({ model: 'sonnet', defaulted: [] }) - return read - }) - const seed = structuredAgentSessionSeedCatalog('claude') - const reopened = applyStructuredAgentSessionOptions( - createStructuredAgentSessionOptionState('claude', seed), - seed, - answer - ) - const options = structuredAgentSessionSendSelection(reopened) - expect(options).toEqual({ model: answer.current.model, effort: 'high' }) - const dispatch = vi.spyOn(host.deps.adapter, 'dispatch') - const setModel = vi.spyOn(claude.child(SESSION).connection, 'setModel') - const body = hostTestMessage('try the replacement') - const sent = await host.send(CALLER, { - envelope: { + claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) + await host.flushStreamedEvents(SESSION) + expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({ + model: RETIRED, + effort: 'high' + }) + const live = await host.deps.adapter.readOptions?.({ sessionId: SESSION, - clientOperationId: `${Date.now()}-${'2'.padStart(32, '0')}`, - expectedRuntimeFence: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body, options } + fence: host.deps.store.getRecord(SESSION)!.lease.runtimeFence + }) + expect(live).toMatchObject({ current: { model: undefined } }) + const answer = await vi.waitFor(async () => { + const read = await host.readOptions(SESSION, { readsWithoutModel: true }) + expect(read.current.model).not.toBe(RETIRED) + expect(read.current).toMatchObject({ model: 'sonnet', defaulted: [] }) + return read + }) + const seed = structuredAgentSessionSeedCatalog('claude') + const reopened = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('claude', seed), + seed, + answer + ) + const options = { + ...structuredAgentSessionSendSelection(reopened), + ...(replacement ? { model: replacement } : {}) + } + expect(options).toEqual({ model: replacement ?? answer.current.model, effort: 'high' }) + const dispatch = vi.spyOn(host.deps.adapter, 'dispatch') + const connection = claude.child(SESSION).connection + let appliedModel: string | undefined = 'sonnet' + const originalSetModel = connection.setModel + const setModel = vi + .spyOn(connection, 'setModel') + .mockImplementation(async (model, options) => { + await originalSetModel(model, options) + appliedModel = model }) - }, - body, - options - }) - expect(sent).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) - expect(dispatch.mock.calls[0]?.[0].executionOptions).toEqual({ - model: answer.current.model, - effort: 'high' - }) - expect(setModel.mock.calls.map(([model]) => model)).toEqual([answer.current.model]) - expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({ - model: RETIRED, - effort: 'high' - }) - }) + const originalSend = connection.send + const modelsAtWrite: (string | undefined)[] = [] + vi.spyOn(connection, 'send').mockImplementation(async (message, prepare) => { + await prepare?.() + modelsAtWrite.push(appliedModel) + return originalSend(message) + }) + const body = hostTestMessage('try the replacement') + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: `${Date.now()}-${'2'.padStart(32, '0')}`, + expectedRuntimeFence: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body, options } + }) + }, + body, + options + }) + expect(sent).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + expect(dispatch.mock.calls[0]?.[0].executionOptions).toEqual({ + model: replacement ?? answer.current.model, + effort: 'high' + }) + expect(setModel.mock.calls.map(([model]) => model)).toEqual([ + replacement ?? answer.current.model + ]) + expect(modelsAtWrite).toEqual([replacement ?? answer.current.model]) + expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({ + model: RETIRED, + effort: 'high' + }) + } + ) it("keeps a model the reply did not come from: a subagent's own error", async () => { const host = await startedWith({ model: RETIRED }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx index 9599bc54749..d8c275c6de5 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx @@ -226,7 +226,10 @@ describe('a permission pick on this device', () => { ) const modelWrite = Promise.withResolvers<{ options: Record }>() const modeWrite = Promise.withResolvers<{ options: Record }>() - vi.mocked(mutate).mockReturnValueOnce(modelWrite.promise).mockReturnValueOnce(modeWrite.promise) + vi.mocked(mutate) + .mockReturnValueOnce(modelWrite.promise) + .mockReturnValueOnce(modeWrite.promise) + .mockResolvedValue({ options: { permissionMode: 'auto' } }) const claude = renderReopened(1, 'claude') await waitFor(() => expect(permission(claude.result.current.optionSnapshot)).toBe('ask')) const choices = (snapshot: readonly SessionOptionDescriptor[]) => { @@ -242,11 +245,15 @@ describe('a permission pick on this device', () => { void captured('permissionMode', 'full-access') }) expect(permission(claude.result.current.optionSnapshot)).toBe('full-access') - expect(choices(claude.result.current.optionSnapshot)).not.toContain('auto') - expect(await captured('permissionMode', 'auto')).toBe(false) - const returned = await claude.result.current.optionSurface.setOption('permissionMode', 'auto') - expect(choices(returned.snapshot)).not.toContain('auto') - expect(vi.mocked(mutate)).toHaveBeenCalledTimes(4) + expect(choices(claude.result.current.optionSnapshot)).toContain('auto') + await act(async () => { + expect(await captured('permissionMode', 'auto')).toBe(true) + }) + await act(async () => { + const returned = await claude.result.current.optionSurface.setOption('permissionMode', 'auto') + expect(choices(returned.snapshot)).toContain('auto') + }) + expect(vi.mocked(mutate)).toHaveBeenCalledTimes(6) model = 'model-b' await act(async () => modeWrite.resolve({ options: { permissionMode: 'full-access' } })) await act(async () => modelWrite.resolve({ options: { model } })) diff --git a/src/shared/agent-chat-permission-mode.test.ts b/src/shared/agent-chat-permission-mode.test.ts index e18fc5faa35..8899a435c81 100644 --- a/src/shared/agent-chat-permission-mode.test.ts +++ b/src/shared/agent-chat-permission-mode.test.ts @@ -2,11 +2,8 @@ import { describe, expect, it } from 'vitest' import { AGENT_CHAT_PERMISSION_MODES, agentChatPermissionModes, - chatModelSupportsAutoMode, - chatPermissionModeNeedsModelSupport, - effectiveChatPermissionMode, newChatPermissionMode, - offeredChatPermissionModes + savedChatPermissionMode } from './agent-chat-permission-mode' const CLAUDE_CHAT_PERMISSION_MODES = agentChatPermissionModes('claude')! @@ -27,7 +24,6 @@ describe('agent chat permission modes', () => { it('seeds a new chat from the host setting, narrowed to what its agent offers', () => { expect(newChatPermissionMode('codex', 'accept-edits')).toBe('ask') expect(newChatPermissionMode('claude', 'accept-edits')).toBe('accept-edits') - // Claude's auto is narrowed at launch by the model, so the chat keeps its choice. expect(newChatPermissionMode('claude', 'auto')).toBe('auto') expect(newChatPermissionMode('codex', 'full-access')).toBe('full-access') expect(newChatPermissionMode('claude', 'retired-mode')).toBe('ask') @@ -39,62 +35,10 @@ describe('agent chat permission modes', () => { }) describe('the mode a chat runs', () => { - it('runs Claude auto only on a model listed as supporting it', () => { - expect(effectiveChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, 'auto', true)).toBe('auto') - expect(effectiveChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, 'auto', false)).toBe('ask') - expect(chatPermissionModeNeedsModelSupport(CLAUDE_CHAT_PERMISSION_MODES, 'auto')).toBe(true) - expect(chatPermissionModeNeedsModelSupport(CLAUDE_CHAT_PERMISSION_MODES, 'ask')).toBe(false) - }) - - it('runs Codex auto on any model', () => { - expect(effectiveChatPermissionMode(CODEX_CHAT_PERMISSION_MODES, 'auto', false)).toBe('auto') - expect(chatPermissionModeNeedsModelSupport(CODEX_CHAT_PERMISSION_MODES, 'auto')).toBe(false) - }) - it('asks for a chat saved before modes existed, or with a value its agent cannot run', () => { - expect(effectiveChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, undefined, true)).toBe('ask') - expect(effectiveChatPermissionMode(CODEX_CHAT_PERMISSION_MODES, 'accept-edits', true)).toBe( - 'ask' - ) - expect( - effectiveChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, 'bypassPermissions', true) - ).toBe('ask') - }) - - it('offers Approve for me only where the model runs it', () => { - expect(offeredChatPermissionModes(CLAUDE_CHAT_PERMISSION_MODES, false)).toEqual([ - 'ask', - 'accept-edits', - 'full-access' - ]) - expect(offeredChatPermissionModes(CLAUDE_CHAT_PERMISSION_MODES, true)).toEqual( - AGENT_CHAT_PERMISSION_MODES - ) - expect(offeredChatPermissionModes(CODEX_CHAT_PERMISSION_MODES, false)).toEqual([ - 'ask', - 'auto', - 'full-access' - ]) - }) -}) - -describe('the model permission catalog', () => { - // The catalog shape `claude` 2.1.280 lists: the default row is folded into the row it resolves to. - it('reads auto support from the chat model row, or the CLI default when it names none', () => { - const models = [ - { id: 'opus[1m]', isDefault: true, supportsAutoMode: true }, - { id: 'sonnet', isDefault: false, supportsAutoMode: true }, - { id: 'haiku', isDefault: false } - ] - for (const model of [undefined, null, 'default', 'opus[1m]', 'sonnet']) { - expect(chatModelSupportsAutoMode(models, model)).toBe(true) - } - for (const model of ['haiku', 'claude-opus-5-5[1m]', 'retired-model']) { - expect(chatModelSupportsAutoMode(models, model)).toBe(false) - } - // No row the CLI runs by default: unknown, so no auto. - expect(chatModelSupportsAutoMode([{ id: 'opus[1m]', supportsAutoMode: true }], undefined)).toBe( - false - ) + expect(savedChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, undefined)).toBe('ask') + expect(savedChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, null)).toBe('ask') + expect(savedChatPermissionMode(CODEX_CHAT_PERMISSION_MODES, 'accept-edits')).toBe('ask') + expect(savedChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, 'bypassPermissions')).toBe('ask') }) }) diff --git a/src/shared/agent-chat-permission-mode.ts b/src/shared/agent-chat-permission-mode.ts index 754aed17ba7..a686490b563 100644 --- a/src/shared/agent-chat-permission-mode.ts +++ b/src/shared/agent-chat-permission-mode.ts @@ -71,47 +71,6 @@ export function savedChatPermissionMode( return support.modes.find((mode) => mode === intent) ?? 'ask' } -/** Launch and menu share model auto support: use its listed row, or the listed CLI default when unnamed. */ -export function chatModelSupportsAutoMode( - models: readonly { id: string; isDefault?: boolean; supportsAutoMode?: boolean }[], - model: string | null | undefined -): boolean { - const row = models.find((entry) => - model === undefined || model === null || model === 'default' - ? entry.isDefault === true - : entry.id === model - ) - return row?.supportsAutoMode === true -} - -export function chatPermissionModeNeedsModelSupport( - support: AgentChatPermissionModeSupport, - intent: unknown -): boolean { - return ( - support.autoNeedsModelSupport === true && savedChatPermissionMode(support, intent) === 'auto' - ) -} - -/** Saved choice, with `auto` on a model not listed for it read as Ask; never written back. */ -export function effectiveChatPermissionMode( - support: AgentChatPermissionModeSupport, - intent: unknown, - autoSupported: boolean -): AgentChatPermissionChoice { - const mode = savedChatPermissionMode(support, intent) - return mode === 'auto' && support.autoNeedsModelSupport && !autoSupported ? 'ask' : mode -} - -export function offeredChatPermissionModes( - support: AgentChatPermissionModeSupport, - autoSupported: boolean -): readonly AgentChatPermissionMode[] { - return support.autoNeedsModelSupport && !autoSupported - ? support.modes.filter((mode) => mode !== 'auto') - : support.modes -} - export function newChatPermissionMode( agent: string, setting: unknown diff --git a/src/shared/agent-chat-permission-support.ts b/src/shared/agent-chat-permission-support.ts index 02b65dbf0cf..995212d5c56 100644 --- a/src/shared/agent-chat-permission-support.ts +++ b/src/shared/agent-chat-permission-support.ts @@ -2,13 +2,10 @@ import type { AgentChatPermissionMode } from './agent-chat-permission-mode' /** The modes one agent's chats offer. */ export type AgentChatPermissionModeSupport = { modes: readonly AgentChatPermissionMode[] - /** `auto` runs only on a model the agent's listing says supports it. */ - autoNeedsModelSupport?: true } const CLAUDE_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = { - modes: ['ask', 'accept-edits', 'auto', 'full-access'], - autoNeedsModelSupport: true + modes: ['ask', 'accept-edits', 'auto', 'full-access'] } const CODEX_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = { diff --git a/src/shared/structured-agent-session-permission-option.test.ts b/src/shared/structured-agent-session-permission-option.test.ts index d4a95ed951a..d3cc08e4a4b 100644 --- a/src/shared/structured-agent-session-permission-option.test.ts +++ b/src/shared/structured-agent-session-permission-option.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionOptionsResult } from './agent-session-wire' import { sortNativeChatSessionOptions } from './native-chat-session-option-snapshot' import { - applyStructuredAgentSessionModelCatalog, applyStructuredAgentSessionOptions, commitStructuredAgentSessionOption, commitStructuredAgentSessionOptionValues, @@ -98,60 +97,21 @@ describe('the permission mode picker', () => { ]) }) - it("shows the host's mode as stated, and offers Claude auto only on a model listed for it", () => { - const offered = (autoMode: boolean | undefined, mode = 'ask') => { - const descriptor = permission(live('claude', { permissionMode: mode }, autoMode)) - return descriptor?.kind.type === 'select' && descriptor.kind.choices.map((c) => c.value) - } - expect(offered(true)).toEqual(['ask', 'accept-edits', 'auto', 'full-access']) - expect(offered(false)).toEqual(['ask', 'accept-edits', 'full-access']) - expect(offered(undefined)).toEqual(['ask', 'accept-edits', 'full-access']) - // The host decides what runs; a client catalog that cannot confirm it never relabels it. + it('offers all Claude modes regardless of catalog support, and shows the host mode as stated', () => { + const unsupported = permission(live('claude', { permissionMode: 'ask' }, false)) + expect( + unsupported?.kind.type === 'select' && unsupported.kind.choices.map((c) => c.value) + ).toEqual(['ask', 'accept-edits', 'auto', 'full-access']) expect(permission(live('claude', { permissionMode: 'auto' }, false))).toMatchObject({ kind: { currentValue: 'auto' } }) - expect(offered(false, 'auto')).toEqual(['ask', 'accept-edits', 'auto', 'full-access']) - const changing = { ...live('claude', { permissionMode: 'ask' }, true), pendingId: 'model' } - expect(permission(changing)?.kind).toMatchObject({ - choices: expect.not.arrayContaining([expect.objectContaining({ value: 'auto' })]) - }) - expect(permission({ ...changing, permissionMode: 'auto' })?.kind).toMatchObject({ - currentValue: 'auto', - choices: expect.arrayContaining([expect.objectContaining({ value: 'auto' })]) - }) - }) - - // A new chat names no model: the host's catalog row for the CLI default decides, as the host's does. - it('offers Claude auto to a new chat whose CLI default the host lists as running it', () => { - const seed = structuredAgentSessionSeedCatalog('claude') - const offered = (rows: { id: string; isDefault: boolean; supportsAutoMode?: boolean }[]) => { - const state = applyStructuredAgentSessionModelCatalog( - createStructuredAgentSessionOptionState('claude', seed), - seed, - { - origin: 'probe', - fetchedAt: 1, - models: rows.map((row) => ({ ...row, label: row.id, efforts: [] })) - }, - { newLaunch: true } - ) - const descriptor = permission( - structuredAgentSessionOptionView(state, { permissionMode: 'ask' }, {}) - ) - return descriptor?.kind.type === 'select' && descriptor.kind.choices.map((c) => c.value) - } + const state = createStructuredAgentSessionOptionState('claude') + const descriptor = permission( + structuredAgentSessionOptionView(state, { permissionMode: 'ask' }, {}) + ) expect( - offered([ - { id: 'opus[1m]', isDefault: true, supportsAutoMode: true }, - { id: 'haiku', isDefault: false } - ]) + descriptor?.kind.type === 'select' && descriptor.kind.choices.map((c) => c.value) ).toContain('auto') - expect( - offered([ - { id: 'opus[1m]', isDefault: false, supportsAutoMode: true }, - { id: 'haiku', isDefault: true } - ]) - ).not.toContain('auto') }) it("shows a held pick over the host's value, and the host's over the seed", () => { diff --git a/src/shared/structured-agent-session-permission-option.ts b/src/shared/structured-agent-session-permission-option.ts index 9f3e44a251f..ed10f767380 100644 --- a/src/shared/structured-agent-session-permission-option.ts +++ b/src/shared/structured-agent-session-permission-option.ts @@ -3,11 +3,8 @@ import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID, agentChatPermissionModes, - chatModelSupportsAutoMode, - offeredChatPermissionModes, type AgentChatPermissionMode } from './agent-chat-permission-mode' -import type { AgentSessionOptionCatalog } from './agent-session-option-catalog' import type { StructuredAgentSessionOptionRecord } from './structured-agent-session-option-state' import type { SessionOptionDescriptor } from './native-chat-session-options' @@ -54,22 +51,9 @@ function agentDescriptions(agent: string): Partial mode === current) const label = reported ? MODE_COPY[reported].label @@ -104,7 +82,7 @@ export function structuredAgentSessionPermissionDescriptor(state: { kind: { type: 'select', currentValue: current, - choices: offered.map((value) => ({ + choices: support.modes.map((value) => ({ value, label: MODE_COPY[value].label, description: agentDescriptions(state.record.agent)[value] ?? MODE_COPY[value].description diff --git a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts index 2e75fd2ca43..e6bd8fabf1d 100644 --- a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts +++ b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts @@ -204,17 +204,30 @@ const optionReads = () => mocks.rpc.mock.calls.filter(([, method]) => method === 'agentSession.options').length describe('Approve for me on a new Claude chat', () => { - it('is offered once the background listing lands after the chat opened', async () => { + it('is offered before and after the background listing lands', async () => { const lands = new Promise((resolve) => (release = resolve)) await hostCatalog(lands) const { result, unmount } = openNewChat() // The chat opened before its account was ever listed: the first answers come without a list. await waitFor(() => expect(optionReads()).toBe(1)) await waitFor(() => expect(offered(result.current.optionSnapshot)).toContain('ask')) - expect(offered(result.current.optionSnapshot)).not.toContain('auto') + expect(offered(result.current.optionSnapshot)).toEqual([ + 'ask', + 'accept-edits', + 'auto', + 'full-access' + ]) release() - await waitFor(() => expect(offered(result.current.optionSnapshot)).toContain('auto')) + await mocks.rpc(undefined, 'agentSession.modelCatalog', { waitForListing: true }) + await waitFor(() => + expect(offered(result.current.optionSnapshot)).toEqual([ + 'ask', + 'accept-edits', + 'auto', + 'full-access' + ]) + ) unmount() })