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 219824987115..eb8e0d41ff84 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-open-turn.ts b/src/main/claude/claude-open-turn.ts index 86365c68fedb..d4b3c74a78a8 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -207,6 +207,6 @@ export class ClaudeOpenTurn { options: { ...item.options, lifecycle: true } }) // Separate keys preserve a queued start publication when the end arrives. - this.deps.sink.publish({ coalescingKey: item.publishCoalescingKey }) + this.deps.sink.publish({ criticalRoot: true, coalescingKey: item.publishCoalescingKey }) } } diff --git a/src/main/claude/claude-structured-acquisition-launch.ts b/src/main/claude/claude-structured-acquisition-launch.ts index ebf3e3b25b5a..0f1c639aa009 100644 --- a/src/main/claude/claude-structured-acquisition-launch.ts +++ b/src/main/claude/claude-structured-acquisition-launch.ts @@ -29,16 +29,21 @@ export type ClaudeAcquisitionLaunch = ClaudeStructuredLaunch & { savedOptions: Omit } -export async function resolveClaudeAcquisitionLaunch(args: { +export type ClaudeAcquisitionContext = { input: StructuredAgentSessionAcquireInput deps: ClaudeStructuredSessionAdapterDeps sessions: Map acquisitions: ClaudeAcquisitionRegistry exits: Map callbacks: ClaudeAcquireCallbacks - previous: ClaudeAcquisitionAttempt | undefined - attempt: ClaudeAcquisitionAttempt -}): Promise { +} + +export async function resolveClaudeAcquisitionLaunch( + args: ClaudeAcquisitionContext & { + previous: ClaudeAcquisitionAttempt | undefined + attempt: ClaudeAcquisitionAttempt + } +): Promise { const { input, deps, sessions, acquisitions, exits, callbacks, previous, attempt } = args const sessionId = input.identity.sessionId return withAgentSessionCreatePhase('auth_settle', input.recordPhase, async () => { diff --git a/src/main/claude/claude-structured-dispatch-admission.test.ts b/src/main/claude/claude-structured-dispatch-admission.test.ts index 234b1d0d3312..dce91c4b1f04 100644 --- a/src/main/claude/claude-structured-dispatch-admission.test.ts +++ b/src/main/claude/claude-structured-dispatch-admission.test.ts @@ -29,7 +29,8 @@ describe('Claude structured dispatch admission', () => { await dispatchClaudeTurn(session, { clientMessageId: 'client-b', body: userMessage([{ type: 'text', text: 'b' }]), - requestedAt: 200 + requestedAt: 150, + admitPreparedInput: async (offer) => offer(200) }) await dispatchClaudeTurn(session, { clientMessageId: 'client-c', @@ -122,7 +123,7 @@ describe('Claude structured dispatch admission', () => { it('bounds pending replay identities instead of retaining an unbounded queue', async () => { const session = sessionFor() - for (let index = 0; index < 64; index += 1) { + for (let index = 0; index < 63; index += 1) { await expect( dispatchClaudeTurn(session, { clientMessageId: `client-${index}`, @@ -131,6 +132,22 @@ describe('Claude structured dispatch admission', () => { ).resolves.toEqual({ state: 'admitted' }) } + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'prepared-at-capacity', + body: userMessage([{ type: 'text', text: 'prepared before the last slot filled' }]), + admitPreparedInput: async (offer) => { + await dispatchClaudeTurn(session, { + clientMessageId: 'last-slot', + body: userMessage([{ type: 'text', text: 'fills the last slot' }]) + }) + await offer(200) + } + }) + ).resolves.toMatchObject({ state: 'rejected', rejection: { kind: 'writeFailed' } }) + expect(session.dispatchWaiters).toHaveLength(64) + expect(session.connection.send).toHaveBeenCalledTimes(64) + await expect( dispatchClaudeTurn(session, { clientMessageId: 'client-over-capacity', diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 81dca812dd5b..232ceaa32333 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -7,7 +7,10 @@ import { waitForReplay } from './claude-structured-dispatch-waiters' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' -import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state' import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import { @@ -118,6 +121,9 @@ export async function dispatchClaudeTurn( requestedAt?: number sentUuid?: string signal?: AbortSignal + admitPreparedInput?: Parameters< + StructuredAgentSessionAdapter['dispatch'] + >[0]['admitPreparedInput'] }, beforeDispatch?: () => Promise ): Promise { @@ -133,7 +139,7 @@ export async function dispatchClaudeTurn( // Classify the mapped trailing prompt so command detection matches what Claude receives. const acceptsResult = claudeDispatchInvokesSlashCommand(content) const sentUuid = input.sentUuid ?? randomUUID() - const arm = () => { + const arm = (requestedAt = input.requestedAt) => { ++session.dispatchSequence // Invalidate context reports requested before this send. session.translator?.markContextActivity() @@ -143,7 +149,7 @@ export async function dispatchClaudeTurn( sentUuid, claudeDispatchContentKey(content), input.clientMessageId ?? null, - input.requestedAt ?? null + requestedAt ?? null ) } const message = { @@ -153,7 +159,7 @@ export async function dispatchClaudeTurn( parent_tool_use_id: null, session_id: session.providerSessionId } - const pending = { replay: beforeDispatch ? undefined : arm() } + const pending = { replay: beforeDispatch || input.admitPreparedInput ? undefined : arm() } const authorize = beforeDispatch ? async () => { await beforeDispatch() @@ -164,9 +170,24 @@ export async function dispatchClaudeTurn( } : undefined try { - await (authorize - ? session.connection.send(message, authorize) - : session.connection.send(message)) + if (input.admitPreparedInput) { + await beforeDispatch?.() + await input.admitPreparedInput( + (requestedAt) => + new Promise((resolve) => { + if (session.dispatchWaiters.length >= MAX_ACTIVE_DISPATCH_WAITERS) { + throw claudeUnwrittenUserMessageError(new Error(DISPATCH_REJECTED_QUEUE_FULL)) + } + pending.replay = arm(requestedAt) + resolve(session.connection.send(message)) + }), + () => session.translator?.currentTurnId ?? null + ) + } else { + await (authorize + ? session.connection.send(message, authorize) + : session.connection.send(message)) + } } catch (error) { const replay = pending.replay if (!replay) { diff --git a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts index a1d345f4df3b..b1659067703d 100644 --- a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts +++ b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts @@ -161,7 +161,12 @@ describe('Claude structured turn timing', () => { state: 'running', startedAt: 1_000, userItemId: USER_1_KEY, - options: { observedAt: 1_000, lifecycle: true, turnScope: { kind: 'thread' } } + options: { + criticalRoot: true, + observedAt: 1_000, + lifecycle: true, + turnScope: { kind: 'thread' } + } } ]) }) @@ -191,7 +196,7 @@ describe('Claude structured turn timing', () => { startedAt: 1_000, completedAt: 4_500, userItemId: USER_1_KEY, - options: { lifecycle: true, turnScope: { kind: 'thread' } } + options: { criticalRoot: true, lifecycle: true, turnScope: { kind: 'thread' } } }) expect(state.items.at(-1)?.identity).toEqual(state.items[0]?.identity) }) diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 711468c2ba41..6901a5c1e0e5 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -287,12 +287,21 @@ describe('claude structured launch resolution', () => { ['ask', 'default'], ['accept-edits', 'acceptEdits'], ['auto', 'auto'] - ] as const)('starts a %s chat in permission mode %s', async (permissionMode, expected) => { - const launch = await resolverFor(record())({ identity: IDENTITY, permissionMode }) - - expect(launch.options.permissionMode).toBe(expected) - expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null }) - }) + ] as const)( + 'starts and resumes a %s chat in permission mode %s', + async (permissionMode, expected) => { + const launches = [ + await resolverFor(record())({ identity: IDENTITY, permissionMode }), + await resolverFor(RESUMABLE)({ identity: identityAt('leaf-current'), permissionMode }) + ] + + expect(launches[1]?.options.resume).toBe('provider-current') + for (const launch of launches) { + expect(launch.options.permissionMode).toBe(expected) + expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null }) + } + } + ) it('ignores the bypass flag the agent Arguments carry', async () => { const launch = await resolverFor( 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 dcab9e734391..49b16c3ea364 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 e7482f8deac8..76b4b5103761 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -7,12 +7,32 @@ 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 +export function claudeModelTransitionRestart( + sessions: ReadonlyMap +): NonNullable { + return (input) => { + const session = sessions.get(input.sessionId) + const model = input.executionOptions?.model + const opened = session?.firstRootInit?.permissionMode + return Boolean( + session?.fence === input.fence && + model !== undefined && + session.launchPermissionMode !== undefined && + opened !== undefined && + opened !== null && + opened !== session.launchPermissionMode && + session.appliedInputOptions.get('model') !== model + ) + } +} + 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 4255429b458c..82057591ef3a 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.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 54c3c0f062be..1ddeeb338125 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -28,6 +28,89 @@ import { claudeProviderHandle } from '../../shared/agent-session-provider-handle import { providerRequestTimeoutOf } from '../provider-process/provider-request-timeout' describe('ClaudeStructuredSessionAdapter.acquire', () => { + it('keeps the first root init and successful literal selector separate from hooks and reports', async () => { + const claude = fakeClaude() + const open = claude.openConnection + claude.openConnection = async (launch, handlers) => { + const connection = await open(launch, handlers) + const frame = { type: 'system', session_id: PROVIDER_SESSION_ID } + handlers?.onMessage?.({ + ...frame, + subtype: 'hook_started', + hook_name: 'SessionStart:startup' + }) + handlers?.onMessage?.({ + ...frame, + subtype: 'init', + parent_tool_use_id: 'child', + permissionMode: 'auto' + }) + handlers?.onMessage?.({ + ...frame, + subtype: 'init', + permissionMode: 'default', + model: 'claude-haiku-4-5' + }) + return connection + } + const adapter = adapterFor(claude, { + options: { permissionMode: 'auto', extraArgs: { model: 'haiku' } } + }) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + const requiresRestart = (model?: string) => + adapter.requiresModelTransitionRestart({ + sessionId: 'session-1', + fence: 7, + executionOptions: model === undefined ? {} : { model } + }) + expect(requiresRestart('haiku')).toBe(false) + expect(requiresRestart()).toBe(false) + expect(requiresRestart('opus')).toBe(true) + for (const subtype of ['status', 'init']) { + claude.connections[0].handlers.onMessage?.({ + type: 'system', + subtype, + session_id: PROVIDER_SESSION_ID, + permissionMode: 'auto', + model: 'opus' + }) + } + expect(requiresRestart('opus')).toBe(true) + await adapter.setOption({ sessionId: 'session-1', fence: 7, key: 'model', value: 'opus' }) + expect(requiresRestart('opus')).toBe(false) + expect(requiresRestart('haiku')).toBe(true) + claude.routes.set_model = () => { + throw new Error('control reply lost') + } + await expect( + adapter.setOption({ sessionId: 'session-1', fence: 7, key: 'model', value: 'haiku' }) + ).rejects.toThrow('control reply lost') + expect(requiresRestart('opus')).toBe(true) + await adapter.closeSession('session-1') + }) + + it('does not let a later init fill the first root init missing permission', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude, { options: { permissionMode: 'auto' } }) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + options: { model: 'haiku' } + }) + const frame = { type: 'system', subtype: 'init', session_id: PROVIDER_SESSION_ID } + claude.connections[0].handlers.onMessage?.(frame) + claude.connections[0].handlers.onMessage?.({ ...frame, permissionMode: 'default' }) + expect( + adapter.requiresModelTransitionRestart({ + sessionId: 'session-1', + fence: 7, + executionOptions: { model: 'opus' } + }) + ).toBe(false) + await adapter.closeSession('session-1') + }) + it('ends a stalled initialize at its 60-second deadline with the timed-out step', async () => { vi.useFakeTimers() try { diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index ae9592b29ad8..d1e0e73fb9bd 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 { @@ -195,6 +195,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda const held = session && claudeHeldSends(session) return held ?? (this.settledExitErrors.has(sessionId) ? new Set() : undefined) } + requiresModelTransitionRestart = claudeOptions.claudeModelTransitionRestart(this.sessions) holdsLiveProviderProcess = (sessionId: string, acquisitionGeneration: string): boolean => { const session = this.sessions.get(sessionId) return ( @@ -206,7 +207,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 201d32a001c7..752ef243ae0f 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 + /** Construction intent and the first admitted root init belong to this process only. */ + launchPermissionMode?: 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 32ad57c25f97..ce1e3ee51055 100644 --- a/src/main/claude/claude-structured-spawn-options.ts +++ b/src/main/claude/claude-structured-spawn-options.ts @@ -80,11 +80,26 @@ export function claudeStructuredSpawnOptions(input: { export function adoptClaudeStructuredSpawnOptions( session: Pick< ClaudeSession, - 'translator' | 'launchedModel' | 'fastModeAtStart' | 'appliedInputOptions' + | 'translator' + | 'launchedModel' + | 'launchPermissionMode' + | 'fastModeAtStart' + | 'appliedInputOptions' >, - spawn: Pick + spawn: ClaudeStructuredSpawnOptions ): void { session.launchedModel = spawn.options.get('model') ?? null + const configuredModel = spawn.sdkOptions.extraArgs?.model + const modelInput = typeof configuredModel === 'string' ? configuredModel : spawn.sdkOptions.model + session.launchPermissionMode = Object.hasOwn( + spawn.sdkOptions.extraArgs ?? {}, + 'dangerously-skip-permissions' + ) + ? 'bypassPermissions' + : spawn.sdkOptions.permissionMode + if (modelInput !== undefined) { + session.appliedInputOptions.set('model', modelInput) + } session.fastModeAtStart = spawn.fastModeAtStart const fastMode = spawn.options.get('fastMode') if (fastMode !== undefined && !spawn.fastModeAtStart) { diff --git a/src/main/claude/claude-terminal-failure.ts b/src/main/claude/claude-terminal-failure.ts index af34ab1d188a..6ef8f0f03a4c 100644 --- a/src/main/claude/claude-terminal-failure.ts +++ b/src/main/claude/claude-terminal-failure.ts @@ -27,11 +27,15 @@ export function writeClaudeTerminalFailure( ): void { const turnItemId = agentJournalItemKey(identity) const failureIdentity = claudeTerminalFailureIdentity(turnItemId) - const options = { turnScope: { kind: 'turn' as const, turnItemId }, lifecycle: true } + const options = { + criticalRoot: true as const, + turnScope: { kind: 'turn' as const, turnItemId }, + lifecycle: true + } const revise = sink.tryReviseResolvedItemAndPublish if (!revise) { sink.appendItem(failureIdentity, body, options) - sink.publish() + sink.publish({ criticalRoot: true }) return } const reservedBytes = Buffer.byteLength(JSON.stringify({ failureIdentity, body }), 'utf8') + 8192 @@ -117,7 +121,7 @@ export function refineClaudeQuotaTurnEnd( reservedBytes ) }, - { turnScope: { kind: 'thread' }, lifecycle: true } + { criticalRoot: true, lifecycle: true, turnScope: { kind: 'thread' } } ) return true } diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index e930a8a1026a..d30b690ad6a5 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -97,7 +97,7 @@ export function claudeTurnLifecycleItem( : { turnId, state: 'running', startedAt, ...requested, userItemId } ), // Use the turn start timestamp so append latency does not inflate timing. - options: end ? {} : { observedAt: startedAt }, + options: { criticalRoot: true, ...(end ? {} : { observedAt: startedAt }) }, publishCoalescingKey: end ? 'publish' : `turn-start:${sessionId}:${turnId}` } } diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 0f113b7cdec3..241d3690aa85 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -332,9 +332,10 @@ export class AgentSessionJournal { resolveDispatch( input: ResolveDispatchInput, - cards?: () => JournalQueuePlan + cards?: () => JournalQueuePlan, + receipt?: JournalOperationReceipt ): Promise { - return this.submissionWriter.resolveDispatch(input, cards) + return this.submissionWriter.resolveDispatch(input, cards, receipt) } acceptLate: JournalSubmissionWriter['acceptLate'] = (i) => this.submissionWriter.acceptLate(i) diff --git a/src/main/native-chat/agent-session-journal/journal-submission-writer.ts b/src/main/native-chat/agent-session-journal/journal-submission-writer.ts index 11807c799674..8c6ddc32fcb5 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-writer.ts @@ -73,11 +73,12 @@ export class JournalSubmissionWriter { resolveDispatch( input: ResolveDispatchInput, /** Plan card changes on the write lane. */ - cards?: () => JournalQueuePlan + cards?: () => JournalQueuePlan, + receipt?: JournalOperationReceipt ): Promise { const dispatch = journalDispatchRowBuilder(this.deps.state, input) if (!cards) { - return this.deps.rowWriter.append(dispatch) + return this.deps.rowWriter.append(dispatch, receipt) } let planned: JournalQueuePlan | undefined return this.deps.rowWriter @@ -87,8 +88,14 @@ export class JournalSubmissionWriter { return [dispatch, ...planned.rows] }, { - write: (db) => planned?.receipt.write(db), - committed: () => planned?.receipt.committed() + write: (db, row) => { + planned?.receipt.write(db) + receipt?.write(db, row) + }, + committed: () => { + planned?.receipt.committed() + receipt?.committed() + } }, (rows) => rows[0] ) diff --git a/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts b/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts index 83eeb1421faf..58954650fc00 100644 --- a/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts +++ b/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts @@ -196,7 +196,7 @@ export function writeAgentJournalTurnRow( if (write.lifecycle && 'identity' in target) { sink.appendItem(target.identity, write.lifecycle, options) if (publish) { - sink.publish() + sink.publish({ criticalRoot: delivery.criticalRoot }) } } return 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 6837149bc786..ea82f1263bd8 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 @@ -107,6 +107,10 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi holdsDispatch = (sessionId: string): boolean => this.liveOwnerOrNull(sessionId)?.holdsDispatch?.(sessionId) ?? false + 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 ca8b07ff6500..5e7df9405d88 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 @@ -1,3 +1,4 @@ +import type { StructuredAgentSessionPreparedAdmission } from './structured-agent-session-prepared-admission' import type { AiVaultConversationCopy } from '../../../shared/ai-vault-resume-preparation' import type { AgentSessionRewindFork, @@ -183,6 +184,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** Preserve submission time as the opened turn’s requestedAt. */ requestedAt?: number beforeDispatch?: () => Promise + /** Prepared local input; the host commits write-ahead custody before synchronously offering it. */ + admitPreparedInput?: StructuredAgentSessionPreparedAdmission /** Mark preparation before choices mutate the provider. */ onInputPreparation?: () => void /** Checkpoint between choice application and execution-evidence capture. */ @@ -228,6 +231,12 @@ 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 + /** Host-local construction facts for the exact next input; absent providers keep their child. */ + 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 acbb040159a8..4e040d077506 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, beforeEach, describe, expect, it, onTestFinished, 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' @@ -22,6 +22,8 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { retireOutgrownStructuredAgentSessionChild } from './structured-agent-session-outgrown-child' +import type * as OutgrownChild from './structured-agent-session-outgrown-child' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -35,6 +37,31 @@ import { claudeAndCodexDeclared } from './structured-agent-session-adapter-route import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import { DISPATCH_DOUBT_PROVIDER_IDLE } from '../agent-session-journal/journal-dispatch-doubt-reasons' import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue' +import { claudeStructuredPermissionOptions } from '../../claude/claude-structured-launch-resolution' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { runningStructuredAgentSessionTurnItemBySequence } from '../../../shared/structured-agent-session-live-turn' +import type * as LiveTurn from '../../../shared/structured-agent-session-live-turn' + +vi.mock('../../../shared/structured-agent-session-live-turn', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + runningStructuredAgentSessionTurnItemBySequence: vi.fn( + actual.runningStructuredAgentSessionTurnItemBySequence + ) + } +}) + +vi.mock('./structured-agent-session-outgrown-child', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + retireOutgrownStructuredAgentSessionChild: vi.fn( + actual.retireOutgrownStructuredAgentSessionChild + ) + } +}) const CALLER = { callerKey: 'client-1' } const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] @@ -44,20 +71,23 @@ let host: StructuredAgentSessionHost let adapter: ClaudeStructuredSessionAdapter let store: AgentSessionRecordStore let claude: ReturnType +let claudeFrames: { replayUuid: null; initProof?: 'none' } beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-claude-opening-send-')) resetHostTestOperationIds() const log = recordingStructuredAgentSessionLogger() - claude = fakeClaude({ - replayUuid: null, - routes: { interrupt: () => ({ still_queued: [], cancelled: [] }) } - }) + claudeFrames = { replayUuid: null } + claude = fakeClaude( + Object.assign(claudeFrames, { + routes: { interrupt: () => ({ still_queued: [], cancelled: [] }) } + }) + ) const lifecycle: Promise[] = [] adapter = new ClaudeStructuredSessionAdapter({ - resolveLaunch: async () => ({ + resolveLaunch: async ({ permissionMode }) => ({ pathToClaudeCodeExecutable: 'claude', - options: {}, + options: claudeStructuredPermissionOptions(permissionMode ?? 'ask'), cwd: root, claudeConfigDir: join(root, 'claude-home'), providerSessionId: PROVIDER_SESSION_ID, @@ -90,6 +120,7 @@ beforeEach(async () => { const params = hostTestAttachParams(null, { provider: 'claude', agent: 'claude', + options: { model: 'haiku', permissionMode: 'auto' }, accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) @@ -107,7 +138,7 @@ afterEach(async () => { const eventually = (assertion: () => T | Promise) => vi.waitFor(assertion, { timeout: 10_000 }) -async function send(text: string): Promise { +async function send(text: string, options?: Record): Promise { const body = hostTestMessage(text) const sent = await host.send(CALLER, { envelope: { @@ -117,10 +148,11 @@ async function send(text: string): Promise { payloadFingerprint: computeAgentSessionPayloadFingerprint({ method: 'agentSession.send', sessionId: SESSION, - fields: { body } + fields: { body, options } }) }, - body + body, + options }) if (!sent.ok) { throw new Error(JSON.stringify(sent.refusal)) @@ -168,6 +200,121 @@ it('reads a send as accepted across its echo, never in doubt, so a worker start expect(agentSessionSendSubmission((await answer)?.value)?.dispatchState).toBe('accepted') }) +it('protects an admitted unechoed input from a model transition, then reopens for a fresh choice', async () => { + claudeFrames.initProof = 'none' + const connection = claude.connections[0]! + expect(connection.launch.options).toMatchObject({ model: 'haiku', permissionMode: 'auto' }) + frame(connection, { + type: 'system', + subtype: 'init', + uuid: 'haiku-root', + model: 'claude-haiku-4-5', + permissionMode: 'default' + }) + const first = await send('ADMITTED prompt') + await eventually(() => expect(written(connection, 'ADMITTED prompt')).toBeDefined()) + await eventually(() => + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) + ) + const admitted = (await snapshot()).submissions.find((entry) => entry.clientMessageId === first)! + expect(admitted).toMatchObject({ dispatchState: 'pending', handedOverAt: expect.any(Number) }) + expect(host.currentWork(SESSION)?.owesSend(admitted)).toBe(true) + expect(host.currentWork(SESSION)?.activeTurnId()).toBeNull() + expect(adapter.holdsDispatch(SESSION)).toBe(false) + + const blocked = await send('BLOCKED transition', { model: 'opus' }) + await eventually(() => + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) + ) + expect(connection.closeCount).toBe(0) + expect(connection.calls.some(({ subtype }) => subtype === 'set_model')).toBe(false) + const submissions = (await snapshot()).submissions + expect(submissions.find((entry) => entry.clientMessageId === blocked)).toMatchObject({ + dispatchState: 'rejected', + rejection: { + kind: 'restartFailed', + refusal: { code: 'agent_session_operation_invalid', details: { reason: 'messagesUnsettled' } } + } + }) + expect(submissions.find((entry) => entry.clientMessageId === first)?.dispatchState).toBe( + 'pending' + ) + expect(host.currentWork(SESSION)?.owesSend(admitted)).toBe(true) + expect(written(connection, 'BLOCKED transition')).toBeUndefined() + + async function reply(on: FakeConnection, prompt: string, text: string, id: string) { + const input = written(on, prompt)! + frame(on, { ...input, isReplay: true, parent_tool_use_id: null }) + frame(on, { + type: 'assistant', + uuid: `assistant-${id}`, + parent_tool_use_id: null, + message: { id: `msg-${id}`, role: 'assistant', content: [{ type: 'text', text }] }, + user_message_uuid: input.uuid, + user_message_uuids: [input.uuid] + }) + frame(on, { + type: 'result', + subtype: 'success', + uuid: `result-${id}`, + is_error: false, + terminal_reason: 'completed', + result: text, + user_message_uuid: input.uuid, + user_message_uuids: [input.uuid] + }) + frame(on, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + await eventually(async () => { + expect( + (await snapshot()).submissions.find((entry) => entry.clientMessageId === id) + ).toMatchObject({ dispatchState: 'accepted' }) + expect(host.currentWork(SESSION)?.activeTurnId()).toBeNull() + }) + } + await reply(connection, 'ADMITTED prompt', 'Original result retained.', first) + expect((await drawn()).map(({ text }) => text)).toContain('Original result retained.') + expect( + (await snapshot()).submissions.find((entry) => entry.clientMessageId === blocked)?.dispatchState + ).toBe('rejected') + + const fresh = await send('FRESH transition', { model: 'opus' }) + await eventually(() => { + expect(claude.connections).toHaveLength(2) + expect(written(claude.connections[1]!, 'FRESH transition')).toBeDefined() + }) + const resumed = claude.connections[1]! + expect(connection.closeCount).toBe(1) + expect(resumed.launch.options).toMatchObject({ model: 'opus', permissionMode: 'auto' }) + expect(store.getRecord(SESSION)?.providerHandleChain).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + handle: expect.objectContaining({ nativeId: PROVIDER_SESSION_ID }) + }) + ]) + ) + frame(resumed, { + type: 'system', + subtype: 'init', + uuid: 'opus-root', + model: 'claude-opus-4-6', + permissionMode: 'default' + }) + await reply(resumed, 'FRESH transition', 'First new-model reply.', fresh) + const repeated = await send('REPEATED choice', { model: 'opus' }) + await eventually(() => expect(written(resumed, 'REPEATED choice')).toBeDefined()) + await reply(resumed, 'REPEATED choice', 'Second new-model reply.', repeated) + expect(claude.connections).toHaveLength(2) + expect(resumed.calls.some(({ subtype }) => subtype === 'set_model')).toBe(false) + expect(claude.connections.some((child) => written(child, 'BLOCKED transition'))).toBe(false) + expect((await drawn()).map(({ text }) => text)).toEqual( + expect.arrayContaining([ + 'Original result retained.', + 'First new-model reply.', + 'Second new-model reply.' + ]) + ) +}) + it('holds a follow-up until the echo opens the turn, then folds it in, drawn inside that turn', async () => { const connection = claude.connections[0]! const first = await send('FIRST prompt') @@ -445,3 +592,256 @@ 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()) }) + +/** Rows an older host wrote as it sent them, at the live child's fence: never answered, and the + * CLI holding none of them, unless each was accepted or refused as not signed in. */ +async function olderHostSends(count: number, answer?: 'accepted' | 'notSignedIn'): Promise { + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + const fence = store.getRecord(SESSION)!.lease.runtimeFence + for (let index = 0; index < count; index += 1) { + const clientMessageId = `older-${index}` + const body = hostTestMessage(clientMessageId) + await journal.appendSubmission({ + clientMessageId, + payloadFingerprint: clientMessageId, + body, + fence + }) + if (answer === 'accepted') { + await journal.resolveDispatch({ + clientMessageId, + fence, + state: 'accepted', + providerIdentity: null + }) + } else if (answer === 'notSignedIn') { + await journal.resolveDispatch({ + clientMessageId, + fence, + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact(answer), { surface: 'rejection' }) + }) + } + } +} + +/** What the delivery loop's retirement checks read: the journal's history, wake debt (its only + * `newestTurn` reader there), and what the CLI holds, built by the adapter itself on each read. */ +function countRetirementReads() { + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + const runningScans: { phase: string; visits: number }[] = [] + const counts = { + checks: 0, + linkageVisits: 0, + submissionsCopied: 0, + newestTurn: 0, + heldSets: 0, + heldVisits: 0, + rejections: 0, + runningScans, + afterCheck: async (_check: number): Promise => undefined + } + let inside = false + let phase: string | null = null + const running = vi.mocked(runningStructuredAgentSessionTurnItemBySequence) + const originalRunning = running.getMockImplementation()! + onTestFinished(() => void running.mockImplementation(originalRunning)) + running.mockImplementation((items) => { + const scan = phase ? { phase, visits: 0 } : null + if (scan) { + counts.runningScans.push(scan) + } + return originalRunning({ + *[Symbol.iterator]() { + for (const item of items) { + if (scan) { + scan.visits += 1 + } + yield item + } + } + }) + }) + const retire = vi.mocked(retireOutgrownStructuredAgentSessionChild) + const actual = retire.getMockImplementation()! + onTestFinished(() => void retire.mockImplementation(actual)) + retire.mockImplementation(async (...args) => { + counts.checks += 1 + inside = true + phase = `owner-${counts.checks}` + try { + return await actual(...args) + } finally { + inside = false + phase = counts.checks === 2 ? 'turnScope' : null + await counts.afterCheck(counts.checks) + } + }) + const { visitItemsWithLinkage, submissions, newestTurn } = journal + journal.visitItemsWithLinkage = (visit) => + visitItemsWithLinkage((...item) => { + counts.linkageVisits += inside ? 1 : 0 + visit(...item) + }) + journal.submissions = () => { + const all = submissions() + counts.submissionsCopied += inside ? all.length : 0 + return all + } + journal.newestTurn = () => { + counts.newestTurn += inside ? 1 : 0 + return newestTurn() + } + const resolveDispatch = journal.resolveDispatch.bind(journal) + journal.resolveDispatch = (input, cards, receipt) => { + counts.rejections += input.state === 'rejected' ? 1 : 0 + phase = null + return resolveDispatch( + input, + cards, + receipt && { + ...receipt, + write: (...args) => { + phase = 'receipt.write' + try { + return receipt.write(...args) + } finally { + phase = null + } + } + } + ) + } + const { heldSends } = adapter + adapter.heldSends = (sessionId) => { + const held = heldSends(sessionId) + counts.heldSets += inside ? 1 : 0 + counts.heldVisits += inside ? (held?.size ?? 0) : 0 + return held + } + return counts +} + +/** A person's pick of another permission mode, which writes the chat's record only. */ +async function pickPermission(value: string): Promise { + const fields = { key: 'permissionMode', value } + const picked = await host.setOption(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.setOption', + sessionId: SESSION, + fields + }) + }, + ...fields + }) + expect(picked).toMatchObject({ ok: true }) +} + +// Each send is checked twice, before and after the wait for its child: the second check is what +// sees a change made during that wait, so it must stay, and stay one pass over history. +it('re-derives the retirement check after the wait, so a sign-out reported in it is honored', async () => { + const connection = claude.connections[0]! + const counts = countRetirementReads() + counts.afterCheck = async (check) => (check === 1 ? olderHostSends(1, 'notSignedIn') : undefined) + await send('CHANGED prompt') + await eventually(() => expect(written(claude.connections[1]!, 'CHANGED prompt')).toBeDefined()) + expect(written(connection, 'CHANGED prompt')).toBeUndefined() + expect(connection.closeCount).toBe(1) +}) + +describe.each([20, 200])('with %i earlier sends', (count) => { + it('checks an ordinary send twice, each check one pass over history', async () => { + await olderHostSends(count, 'accepted') + const counts = countRetirementReads() + await send('ORDINARY prompt') + await eventually(() => expect(written(claude.connections[0]!, 'ORDINARY prompt')).toBeDefined()) + const { items, submissions } = await snapshot() + expect(submissions).toHaveLength(count + 1) + expect(counts).toMatchObject({ + checks: 2, + linkageVisits: 2 * items.length, + submissionsCopied: 2 * submissions.length, + newestTurn: 0, + heldSets: 0 + }) + }) + + it('keeps an agent mid-turn through a permission change, deriving no wake debt', async () => { + await olderHostSends(count, 'accepted') + const connection = claude.connections[0]! + await send('FIRST prompt') + await eventually(() => expect(written(connection, 'FIRST prompt')).toBeDefined()) + frame(connection, { + ...written(connection, 'FIRST prompt')!, + isReplay: true, + parent_tool_use_id: null + }) + await eventually(() => expect(host.currentWork(SESSION)?.activeTurnId()).not.toBeNull()) + await pickPermission('ask') + const counts = countRetirementReads() + const dispatch = vi.spyOn(adapter, 'dispatch') + await send('STEER prompt') + await eventually(() => expect(written(connection, 'STEER prompt')).toBeDefined()) + expect(counts).toMatchObject({ checks: 2, newestTurn: 0 }) + const { items } = await snapshot() + expect(counts.runningScans).toEqual( + ['owner-1', 'owner-2', 'turnScope', 'receipt.write'].map((phase) => ({ + phase, + visits: items.length + })) + ) + const addedScans = counts.runningScans.filter(({ phase }) => phase !== 'turnScope') + expect(addedScans.reduce((total, scan) => total + scan.visits, 0)).toBe(3 * items.length) + expect(dispatch).toHaveBeenCalledTimes(1) + expect( + connection.sent.filter((message) => JSON.stringify(message).includes('STEER prompt')) + ).toHaveLength(1) + expect(connection.closeCount).toBe(0) + }) + + // The 200-send case awaits journal writes and dispatches, so it has a larger test budget. + it('refuses each queued model change behind an admitted send, reading what the CLI holds once per check', async () => { + claudeFrames.initProof = 'none' + const connection = claude.connections[0]! + frame(connection, { + type: 'system', + subtype: 'init', + uuid: 'haiku-root', + model: 'claude-haiku-4-5', + permissionMode: 'default' + }) + await olderHostSends(count) + const admitted = await send('ADMITTED prompt') + await eventually(() => expect(written(connection, 'ADMITTED prompt')).toBeDefined()) + expect(adapter.holdsDispatch(SESSION)).toBe(false) + const counts = countRetirementReads() + const blocked: string[] = [] + for (let index = 0; index < count; index += 1) { + blocked.push(await send(`BLOCKED ${index}`, { model: 'opus' })) + } + await eventually(async () => { + const rejected = (await snapshot()).submissions.filter( + (entry) => entry.dispatchState === 'rejected' + ) + expect(rejected.map((entry) => entry.clientMessageId).sort()).toEqual([...blocked].sort()) + }) + // The admitted send is the one send the CLI holds, read once per check however many older + // unanswered rows come before it. + expect(counts).toMatchObject({ + checks: count, + rejections: count, + heldSets: count, + heldVisits: count + }) + expect( + (await snapshot()).submissions.find((entry) => entry.clientMessageId === admitted) + ).toMatchObject({ dispatchState: 'pending' }) + expect(connection.closeCount).toBe(0) + expect(connection.calls.some(({ subtype }) => subtype === 'set_model')).toBe(false) + expect(claude.connections).toHaveLength(1) + }, 60_000) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-pressure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-pressure.test.ts new file mode 100644 index 000000000000..dea71f48ba7d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-pressure.test.ts @@ -0,0 +1,268 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi, type MockInstance } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { acpChildWorkStatusSink } from '../../acp/acp-structured-child-work.test-support' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + claudeStartupSettled, + fakeClaude, + PROVIDER_SESSION_ID, + type FakeConnection +} from '../../claude/claude-structured-session-test-support' +import { claudeStructuredPermissionOptions } from '../../claude/claude-structured-launch-resolution' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { OrchestrationDb } from '../../runtime/orchestration/db' +import { structuredWorkerOwesWork } from '../../runtime/structured-worker-custody' +import { setStructuredAgentSessionHost } from './structured-agent-session-registry' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import type * as EventSink from './structured-agent-session-event-sink' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +let revision: MockInstance< + NonNullable +> + +const CALLER = { callerKey: 'client-1' } +vi.mock('./structured-agent-session-event-sink', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createDeferredStructuredAgentSessionEventSink: ( + deps: Parameters[0] + ) => { + const sink = actual.createDeferredStructuredAgentSessionEventSink({ + ...deps, + watermarks: { + maxQueuedOperations: 4, + maxLifecycleQueuedOperations: 4, + pauseQueuedOperations: 2, + lowQueuedOperations: 1 + } + }) + revision = vi.spyOn(sink.sink, 'tryReviseResolvedItem') + return sink + } + } +}) + +let releaseBarriers: (() => void)[] = [] +let claudeFrames: { replayUuid: null; initProof?: 'none' } +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +let claude: ReturnType +let records: ReturnType +let orchestration: OrchestrationDb +/** The host's clock, which stamps every child's start and end. */ +let clock = NOW + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-subagent-row-')) + clock = NOW + releaseBarriers = [] + resetHostTestOperationIds() + const log = recordingStructuredAgentSessionLogger() + claudeFrames = { replayUuid: null } + claude = fakeClaude(claudeFrames) + orchestration = new OrchestrationDb(':memory:') + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async ({ permissionMode }) => ({ + pathToClaudeCodeExecutable: 'claude', + options: claudeStructuredPermissionOptions(permissionMode ?? 'ask'), + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + // A later child resumes the transcript the first one started. + resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, + continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + void host.handleAdapterEvent(mapped) + } + }, + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + persistHandle: async () => undefined, + logger: log.logger, + onChildWorkEvidence: (sessionId, evidence, producer) => + host.publishChildWorkEvidence(sessionId, evidence, producer), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => clock + }) + store = await openTestAgentSessionRecordStore(root) + records = acpChildWorkStatusSink() + host = new StructuredAgentSessionHost({ + agents: claudeAndCodexAgents(adapter), + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + logger: log.logger, + // The host's child records live in a real hook server's store, as in production. + statusSink: records.sink, + hasOpenDispatch: (record) => structuredWorkerOwesWork(orchestration, record), + now: () => clock + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + options: { model: 'haiku', permissionMode: 'auto' }, + 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 claudeStartupSettled(adapter, SESSION) +}) + +afterEach(async () => { + releaseBarriers.forEach((release) => release()) + setStructuredAgentSessionHost(null) + await adapter.closeAll() + await host.flushAllStreamedEvents() + orchestration.close() + await rm(root, { recursive: true, force: true }) +}) + +function eventually(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +function frame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +function envelope(method: 'agentSession.send' | 'agentSession.cancel', fields: Fields) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +type Fields = Parameters[0]['fields'] + +async function sendAndEcho(connection: FakeConnection, text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + expect(sent.ok).toBe(true) + await eventually(() => + expect(connection.sent.some((message) => JSON.stringify(message).includes(text))).toBe(true) + ) + frame(connection, { type: 'system', subtype: 'init', uuid: 'init-1', model: 'claude-sonnet-5' }) + frame(connection, { ...connection.sent.at(-1)! }) +} + +it('retains the root end under pressure so the same input progresses once without closing', async () => { + const written = (on: FakeConnection, text: string) => + on.sent.find((message) => JSON.stringify(message).includes(text)) + const snapshot = () => host.journalSnapshot(SESSION) + const connection = claude.connections[0]! + await sendAndEcho(connection, 'A before pressure') + await host.flushStreamedEvents(SESSION) + const collaborators = host.collaboratorsForTests() + const journal = collaborators.sessions.get(SESSION)!.journal + const sink = collaborators.runtimeState.currentEventSink(SESSION)! + const target = host.currentWork(SESSION)!.activeTurnId() + expect(target).not.toBeNull() + for (let index = 0; index < 4; index += 1) { + frame(connection, { + type: 'stream_event', + uuid: `unfinished-thinking-${index}`, + parent_tool_use_id: null, + event: { + type: 'content_block_start', + index, + content_block: { type: 'thinking', thinking: 'x' } + } + }) + await host.flushStreamedEvents(SESSION) + } + revision.mockClear() + frame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + expect(sink.state()).toMatchObject({ backpressured: true, failed: false }) + expect(sink.state().queuedOperations).toBeGreaterThan(4) + expect(revision.mock.results.map(({ value }) => value)).toContainEqual({ accepted: true }) + await host.flushStreamedEvents(SESSION) + expect(host.currentWork(SESSION)?.activeTurnId()).toBeNull() + expect( + (await snapshot()).items + .map(({ body }) => readAgentJournalTurn(body)) + .find((turn) => turn?.turnId === target) + ).toMatchObject({ state: 'completed' }) + + // A mistakenly requested close cannot unblock the input in this fixture. + const releaseClose = Promise.withResolvers() + releaseBarriers.push(() => releaseClose.resolve()) + const close = connection.close.bind(connection) + const closing = vi.spyOn(connection, 'close').mockImplementation(async () => { + await releaseClose.promise + return close() + }) + const body = hostTestMessage('B after pressured end') + const capture = { model: 'haiku', effort: 'high' } + const sendEnvelope = envelope('agentSession.send', { body, options: capture }) + const bId = sendEnvelope.clientOperationId + const pending = vi.spyOn(journal, 'resolveDispatch') + const dispatch = vi.spyOn(adapter, 'dispatch') + expect(await host.send(CALLER, { envelope: sendEnvelope, body, options: capture })).toMatchObject( + { ok: true } + ) + await eventually(() => expect(written(connection, 'B after pressured end')).toBeDefined()) + const bInput = dispatch.mock.calls.filter(([input]) => input.clientMessageId === bId) + expect(bInput).toHaveLength(1) + expect(bInput[0]![0]).toMatchObject({ body, executionOptions: capture }) + expect( + pending.mock.calls.filter( + ([input]) => input.clientMessageId === bId && input.state === 'pending' + ) + ).toHaveLength(1) + expect(closing).not.toHaveBeenCalled() + expect(claude.connections).toHaveLength(1) + frame(connection, { + ...written(connection, 'B after pressured end')!, + isReplay: true, + parent_tool_use_id: null + }) + frame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + await host.flushStreamedEvents(SESSION) + await eventually(() => + expect(collaborators.conversationDelivery.loop.isRunning(SESSION)).toBe(false) + ) + expect(dispatch.mock.calls.filter(([input]) => input.clientMessageId === bId)).toHaveLength(1) + expect( + connection.sent.filter((message) => JSON.stringify(message).includes('B after pressured end')) + ).toHaveLength(1) + expect( + (await snapshot()).submissions.find(({ clientMessageId }) => clientMessageId === bId) + ).toMatchObject({ + clientMessageId: bId, + options: capture, + dispatchState: 'accepted' + }) + expect(closing).not.toHaveBeenCalled() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts index fa891ab97f4e..91b4a907da62 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts @@ -6,10 +6,15 @@ import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { recordAgentSessionRanOptions } from '../../runtime/agent-session-record-options' import { isSubagentBlock } from '../../../shared/native-chat-types' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' import { acpChildWorkStatusSink } from '../../acp/acp-structured-child-work.test-support' -import { RESUMED_BY_MESSAGE } from '../../claude/claude-captured-task-frames.test-fixture' +import { + MOVED_TO_BACKGROUND, + RESUMED_BY_MESSAGE +} from '../../claude/claude-captured-task-frames.test-fixture' import { WAKES_AFTER_ITS_SHELL } from '../../claude/claude-captured-wake-frames.test-fixture' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { @@ -21,6 +26,13 @@ import { import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { claudeStructuredPermissionOptions } from '../../claude/claude-structured-launch-resolution' +import { OrchestrationDb } from '../../runtime/orchestration/db' +import { reconcileLifecycleMessage } from '../../runtime/orchestration/lifecycle-reconciliation' +import { OrchestrationStructuredMailboxPointerDelivery } from '../../runtime/orchestration/structured-mailbox-pointer-delivery' +import { createStructuredMailboxPointerHost } from '../../runtime/orchestration/structured-mailbox-pointer-host' +import { structuredWorkerOwesWork } from '../../runtime/structured-worker-custody' +import { setStructuredAgentSessionHost } from './structured-agent-session-registry' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' @@ -36,25 +48,31 @@ import { } from './structured-agent-session-host-test-data' const CALLER = { callerKey: 'client-1' } - +let releaseBarriers: (() => void)[] = [] +let claudeFrames: { replayUuid: null; initProof?: 'none' } let root: string let host: StructuredAgentSessionHost let adapter: ClaudeStructuredSessionAdapter let store: AgentSessionRecordStore let claude: ReturnType let records: ReturnType +let orchestration: OrchestrationDb +/** The host's clock, which stamps every child's start and end. */ let clock = NOW beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-claude-subagent-row-')) clock = NOW + releaseBarriers = [] resetHostTestOperationIds() const log = recordingStructuredAgentSessionLogger() - claude = fakeClaude({ replayUuid: null }) + claudeFrames = { replayUuid: null } + claude = fakeClaude(claudeFrames) + orchestration = new OrchestrationDb(':memory:') adapter = new ClaudeStructuredSessionAdapter({ - resolveLaunch: async () => ({ + resolveLaunch: async ({ permissionMode }) => ({ pathToClaudeCodeExecutable: 'claude', - options: {}, + options: claudeStructuredPermissionOptions(permissionMode ?? 'ask'), cwd: root, claudeConfigDir: join(root, 'claude-home'), providerSessionId: PROVIDER_SESSION_ID, @@ -88,11 +106,13 @@ beforeEach(async () => { mintSpawnToken: () => 'spawn-a', logger: log.logger, statusSink: records.sink, + hasOpenDispatch: (record) => structuredWorkerOwesWork(orchestration, record), now: () => clock }) const params = hostTestAttachParams(null, { provider: 'claude', agent: 'claude', + options: { model: 'haiku', permissionMode: 'auto' }, accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) @@ -101,8 +121,11 @@ beforeEach(async () => { }) afterEach(async () => { + releaseBarriers.forEach((release) => release()) + setStructuredAgentSessionHost(null) await adapter.closeAll() await host.flushAllStreamedEvents() + orchestration.close() await rm(root, { recursive: true, force: true }) }) @@ -147,6 +170,235 @@ async function savedRows() { ) } +it('keeps worker mail and native results on the live process, then reopens for the captured user choice', async () => { + const db = orchestration + const runId = 'run_legacy_local' + const handle = 'structworker_transition' + const task = db.createTask({ runId, spec: 'Read the result and report completion.' }) + const { dispatch } = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + db.prepareStartingWorkerAuthority({ + dispatchId: dispatch.id, + handle, + paneKey: null, + processIncarnation: `structured:${SESSION}`, + worktreeId: 'workspace-1', + setupState: 'not_applicable', + effects: [], + terminalOwnership: 'created', + hostScope: JSON.stringify({ kind: 'local', hostId: 'local' }) + }) + db.markWorkerDispatchReady(dispatch.id) + expect(structuredWorkerOwesWork(db, store.getRecord(SESSION)!)).toBe(true) + const first = claude.connections[0]! + expect(first.launch.options).toMatchObject({ model: 'haiku', permissionMode: 'auto' }) + frame(first, { + type: 'system', + subtype: 'init', + uuid: 'first-root', + model: 'claude-haiku-4-5', + permissionMode: 'default' + }) + await sendAndEcho(first, 'Run it in the background.') + for (const [index, captured] of RESUMED_BY_MESSAGE.filter(({ at }) => at <= 4_765).entries()) { + frame(first, { uuid: `launch-${index}`, ...captured.frame }) + } + await eventually(async () => + expect(await savedRows()).toEqual([expect.objectContaining({ state: 'working' })]) + ) + await store.replaceSessionOptions({ + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + options: { model: 'opus' }, + now: clock + }) + const body = hostTestMessage('Use my new model now.') + const refused = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + expect(refused.ok).toBe(true) + if (!refused.ok) { + throw new Error('send was not journaled') + } + await eventually(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.find( + (submission) => submission.clientMessageId === refused.value.clientMessageId + ) + ).toMatchObject({ + dispatchState: 'rejected', + reason: expect.stringContaining('Background tasks are still running.') + }) + ) + expect(first.closed).toBe(false) + expect(first.calls.some(({ subtype }) => subtype === 'set_model')).toBe(false) + await store.replaceSessionOptions({ + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + permissionMode: 'ask', + now: clock + }) + + setStructuredAgentSessionHost(host) + const mailboxHandle = `dispatch:${dispatch.id}` + const mail = db.insertMessage({ + runId, + from: 'coordinator', + to: mailboxHandle, + type: 'question', + subject: 'Result ready', + body: 'Consume this result and finish.', + deliveryContract: 'current_delivery' + }) + const pointers = new OrchestrationStructuredMailboxPointerDelivery({ + getDb: () => db, + getMessageWaiters: () => undefined, + resolveStructuredTarget: () => ({ sessionId: SESSION, dispatchId: dispatch.id }), + getCliCommand: () => 'orca-dev', + senderName: () => null, + host: createStructuredMailboxPointerHost() + }) + expect(pointers.deliverForHandle(mailboxHandle)).toBe(true) + await eventually(() => expect(first.sent).toHaveLength(2)) + frame(first, { ...first.sent.at(-1)!, uuid: 'mail-echo' }) + await eventually(() => expect(db.getMessageById(mail.id)?.delivered_at).not.toBeNull()) + expect(claude.connections).toHaveLength(1) + expect( + first.calls.some(({ subtype }) => subtype === 'set_model' || subtype === 'set_permission_mode') + ).toBe(false) + const pointer = (await host.journalSnapshot(SESSION)).submissions.at(-1)! + expect(pointer.options).toBeUndefined() + expect(store.getRecord(SESSION)?.options?.model).toBe('opus') + for (const [index, captured] of RESUMED_BY_MESSAGE.filter( + ({ at }) => at > 4_765 && at <= 10_960 + ).entries()) { + frame(first, { uuid: `native-result-${index}`, ...captured.frame }) + } + await eventually(async () => + expect(await savedRows()).toEqual([ + expect.objectContaining({ state: 'completed', tokens: 15_971 }) + ]) + ) + await eventually(() => expect(host.currentWork(SESSION)?.activeTurnId()).toBeNull()) + const consumer = { + runId, + mailboxHandle, + consumerGeneration: db.getDispatchContextById(dispatch.id)!.consumer_generation + } + const checked = db.getOrCreateMailboxDelivery(consumer)! + expect(checked.messages.map(({ id }) => id)).toEqual([mail.id]) + expect(db.getMessageById(mail.id)?.read).toBe(0) + db.acknowledgeMailboxDelivery({ ...consumer, deliveryId: checked.delivery.id }) + expect(db.getMessageById(mail.id)?.read).toBe(1) + const done = db.insertMessage({ + runId, + from: handle, + to: 'coordinator', + type: 'worker_done', + subject: 'Done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id, outcome: 'succeeded' }) + }) + expect(reconcileLifecycleMessage(db, done).action).toBe('completed') + expect(db.getTask(task.id)?.status).toBe('completed') + await store.replaceSessionOptions({ + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + permissionMode: 'auto', + now: clock + }) + const followupTask = db.createTask({ runId, spec: 'Continue on the same worker.' }) + const followup = db.createDispatchContext({ + taskId: followupTask.id, + assigneeHandle: handle, + processIncarnation: `structured:${SESSION}`, + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + expect(structuredWorkerOwesWork(db, store.getRecord(SESSION)!)).toBe(true) + + const nextBody = hostTestMessage('Reply on my captured choice.') + const options = { model: 'opus' } + const close = first.close + vi.spyOn(first, 'close').mockImplementationOnce(async () => { + await store.replaceSessionOptions({ + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + options: { model: 'haiku' }, + now: clock + }) + return close() + }) + const push = claude.connections.push.bind(claude.connections) + vi.spyOn(claude.connections, 'push').mockImplementation((connection) => { + const initialize = connection.initializationResult + connection.initializationResult = async () => { + frame(connection, { + type: 'system', + subtype: 'init', + uuid: 'reopened-root', + model: 'claude-opus-4-6', + permissionMode: 'default' + }) + return initialize() + } + return push(connection) + }) + await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: nextBody, options }), + body: nextBody, + options + }) + await eventually(() => expect(claude.connections).toHaveLength(2)) + const second = claude.connections[1]! + expect(first.closeCount).toBe(1) + expect(second.launch.options).toMatchObject({ model: 'opus', permissionMode: 'auto' }) + expect(store.getRecord(SESSION)?.options?.model).toBe('haiku') + expect(store.getRecord(SESSION)?.providerHandleChain).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + handle: expect.objectContaining({ transport: 'claude-sdk', nativeId: PROVIDER_SESSION_ID }) + }) + ]) + ) + await eventually(() => expect(second.sent).toHaveLength(1)) + frame(second, { ...second.sent[0]!, uuid: 'new-choice-echo' }) + frame(second, { + type: 'assistant', + uuid: 'reply-1', + parent_tool_use_id: null, + message: { role: 'assistant', content: [{ type: 'text', text: 'First reply.' }] } + }) + frame(second, { type: 'result', subtype: 'success', uuid: 'result-1', result: 'First reply.' }) + await eventually(() => expect(host.currentWork(SESSION)?.activeTurnId()).toBeNull()) + const again = hostTestMessage('Reply again.') + await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: again, options }), + body: again, + options + }) + await eventually(() => expect(second.sent).toHaveLength(2)) + frame(second, { ...second.sent[1]!, uuid: 'second-echo' }) + frame(second, { + type: 'assistant', + uuid: 'reply-2', + parent_tool_use_id: null, + message: { role: 'assistant', content: [{ type: 'text', text: 'Second reply.' }] } + }) + frame(second, { type: 'result', subtype: 'success', uuid: 'result-2', result: 'Second reply.' }) + await host.flushStreamedEvents(SESSION) + const transcript = JSON.stringify((await host.journalSnapshot(SESSION)).items) + expect(transcript).toContain('First reply.') + expect(transcript).toContain('Second reply.') + expect(claude.connections).toHaveLength(2) + expect(db.getDispatchContextById(followup.id)?.status).toBe('dispatched') + expect(structuredWorkerOwesWork(db, store.getRecord(SESSION)!)).toBe(true) +}) + it("keeps a background helper's row working past its spawn result and its turn's end, until its own end", async () => { const connection = claude.connections[0]! await sendAndEcho(connection, 'Run it in the background.') @@ -297,3 +549,288 @@ it('keeps a helper the next child resumes while it is still starting working, pa expect(claude.connections).toHaveLength(2) expect(await savedRows()).toEqual([expect.objectContaining({ id: 'agent-1', state: 'working' })]) }) + +it('steers on current choices and returns the same unwritten input to new-turn selection at admission', async () => { + const written = (on: FakeConnection, text: string) => + on.sent.find((message) => JSON.stringify(message).includes(text)) + const snapshot = async () => { + await host.flushStreamedEvents(SESSION) + return host.journalSnapshot(SESSION) + } + const send = async (text: string, options?: Record) => { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body, options }), + body, + options + }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + return sent.value.clientMessageId + } + claudeFrames.initProof = 'none' + const first = claude.connections[0]! + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + const dispatch = vi.spyOn(adapter, 'dispatch') + const recorded = vi.spyOn(store, 'reviseSessionOptions') + const idle = () => + eventually(() => + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) + ) + const init = (on: FakeConnection, permissionMode = 'default') => + frame(on, { + type: 'system', + subtype: 'init', + uuid: `init-${claude.connections.length}`, + model: on.launch.options.model === 'opus' ? 'claude-opus-4-6' : 'claude-haiku-4-5', + permissionMode + }) + const echo = (on: FakeConnection, text: string) => + frame(on, { ...written(on, text)!, isReplay: true, parent_tool_use_id: null }) + const finish = (on: FakeConnection, text: string) => { + const input = written(on, text)! + frame(on, { + type: 'assistant', + uuid: `reply-${text}`, + parent_tool_use_id: null, + message: { + id: `reply-${text}`, + role: 'assistant', + content: [{ type: 'text', text: `${text} result` }] + }, + user_message_uuid: input.uuid, + user_message_uuids: [input.uuid] + }) + frame(on, { + type: 'result', + subtype: 'success', + uuid: `result-${text}`, + is_error: false, + terminal_reason: 'completed', + user_message_uuid: input.uuid, + user_message_uuids: [input.uuid] + }) + frame(on, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + } + const opened = async (text: string, options?: Record) => { + const id = await send(text, options) + await eventually(() => expect(written(claude.connections.at(-1)!, text)).toBeDefined()) + const on = claude.connections.at(-1)! + init(on) + echo(on, text) + await eventually(() => expect(host.currentWork(SESSION)?.activeTurnId()).not.toBeNull()) + await idle() + return { id, on } + } + init(first) + await opened('A') + for (const captured of MOVED_TO_BACKGROUND.filter(({ at }) => at <= 11_139)) { + frame(first, captured.frame) + } + await eventually(async () => + expect(await savedRows()).toEqual( + expect.arrayContaining([expect.objectContaining({ state: 'working' })]) + ) + ) + const choices = { model: 'opus', effort: 'high', fastMode: 'true' } + await store.replaceSessionOptions({ + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + options: choices, + now: NOW + }) + const controls = first.calls.length + const b = await send('B', choices) + await eventually(() => expect(written(first, 'B')).toBeDefined()) + await idle() + expect(first.calls.slice(controls)).toEqual([]) + expect(first.closeCount).toBe(0) + expect(claude.connections).toHaveLength(1) + const bIndex = dispatch.mock.calls.findIndex(([input]) => input.clientMessageId === b) + expect(dispatch.mock.calls[bIndex]![0].executionOptions).toBeUndefined() + expect(await dispatch.mock.results[bIndex]!.value).toMatchObject({ + state: 'admitted', + ranOptions: { model: 'claude-haiku-4-5' } + }) + expect( + recorded.mock.calls.findLast(([update]) => update === recordAgentSessionRanOptions)?.[1] + ).toMatchObject({ model: 'claude-haiku-4-5' }) + expect( + (await snapshot()).submissions.find(({ clientMessageId }) => clientMessageId === b)?.options + ).toEqual(choices) + expect(await savedRows()).toEqual( + expect.arrayContaining([expect.objectContaining({ state: 'working' })]) + ) + echo(first, 'B') + for (const captured of MOVED_TO_BACKGROUND.filter(({ at }) => at >= 51_275 && at <= 52_441)) { + frame(first, captured.frame) + } + await eventually(async () => + expect(await savedRows()).toEqual( + expect.arrayContaining([expect.objectContaining({ state: 'completed' })]) + ) + ) + finish(first, 'A') + await eventually(() => expect(host.currentWork(SESSION)?.activeTurnId()).toBeNull()) + const c = await opened('C', choices) + expect(c.on).not.toBe(first) + expect(c.on.launch.options).toMatchObject({ model: 'opus', permissionMode: 'auto' }) + finish(c.on, 'C') + await eventually(() => expect(host.currentWork(SESSION)?.activeTurnId()).toBeNull()) + await opened('same choice', choices) + expect(claude.connections).toHaveLength(2) + + // Preparation is complete but pending has not entered the journal lane: still reversible. + const release = Promise.withResolvers() + releaseBarriers.push(() => release.resolve()) + const resolve = journal.resolveDispatch.bind(journal) + let held = false + const pending = vi + .spyOn(journal, 'resolveDispatch') + .mockImplementation(async (input, cards, receipt) => { + if (receipt && !held) { + held = true + await release.promise + } + return resolve(input, cards, receipt) + }) + const lateCapture = { ...choices, model: 'haiku' } + const late = await send('late preparation', lateCapture) + await eventually(() => expect(held).toBe(true)) + const queuedLate = (await snapshot()).submissions.find( + ({ clientMessageId }) => clientMessageId === late + )! + expect(queuedLate.options).toEqual(lateCapture) + expect(queuedLate.handedOverAt).toBeUndefined() + expect(isQueuedAgentJournalSubmission(queuedLate)).toBe(true) + expect(written(c.on, 'late preparation')).toBeUndefined() + expect(adapter.heldSends(SESSION)?.has(late)).toBe(false) + finish(c.on, 'same choice') + await eventually(() => expect(host.currentWork(SESSION)?.activeTurnId()).toBeNull()) + release.resolve() + await eventually(() => + expect(written(claude.connections.at(-1)!, 'late preparation')).toBeDefined() + ) + pending.mockRestore() + const d = claude.connections.at(-1)! + expect(d).not.toBe(c.on) + expect(d.launch.options).toMatchObject({ model: 'haiku', permissionMode: 'auto' }) + init(d) + echo(d, 'late preparation') + await idle() + + // The end is decoded after pending joins the lane, but its lifecycle fold queues behind it. + const target = host.currentWork(SESSION)!.activeTurnId() + let staged = false + let rolledBack = false + const queued = vi + .spyOn(journal, 'resolveDispatch') + .mockImplementation((input, cards, receipt) => { + if (!receipt || staged) { + return resolve(input, cards, receipt) + } + staged = true + let result!: ReturnType + return journal + .readInOrder(() => { + result = resolve(input, cards, { + ...receipt, + write: (db, row) => { + expect(host.currentWork(SESSION)?.activeTurnId()).toBe(target) + try { + receipt.write(db, row) + } catch (error) { + rolledBack = true + throw error + } + } + }) + finish(d, 'late preparation') + expect(host.currentWork(SESSION)?.activeTurnId()).toBe(target) + }) + .then(() => result) + }) + const lastCapture = { ...choices, model: 'opus' } + const final = await send('late journal', lastCapture) + await eventually(() => expect(written(claude.connections.at(-1)!, 'late journal')).toBeDefined()) + queued.mockRestore() + expect(rolledBack).toBe(true) + const e = claude.connections.at(-1)! + expect(e).not.toBe(d) + expect(e.launch.options).toMatchObject({ model: 'opus', permissionMode: 'auto' }) + init(e, 'auto') + echo(e, 'late journal') + await idle() + + // A returned-promise delay after actual COMMIT/offer is already past local admission. + const offered = Promise.withResolvers() + const acknowledge = Promise.withResolvers() + releaseBarriers.push(() => acknowledge.resolve()) + const admitted = vi + .spyOn(journal, 'resolveDispatch') + .mockImplementation((input, cards, receipt) => { + const result = resolve(input, cards, receipt) + return receipt + ? result.then(async (cursor) => { + offered.resolve() + await acknowledge.promise + return cursor + }) + : result + }) + const currentControls = e.calls.length + const after = await send('after admission', { model: 'haiku' }) + await eventually(() => expect(written(e, 'after admission')).toBeDefined()) + await offered.promise + expect(e.calls).toHaveLength(currentControls) + finish(e, 'late journal') + await eventually(() => expect(host.currentWork(SESSION)?.activeTurnId()).toBeNull()) + acknowledge.resolve() + await idle() + admitted.mockRestore() + echo(e, 'after admission') + finish(e, 'after admission') + await eventually(async () => + expect( + (await snapshot()).submissions.find(({ clientMessageId }) => clientMessageId === after) + ?.dispatchState + ).toBe('accepted') + ) + for (const [id, text, capture] of [ + [late, 'late preparation', lateCapture], + [final, 'late journal', lastCapture] + ] as const) { + expect( + claude.connections + .flatMap(({ sent }) => sent) + .filter( + (message) => message.message && JSON.stringify(message.message).includes(`"${text}"`) + ) + ).toHaveLength(1) + expect( + (await snapshot()).submissions.find(({ clientMessageId }) => clientMessageId === id) + ).toMatchObject({ clientMessageId: id, options: capture, dispatchState: 'accepted' }) + } + expect( + claude.connections + .flatMap(({ sent }) => sent) + .filter((message) => JSON.stringify(message).includes('after admission')) + ).toHaveLength(1) + expect(claude.connections).toHaveLength(4) + expect( + (await snapshot()).items.flatMap(({ body }) => + body.kind === 'message' + ? body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) + : [] + ) + ).toEqual( + expect.arrayContaining([ + 'A result', + 'C result', + 'same choice result', + 'late preparation result', + 'late journal result' + ]) + ) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts index 7ccbd6d5497e..0ab6e1139c45 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts @@ -167,14 +167,31 @@ export class StructuredAgentSessionCurrentWork { } private holds(submission: AgentJournalSubmission): boolean { + return this.holdsAny([submission]) + } + + /** `holds` for any of these sends, reading what the child holds once and only when one is asked + * about: the child's answer is rebuilt on each read and never outlives the call. */ + private holdsAny(submissions: readonly AgentJournalSubmission[]): boolean { + if (submissions.length === 0) { + return false + } const held = this.heldSends?.() + const handingOver = structuredAgentSessionHandingOver(this.journal) return ( held === undefined || - held.has(submission.clientMessageId) || - structuredAgentSessionHandingOver(this.journal).has(submission.clientMessageId) + submissions.some( + ({ clientMessageId }) => held.has(clientMessageId) || handingOver.has(clientMessageId) + ) ) } + /** Whether a send handed to the live generation is still owed: `owesSend` over every send not + * queued, in one pass. */ + holdsHandedOverSend(): boolean { + return this.holdsAny(this.unansweredSends()) + } + isCurrentItem(itemId: string): boolean { if (this.liveFence === null) { return false 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 1f2b73cfd102..86af5742f499 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,5 @@ +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionChildTransition } from './structured-agent-session-outgrown-child' import type { StructuredAgentSessionStartFlights } from './structured-agent-session-start-flight' import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -27,7 +29,10 @@ export type StructuredAgentSessionDeliveryLoopDeps = { trackStart: (start: Promise) => Promise startProviderChild: (sessionId: string, startedFor: string) => StructuredAgentSessionStartClaim /** Retire mismatched login/permission children under serialization. */ - retireOutgrownChild: (sessionId: string) => Promise + retireOutgrownChild: ( + sessionId: string, + next: AgentJournalSubmission + ) => Promise readiness: ( sessionId: string, child: StructuredAgentSessionProviderChildIdentity 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 f762d4aa4d46..d8ca59045c03 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 @@ -23,6 +23,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 { rejectBlockedStructuredAgentSessionTransition } from './structured-agent-session-outgrown-child' export type { StructuredAgentSessionDeliveryLoopDeps } from './structured-agent-session-delivery-loop-deps' @@ -123,12 +124,7 @@ export class StructuredAgentSessionDeliveryLoop { } await prepared.before const handed = await this.deps.serialize(sessionId, () => - this.handOver( - sessionId, - prepared.awaited, - prepared.clientMessageId, - prepared.readiness !== null - ) + this.handOver(sessionId, prepared.awaited, prepared.clientMessageId) ) if (handed === 'stop') { return @@ -199,7 +195,11 @@ export class StructuredAgentSessionDeliveryLoop { if (failedStart) { return this.fail(sessionId, failedStart, oldest.clientMessageId) } - await this.deps.retireOutgrownChild(sessionId) + if ( + (await rejectBlockedStructuredAgentSessionTransition(this.deps, sessionId, oldest)) === true + ) { + return 'continue' + } const child = this.deps.sessions.get(sessionId)?.child if (!child || child.close) { return { @@ -218,8 +218,7 @@ export class StructuredAgentSessionDeliveryLoop { private async handOver( sessionId: string, awaited: StructuredAgentSessionProviderChildIdentity, - clientMessageId: string, - waited: boolean + clientMessageId: string ): Promise<'continue' | 'stop' | StructuredAgentSessionPendingHandover> { const session = this.deps.sessions.get(sessionId) if (!session || this.disposed) { @@ -256,12 +255,14 @@ export class StructuredAgentSessionDeliveryLoop { if (!next) { return this.stop(sessionId) } - if (waited) { - // Verify awaited-child identity before rechecking launch permissions. - await this.deps.retireOutgrownChild(sessionId) - if (session.child !== awaitedChild) { - return 'continue' - } + // Re-derive for the actual next input after proving the awaited child still exists. + const transition = await rejectBlockedStructuredAgentSessionTransition( + this.deps, + sessionId, + next + ) + if (transition === true || session.child !== awaitedChild) { + return 'continue' } if ( waitsForCodexTurnOptions(this.deps.record(sessionId), session.journal, next, awaitedChild) @@ -288,7 +289,8 @@ export class StructuredAgentSessionDeliveryLoop { }, next, // Recheck child identity immediately before the external provider write. - () => this.deps.sessions.get(sessionId)?.child === awaitedChild && !awaitedChild.close + () => this.deps.sessions.get(sessionId)?.child === awaitedChild && !awaitedChild.close, + typeof transition === 'function' ? transition : undefined ) return pending ?? 'continue' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index f68cabda1e59..861b7bd50be0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -51,7 +51,7 @@ export class StructuredAgentSessionSinkQueue { /** Closing drops only buffered operations after this handover sequence. */ private handedOverSequence = 0 private handedOverSettled: Promise = Promise.resolve() - private readonly buffered: Admitted[] = [] + private readonly buffered = new Map() private readonly waitingPublications = new Map() private readonly waiters: StructuredAgentSessionDrainWaiter[] = [] private readonly finalTail = new StructuredAgentSessionFinalTailReservation() @@ -105,7 +105,9 @@ export class StructuredAgentSessionSinkQueue { return } this.target = target - for (const operation of this.buffered.splice(0)) { + const buffered = [...this.buffered.values()] + this.buffered.clear() + for (const operation of buffered) { this.handOver(operation, target) } } @@ -149,14 +151,8 @@ export class StructuredAgentSessionSinkQueue { return { accepted: false, reason: 'failed' } } const key = operation.publicationKey - const replaceAt = - key === undefined ? -1 : this.buffered.findIndex((queued) => queued.publicationKey === key) const replaced = - replaceAt >= 0 - ? this.buffered[replaceAt] - : key === undefined - ? undefined - : this.waitingPublications.get(key) + key === undefined ? undefined : (this.buffered.get(key) ?? this.waitingPublications.get(key)) const finalTail = operation.finalTail === true const lifecycle = !finalTail && (operation.lifecycle ?? options.lifecycle === true) const lifecycleBytes = lifecycle ? (operation.lifecycleBytes ?? operation.bytes) : 0 @@ -171,7 +167,7 @@ export class StructuredAgentSessionSinkQueue { !finalTail && !lifecycle && nextOperations > this.deps.watermarks.maxQueuedOperations const exceedsLifecycle = lifecycle && nextLifecycleOperations > this.deps.watermarks.maxLifecycleQueuedOperations - if (exceedsOrdinary || exceedsLifecycle) { + if (!options.criticalRoot && (exceedsOrdinary || exceedsLifecycle)) { this.setBackpressure(true) return { accepted: false, reason: 'backpressure' } } @@ -188,9 +184,7 @@ export class StructuredAgentSessionSinkQueue { lifecycleBytes, releaseFinalTail: releaseFinalTail ?? undefined } - if (replaceAt >= 0) { - this.buffered.splice(replaceAt, 1) - } else if (replaced) { + if (replaced && !this.buffered.delete(key ?? replaced.sequence)) { // Superseded publications still settle in order as no-ops. replaced.superseded = true } @@ -201,7 +195,7 @@ export class StructuredAgentSessionSinkQueue { if (this.target) { this.handOver(accepted, this.target) } else { - this.buffered.push(accepted) + this.buffered.set(key ?? accepted.sequence, accepted) } this.updateBackpressure() return { accepted: true } @@ -254,7 +248,8 @@ export class StructuredAgentSessionSinkQueue { /** Buffered drops settle only after earlier handed-over writes so barriers cannot overtake them. */ private dropBuffered(): void { - const dropped = this.buffered.splice(0) + const dropped = [...this.buffered.values()] + this.buffered.clear() for (const operation of dropped) { this.release(operation) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index d03582c401fb..4e2c09098e3f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -18,6 +18,7 @@ import { createDeferredStructuredAgentSessionEventSink, type StructuredAgentSessionEventTarget } from './structured-agent-session-event-sink' +import { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { @@ -118,6 +119,193 @@ function target( } describe('deferred structured agent-session event sink', () => { + it('bounds buffered key lookup and snapshot work while preserving coalescing and drop order', async () => { + const measure = async (size: number) => { + const runs = new WeakSet() + const maps = new WeakSet() + const counts = { keys: 0, snapshot: 0, scanned: 0 } + const isOperation = (value: unknown): boolean => + typeof value === 'object' && + value !== null && + 'run' in value && + typeof value.run === 'function' && + runs.has(value.run) + const keyed = (key: unknown): boolean => + typeof key === 'string' && key.startsWith('buffer-cost:') + const get = Map.prototype.get + const set = Map.prototype.set + const remove = Map.prototype.delete + const values = Map.prototype.values + const findIndex = Array.prototype.findIndex + const spies = [ + vi.spyOn(Map.prototype, 'get').mockImplementation(function ( + this: Map, + key: unknown + ) { + if (keyed(key) || maps.has(this)) { + maps.add(this) + counts.keys += 1 + } + return get.call(this, key) + }), + vi.spyOn(Map.prototype, 'set').mockImplementation(function ( + this: Map, + key: unknown, + value: unknown + ) { + if (isOperation(value) || maps.has(this)) { + maps.add(this) + counts.keys += 1 + } + return set.call(this, key, value) + }), + vi.spyOn(Map.prototype, 'delete').mockImplementation(function ( + this: Map, + key: unknown + ) { + if (keyed(key) || maps.has(this)) { + counts.keys += 1 + } + return remove.call(this, key) + }), + vi.spyOn(Map.prototype, 'values').mockImplementation(function ( + this: Map + ) { + if (maps.has(this)) { + counts.snapshot += this.size + } + return values.call(this) + }), + vi.spyOn(Array.prototype, 'findIndex').mockImplementation(function ( + this: unknown[], + predicate, + thisArg + ) { + return findIndex.call(this, (value, index, array) => { + if (isOperation(value)) { + counts.scanned += 1 + } + return predicate.call(thisArg, value, index, array) + }) + }) + ] + try { + const log: string[] = [] + const bound = target(7, []) + const reads: (() => void)[] = [] + vi.spyOn(bound.journal, 'readInOrder').mockImplementation( + (read: () => T): Promise => + new Promise((resolve) => reads.push(() => resolve(read()))) + ) + const queue = () => + new StructuredAgentSessionSinkQueue({ + watermarks: { + pauseQueuedBytes: 4, + maxQueuedBytes: 8, + lowQueuedBytes: 1, + pauseQueuedOperations: 4, + maxQueuedOperations: 8, + lowQueuedOperations: 1, + maxLifecycleQueuedBytes: 8, + maxLifecycleQueuedOperations: 8 + } + }) + const live = queue() + const dropped = queue() + const submit = ( + sink: StructuredAgentSessionSinkQueue, + label: string, + key?: string, + reenter?: () => void + ) => { + const run = () => { + log.push(label) + reenter?.() + } + runs.add(run) + expect( + sink.submit({ bytes: 1, publicationKey: key, run }, { criticalRoot: true }) + ).toEqual({ accepted: true }) + } + const fill = (sink: StructuredAgentSessionSinkQueue) => { + for (let index = 0; index < size; index += 1) { + submit(sink, `write:${index}`) + submit(sink, `old:${index}`, `buffer-cost:${index}`) + } + for (let index = 0; index < size; index += 1) { + submit(sink, `buffered:${index}`, `buffer-cost:${index}`) + } + expect(sink.state()).toMatchObject({ + queuedOperations: 2 * size, + queuedBytes: 2 * size, + backpressured: true, + failed: false + }) + } + fill(live) + // During bind, an issued write unbinds and buffers one further write. + submit(live, 'reentry', undefined, () => { + live.unbind() + submit(live, 'from-reentry') + }) + live.bind(bound) + expect(log).toEqual([ + ...Array.from({ length: size }, (_, index) => `write:${index}`), + 'reentry' + ]) + for (let index = 0; index < size; index += 1) { + submit(live, `latest:${index}`, `buffer-cost:${index}`) + } + live.bind(bound) + for (const read of reads) { + read() + } + await expect(live.barrier()).resolves.toEqual({ ok: true }) + await expect(live.written()).resolves.toEqual({ ok: true }) + expect(log).toEqual([ + ...Array.from({ length: size }, (_, index) => `write:${index}`), + 'reentry', + 'from-reentry', + ...Array.from({ length: size }, (_, index) => `latest:${index}`) + ]) + expect(live.state()).toEqual({ + queuedOperations: 0, + queuedBytes: 0, + backpressured: false, + failed: false + }) + fill(dropped) + const beforeDrop = [...log] + const written = dropped.written() + dropped.close() + dropped.bind(bound) + await expect(dropped.barrier()).resolves.toEqual({ ok: true }) + await expect(written).resolves.toMatchObject({ ok: false }) + expect(log).toEqual(beforeDrop) + expect(dropped.state()).toEqual({ + queuedOperations: 0, + queuedBytes: 0, + backpressured: false, + failed: false + }) + return counts + } finally { + for (const spy of spies) { + spy.mockRestore() + } + } + } + const small = await measure(200) + const large = await measure(2000) + const work = (count: typeof small) => count.keys + count.snapshot + count.scanned + console.info('buffer key/snapshot work', { small, large }) + expect(small.snapshot).toBe(5 * 200 + 2) + expect(large.snapshot).toBe(5 * 2000 + 2) + expect(work(small)).toBeLessThanOrEqual(64 * 200 + 32) + expect(work(large)).toBeLessThanOrEqual(64 * 2000 + 32) + expect(work(large)).toBeLessThanOrEqual(10 * work(small) + 32) + }) + it('buffers writes made before the journal exists and drains them in arrival order', async () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 00278f31b8ce..a1cb5ea6dadb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -50,12 +50,14 @@ export type StructuredAgentSessionSinkBarrier = { ok: true } | { ok: false; erro /** Omitted producer linkage means session/root agent. */ export type StructuredAgentSessionAppendOptions = AgentJournalProducerLinkage & { lifecycle?: boolean + /** Root control events remain ordered even when ordinary input is paused. */ + criticalRoot?: true observedAt?: number } export type StructuredAgentSessionPublishOptions = Pick< StructuredAgentSessionAppendOptions, - 'lifecycle' + 'lifecycle' | 'criticalRoot' > & { coalescingKey?: string } @@ -198,7 +200,6 @@ export function createDeferredStructuredAgentSessionEventSink(deps: { onBackpressureChange?: (backpressured: boolean, state: StructuredAgentSessionSinkState) => void }): DeferredStructuredAgentSessionEventSink { const exit = new StructuredAgentSessionExitObservation() - const watermarks = { ...DEFAULT_WATERMARKS, ...deps.watermarks } const failed = (error: unknown): void => { deps.logger.error('writing provider events to the chat journal failed', { scope: 'journal-event-sink', @@ -208,7 +209,7 @@ export function createDeferredStructuredAgentSessionEventSink(deps: { deps.onFailed?.(error) } const queue = new StructuredAgentSessionSinkQueue({ - watermarks, + watermarks: { ...DEFAULT_WATERMARKS, ...deps.watermarks }, onFailed: failed, ...(deps.readingControl ? { readingControl: deps.readingControl } : {}), ...(deps.onBackpressureChange ? { onBackpressureChange: deps.onBackpressureChange } : {}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts index 621b8deafe3e..ff4b4993de1f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts @@ -17,7 +17,9 @@ import type { import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import type { AgentSessionDispatchOutcome } from './structured-agent-session-adapter' +import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { AgentSessionPreDispatchError } from './structured-agent-session-operation-settlement' import { handOverStructuredAgentSessionCommand, structuredAgentSessionHandoverOrigin, @@ -49,7 +51,8 @@ export type StructuredAgentSessionPendingHandover = { export async function beginSubmissionHandover( ctx: AgentSessionHandoverContext, submission: AgentJournalSubmission, - stillCurrent: () => boolean + stillCurrent: () => boolean, + keepsTurn?: (providerTurn: () => string | null) => boolean ): Promise { const { clientMessageId } = submission const body = ctx.journal.itemBody(agentJournalSubmissionKey(clientMessageId)) @@ -83,14 +86,24 @@ export async function beginSubmissionHandover( ) const attempt = ctx.acquireAborts?.begin(ctx.sessionId, true) const queued = submission.queuedMessageId !== undefined + const executionOptions = keepsTurn ? undefined : submission.options + let reconsider = false + const pending = (receipt?: JournalOperationReceipt) => + ctx.journal.resolveDispatch( + { + clientMessageId, + state: 'pending', + fence: ctx.fence, + turnScope, + ...(carried ? { carries: carried.covers } : {}) + }, + undefined, + receipt + ) try { - await ctx.journal.resolveDispatch({ - clientMessageId, - state: 'pending', - fence: ctx.fence, - turnScope, - ...(carried ? { carries: carried.covers } : {}) - }) + if (!keepsTurn) { + await pending() + } } catch (error) { released() release?.() @@ -117,20 +130,48 @@ export async function beginSubmissionHandover( clientMessageId, carried ? withStoppedHistory(body, carried.messages) : body, requestedAt, - submission.options, + executionOptions, queued, - attempt?.signal + attempt?.signal, + keepsTurn + ? async (offer, providerTurn) => { + let written!: Promise + let origin!: number + await pending({ + write: (_db, row) => { + if (!row) { + throw new Error('pending dispatch receipt has no row') + } + origin = row.ts + attempt?.signal.throwIfAborted() + if (!stillCurrent() || !keepsTurn(providerTurn)) { + reconsider = true + throw new AgentSessionPreDispatchError('steer-target-ended') + } + }, + committed: () => { + written = offer(origin) + // The offer stays observed even if the subsequent fold throws. + void written.catch(() => undefined) + } + }) + await written + } + : undefined ) : null } finally { attempt?.end() } + if (reconsider) { + return async () => undefined + } // The journal writer orders proof before teardown; the host lane may be closing the child. await resolveHandover(ctx, clientMessageId, outcome) return async () => { if (outcome?.state === 'accepted' || outcome?.state === 'admitted') { await ctx.recordRanOptions?.({ - message: submission.options, + message: executionOptions, executed: outcome.ranOptions }) } @@ -189,7 +230,10 @@ async function dispatchSafely( requestedAt: number, executionOptions: AgentSessionExecutionOptions | undefined, queued: boolean, - signal?: AbortSignal + signal?: AbortSignal, + admitPreparedInput?: Parameters< + AgentSessionHandoverContext['adapter']['dispatch'] + >[0]['admitPreparedInput'] ): Promise { try { const outcome = await ctx.adapter.dispatch({ @@ -200,6 +244,7 @@ async function dispatchSafely( ...(signal ? { signal } : {}), fence: ctx.fence, requestedAt, + ...(admitPreparedInput ? { admitPreparedInput } : {}), onInputPreparation: ctx.recordRanOptions?.prepareInput, onExecutionSnapshot: ctx.recordRanOptions?.checkpoint }) 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 02dfbeb9184b..9fa5ac117c28 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 @@ -83,12 +83,21 @@ export function createStructuredAgentSessionConversationDelivery(input: { serialize: input.serialize, trackStart: input.trackStart, startProviderChild: input.startProviderChild, - retireOutgrownChild: async (sessionId) => { + retireOutgrownChild: async (sessionId, 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, { + return retireOutgrownStructuredAgentSessionChild(sessionId, session, { + next, + turnScopedSteering: deps.store.getRecord(sessionId)?.provider === 'claude', + requiresModelTransitionRestart: (submission) => + session?.child?.phase === 'ready' && + deps.adapter.requiresModelTransitionRestart?.({ + sessionId, + fence: session.child.fence, + executionOptions: submission.options + }) === true, work: { currentWork: () => hostStructuredAgentSessionCurrentWork({ store: deps.store, sessions }, sessionId), @@ -98,7 +107,12 @@ export function createStructuredAgentSessionConversationDelivery(input: { const record = deps.store.getRecord(sessionId) return record !== null && deps.hasOpenDispatch?.(record) === true }, - providerHoldsDispatch: () => deps.adapter.holdsDispatch?.(sessionId) === true + providerHoldsDispatch: () => + deps.adapter.holdsDispatch?.(sessionId) === true || + hostStructuredAgentSessionCurrentWork( + { store: deps.store, sessions }, + sessionId + )?.holdsHandedOverSend() === true }, launchPermission: async () => { const record = deps.store.getRecord(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 5e119580bbb7..91824267fe87 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -73,6 +73,8 @@ export function sendStructuredAgentSessionTurn( /** Host-origin personal text, including launch prompts; other agents carry from instead. */ personsMessage?: true beforeRun?: () => void + /** Host-local: keep the running process's choices instead of capturing saved ones. */ + keepCurrentOptions?: true }, arrival?: Parameters[2] ): Promise> { 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 45b083939926..ad5bbf51acef 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,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { AGENT_CHAT_PERMISSION_MODES } from '../../../shared/agent-chat-permission-mode' 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' @@ -43,66 +44,81 @@ function launchPermission( stop: vi.fn(), providerStarted: vi.fn() } - return { - read, - result: structuredAgentSessionLaunchPermission( - { agents: claudeAndCodexDeclared(), modelCatalog }, - record(provider, options) - ) - } + const deps = { agents: claudeAndCodexDeclared(), modelCatalog } + return { read, result: structuredAgentSessionLaunchPermission(deps, 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.each(AGENT_CHAT_PERMISSION_MODES)( + 'launches saved Claude %s without consulting the catalog', + async (permissionMode) => { + const launch = launchPermission( + 'claude', + { permissionMode, model: 'haiku' }, + listing([{ id: 'haiku' }]) + ) + await expect(launch.result).resolves.toBe(permissionMode) + expect(launch.read).not.toHaveBeenCalled() + } + ) - it('asks for a chat saved before modes existed', async () => { - await expect(launchPermission('claude', undefined).result).resolves.toBe('ask') + it('delegates saved own settings without consulting the catalog', async () => { + for (const provider of ['claude', 'codex']) { + const launch = launchPermission( + provider, + { permissionMode: 'own', model: 'unlisted-model' }, + new Error('catalog unavailable') + ) + await expect(launch.result).resolves.toBe('own') + expect(launch.read).not.toHaveBeenCalled() + } }) - it('runs Claude auto on a model the saved catalog lists as supporting it', async () => { - const supported = launchPermission( + // Captured saved choices with an alias-only host catalog. + it('keeps saved Haiku full-ID Auto with the captured alias-only catalog', async () => { + const launch = launchPermission( 'claude', - { permissionMode: 'auto', model: 'opus' }, - listing([{ id: 'opus', supportsAutoMode: true }]) + { model: 'claude-haiku-4-5-20251001', permissionMode: 'auto' }, + listing([ + { id: 'opus', isDefault: true, supportsAutoMode: true }, + { id: 'fable', isDefault: false, supportsAutoMode: true }, + { id: 'sonnet', isDefault: false, supportsAutoMode: true }, + { id: 'haiku', isDefault: false, supportsAutoMode: true } + ]) ) - await expect(supported.result).resolves.toBe('auto') - expect(supported.read).toHaveBeenCalledWith(expect.objectContaining({ savedOnly: true })) + await expect(launch.result).resolves.toBe('auto') + expect(launch.read).not.toHaveBeenCalled() }) - it('matches a chat with no model to the listed default', async () => { - await expect( - launchPermission( + // #30032: a catalog saved under another account home read as none, and the chat relaunched asking. + it('keeps saved auto with no selected model, unlisted models, or an unavailable catalog', async () => { + const savedOptions: Record[] = [{}, { model: 'claude-opus-5-5[1m]' }] + for (const options of savedOptions) { + const launch = launchPermission( 'claude', - { permissionMode: 'auto' }, - listing([ - { id: 'sonnet', isDefault: true, supportsAutoMode: false }, - { id: 'opus', supportsAutoMode: true } - ]) - ).result - ).resolves.toBe('ask') + { permissionMode: 'auto', ...options }, + new Error('catalog unavailable') + ) + await expect(launch.result).resolves.toBe('auto') + expect(launch.read).not.toHaveBeenCalled() + } + await expect(launchPermission('codex', { permissionMode: 'auto' }).result).resolves.toBe('auto') }) - 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('asks for an absent or invalid saved mode, including modes the provider does not offer', async () => { + await expect(launchPermission('claude', undefined).result).resolves.toBe('ask') + await expect( + launchPermission('claude', { permissionMode: 'bypassPermissions' }).result + ).resolves.toBe('ask') + await expect( + launchPermission('codex', { permissionMode: 'accept-edits' }).result + ).resolves.toBe('ask') }) - it('has none for an agent with no modes', async () => { + it('has none for an unregistered agent or one with no modes', async () => { + await expect(launchPermission('grok', { permissionMode: 'full-access' }).result).resolves.toBe( + null + ) await expect(launchPermission('pi', { permissionMode: 'full-access' }).result).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 b3f858d82ed2..544927b27867 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 @@ -1,46 +1,19 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentChatPermissionModes, - chatModelSupportsAutoMode, - chatPermissionModeNeedsModelSupport, - effectiveChatPermissionMode, + savedChatPermissionMode, 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: AgentSessionRecord -): 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: AgentSessionRecord ): 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) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts index 5b8f237cb47f..c3ada64c5c74 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts @@ -27,7 +27,7 @@ export function waitsForCodexTurnOptions( next: AgentJournalSubmission, child: StructuredAgentSessionProviderChild ): boolean { - if (record?.provider !== 'codex') { + if (record?.provider !== 'codex' || next.options === undefined) { return false } const body = journal.itemBody(agentJournalSubmissionKey(next.clientMessageId)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 78a2a4fb5e61..ffc7a45d48a4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -130,6 +130,7 @@ export function sendPlan( /** Host-sent personal text; userSend always implies this. */ personsMessage?: true beforeRun?: () => void + keepCurrentOptions?: true } ): MutationPlan { // Match operation ID to optimistic client message ID for durable reconciliation. @@ -151,6 +152,7 @@ export function sendPlan( ...(source ? { source } : {}), clientMessageId, ...(params.options ? { options: params.options } : {}), + ...(params.keepCurrentOptions ? { keepCurrentOptions: true } : {}), payloadFingerprint: agentSessionSendBodyFingerprint(params.envelope.sessionId, params.body), body: params.body }) 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 ea51d69eb64e..785157d226dc 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, @@ -39,7 +36,7 @@ describe('a send after the agent said it is not signed in', () => { const fence = () => rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 - it('goes to a new agent, and the new agent is kept for the sends after it', async () => { + it('recovers a signed-out child for mail after sign-in, then reuses the new child', async () => { await foundRestTestChat(rig) rig.adapter.dispatch.mockImplementationOnce(async () => ({ state: 'rejected', @@ -52,7 +49,18 @@ describe('a send after the agent said it is not signed in', () => { expect(rig.adapter.acquire).toHaveBeenCalledTimes(1) rig.adapter.closeSession.mockClear() - expect((await rig.host.send(CALLER, restTestSend('signed in now', fence()))).ok).toBe(true) + const mail = restTestSend('signed in now: worker mail', fence()) + mail.body.from = { + kind: 'agent', + senders: [], + orchestration: { + message: 'mail-notice', + mailbox: 'agent:worker', + dispatchId: null, + messages: [] + } + } + expect((await rig.host.send(CALLER, mail)).ok).toBe(true) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(3)) expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) @@ -128,6 +136,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 @@ -235,6 +244,54 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { expect(before.stopAgent).toHaveBeenCalledWith('session') }) + it('says why a model change waits, reading wake debt only when open work leaves that open', async () => { + const settled: AgentChildWorkView = { + ...liveChildWork, + state: 'done', + membership: 'settled', + outcome: 'succeeded', + settledAt: 200 + } + const refuse = async (work: Partial, children: AgentChildWorkView[]) => { + const childWork = vi.fn(() => children) + const refusal = await retireOutgrownStructuredAgentSessionChild( + 'session', + conversation({ newestTurnEndedAt: 100 }), + { + work: { ...idle, childWork, ...work }, + next: { + clientMessageId: 'next', + fence: 2, + payloadFingerprint: 'next', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 0, + resolvedAt: null + }, + requiresModelTransitionRestart: () => true, + stopAgent: vi.fn(async () => undefined), + logger: { warn: vi.fn(), error: vi.fn() } + } + ) + return { refusal, wakeDebtReads: childWork.mock.calls.length } + } + const waits = (reason: string, wakeDebtReads: number) => ({ + refusal: { details: { reason } }, + wakeDebtReads + }) + expect(await refuse({ childWorkOpen: () => true }, [settled])).toMatchObject( + waits('backgroundTasksRunning', 0) + ) + expect(await refuse({}, [settled])).toMatchObject(waits('backgroundTasksRunning', 1)) + expect(await refuse({ providerHoldsDispatch: () => true }, [settled])).toMatchObject( + waits('backgroundTasksRunning', 1) + ) + expect(await refuse({ providerHoldsDispatch: () => true }, [])).toMatchObject( + waits('messagesUnsettled', 1) + ) + }) + it('logs a stop that fails and lets the send go on', async () => { const { warn } = await retire( signedOut, @@ -278,87 +335,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 aa2729d702af..bbb9679c2406 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 @@ -1,8 +1,12 @@ -// Refresh login/permission mode by replacing outgrown children before sending; agents read them only at startup. +// Replace outgrown launch construction before user input; work the process owns keeps its child. -import { readWholeAgentSessionFailureFact } from '../../../shared/agent-session-failure' +import { + agentSessionFailureFact, + readWholeAgentSessionFailureFact +} from '../../../shared/agent-session-failure' import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -12,9 +16,51 @@ 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 type { AgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' + +export type StructuredAgentSessionChildTransition = + | AgentSessionRefusalReference + | ((providerTurn: () => string | null) => boolean) + | void + +export async function rejectBlockedStructuredAgentSessionTransition( + deps: { + retireOutgrownChild: ( + sessionId: string, + next: AgentJournalSubmission + ) => Promise + sessions: ReadonlyMap> + conversationFence: (sessionId: string) => number + failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext + }, + sessionId: string, + next: AgentJournalSubmission +): Promise string | null) => boolean)> { + const refusal = await deps.retireOutgrownChild(sessionId, next) + if (!refusal || typeof refusal === 'function') { + return refusal ?? false + } + await deps.sessions.get(sessionId)?.journal.resolveDispatch({ + clientMessageId: next.clientMessageId, + state: 'rejected', + fence: deps.conversationFence(sessionId), + ...agentSessionFailureWords(agentSessionFailureFact('restartFailed', { refusal }), { + ...deps.failureTextContext(sessionId), + surface: 'rejection' + }) + }) + return true +} type OutgrownChildReading = Pick & { - journal: Pick & { + journal: Pick< + AgentSessionJournal, + 'visitItemsWithLinkage' | 'itemFence' | 'newestTurn' | 'itemBody' + > & { submissions(): readonly Pick[] } } @@ -87,19 +133,65 @@ export async function retireOutgrownStructuredAgentSessionChild( childWork: () => readonly AgentChildWorkView[] | undefined } launchPermission?: () => Promise + turnScopedSteering?: boolean + next?: AgentJournalSubmission + requiresModelTransitionRestart?: (next: AgentJournalSubmission) => boolean stopAgent: (sessionId: string) => Promise logger: StructuredAgentSessionLogger } -): Promise { - if ( - !session || - !( - structuredAgentSessionChildReportedSignedOut(session) || - (await launchedWithOtherPermissionMode(session.child, deps.launchPermission)) - ) || - structuredAgentSessionChildHasOpenWork(deps.work) || - owesWakeUp(session.journal, deps.work.childWork()) - ) { +): Promise { + if (!session) { + return + } + const body = + deps.next && session.journal.itemBody(agentJournalSubmissionKey(deps.next.clientMessageId)) + const mail = body?.kind === 'message' && body.from?.orchestration?.message === 'mail-notice' + const child = session.child + const turnId = + deps.turnScopedSteering && + child?.phase === 'ready' && + !child.close && + body?.kind === 'message' && + !body.command && + !mail && + deps.next?.queuedMessageId === undefined + ? deps.work.currentWork()?.activeTurnId() + : null + const steer = + turnId && child + ? (providerTurn: () => string | null) => + session.child === child && + !child.close && + deps.work.currentWork()?.activeTurnId() === turnId && + providerTurn() === turnId + : undefined + const keepOptions = mail || steer !== undefined + const otherReason = + structuredAgentSessionChildReportedSignedOut(session) || + (!keepOptions && (await launchedWithOtherPermissionMode(session.child, deps.launchPermission))) + const modelTransition = + !keepOptions && deps.next && deps.requiresModelTransitionRestart?.(deps.next) === true + if (!otherReason && !modelTransition) { + return steer + } + const open = structuredAgentSessionChildHasOpenWork( + modelTransition && !otherReason ? { ...deps.work, hasOpenDispatch: () => false } : deps.work + ) + if (open && !modelTransition) { + return steer + } + // Wake debt is derived only where it decides: keeping an idle child, or why a model change waits. + const background = + (open && deps.work.childWorkOpen()) || owesWakeUp(session.journal, deps.work.childWork()) + if (open || background) { + return modelTransition + ? { + code: 'agent_session_operation_invalid', + details: { reason: background ? 'backgroundTasksRunning' : 'messagesUnsettled' } + } + : undefined + } + if (!session.child || session.child.phase === 'starting' || session.child.close) { return } await deps.stopAgent(sessionId).catch((error: unknown) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prepared-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prepared-admission.ts new file mode 100644 index 000000000000..90def80c4b4f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prepared-admission.ts @@ -0,0 +1,5 @@ +/** Local preparation only: no journal or wire representation. */ +export type StructuredAgentSessionPreparedAdmission = ( + offer: (requestedAt: number) => Promise, + currentTurn: () => string | null +) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index f11681fcba42..b21757700729 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -93,6 +93,7 @@ export async function performSend( /** Persist only source kind in the submission. */ source?: AgentSessionMessageSource options?: Readonly> + keepCurrentOptions?: true } ): Promise> { const existing = ctx.journal @@ -110,13 +111,16 @@ export async function performSend( value: { clientMessageId: input.clientMessageId, submission: { ...existing } } } } - const captured = captureStructuredExecutionOptions(ctx, input.options) + const { keepCurrentOptions, ...submission } = input + const captured = keepCurrentOptions + ? { options: undefined } + : captureStructuredExecutionOptions(ctx, input.options) if ('refusal' in captured) { return { ok: false, refusal: captured.refusal } } try { await ctx.journal.appendSubmission( - { ...input, options: captured.options, fence: ctx.fence, handoverRecorded: true }, + { ...submission, options: captured.options, fence: ctx.fence, handoverRecorded: true }, undefined, ctx.operationReceipt ) 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 28270f04581b..025ee993485f 100644 --- a/src/main/runtime/claude-structured-retired-saved-model.test.ts +++ b/src/main/runtime/claude-structured-retired-saved-model.test.ts @@ -63,7 +63,8 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { 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')) + const reply = modelNotFoundReply('reply-1') + claude.child(SESSION).handlers.onMessage?.(reply) await host.flushStreamedEvents(SESSION) expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({ model: RETIRED, @@ -88,6 +89,18 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { ) const options = structuredAgentSessionSendSelection(reopened) expect(options).toEqual({ model: answer.current.model, effort: 'high' }) + // Source-derived CLI 2.1.302 failed root result; retry starts after it ends. + claude.child(SESSION).handlers.onMessage?.({ + type: 'result', + uuid: 'result-1', + session_id: claudeSessionIdForOrcaSession(SESSION), + parent_tool_use_id: null, + subtype: 'success', + is_error: true, + terminal_reason: 'api_error', + result: reply.message.content[0]!.text + }) + await host.flushStreamedEvents(SESSION) const dispatch = vi.spyOn(host.deps.adapter, 'dispatch') const setModel = vi.spyOn(claude.child(SESSION).connection, 'setModel') const body = hostTestMessage('try the replacement') diff --git a/src/main/runtime/orchestration/send-agent-turn.ts b/src/main/runtime/orchestration/send-agent-turn.ts index b9ffb3a5672e..9d4539345af3 100644 --- a/src/main/runtime/orchestration/send-agent-turn.ts +++ b/src/main/runtime/orchestration/send-agent-turn.ts @@ -33,6 +33,8 @@ export type StructuredSessionTurn = { delivery: AgentTurnDelivery /** Host-local admission for an automated send. */ beforeRun?: () => void + /** Host-local: a background notice carries no changed user choices. */ + keepCurrentOptions?: true } export type StructuredSessionTurnSend = { @@ -138,7 +140,7 @@ async function sendStructuredSessionTurn( }) const result = await send.host.send( { callerKey: send.callerKey }, - turn.beforeRun ? { ...message, beforeRun: turn.beforeRun } : message + { ...message, beforeRun: turn.beforeRun, keepCurrentOptions: turn.keepCurrentOptions } ) if (!result.ok) { return { kind: 'refused', refusal: result.refusal } diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index 4a87a084597e..29b264159042 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -113,6 +113,7 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo body: input.body, // A person's plain send: every agent takes it the way it takes their own mid-turn message. delivery: 'now', + keepCurrentOptions: true, operationId: input.operationId, expectedRuntimeFence: input.expectedRuntimeFence, beforeRun: () => { diff --git a/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts b/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts index 7c42f7208961..d92220b5787b 100644 --- a/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts +++ b/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts @@ -1,8 +1,15 @@ import { mkdtemp, rm } from 'node:fs/promises' +import { readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { codexScriptedChild } from '../codex/codex-scripted-child.test-fixture' +import { createCodexAppServerRecordDispatcher } from '../codex/codex-app-server-record-dispatch' +import { readCodexThreadId, readCodexTurnId } from '../codex/codex-structured-thread-facts' +import * as turnOptions from '../native-chat/agent-session-wire/structured-agent-session-message-options' +import { createStructuredMailboxPointerHost } from './orchestration/structured-mailbox-pointer-host' +import { structuredMailSource } from './orchestration/structured-mail-source' +import { scriptedCodexTransport } from './scripted-codex-transport.test-fixture' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import { HOST_TEST_SESSION, @@ -33,12 +40,199 @@ function envelope(method: string, fence: number, fields: Record } } afterEach(async () => { + vi.restoreAllMocks() await stopStructuredAgentSessionRuntime() if (root) { await rm(root, { recursive: true, force: true }) } }) +it('delivers current-choice mail during a recorded Codex turn while changed-choice input waits for completion', async () => { + // Recorded real-binary lifecycle, with a faked upstream provider; original null clientIds stay intact. + const frames: { case: string; method: string; params: Record }[] = readFileSync( + join(__dirname, '../codex/__fixtures__/codex-app-server-turn-endings.jsonl'), + 'utf8' + ) + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + .filter((frame) => frame.case === '0.158.0-control') + const threadId = readCodexThreadId(frames[0]?.params)! + const turnId = readCodexTurnId(frames.find(({ method }) => method === 'turn/started')?.params)! + const oldChoices = { model: 'scripted-model', effort: 'low' } + const capture = { model: 'scripted-alt-model', effort: 'high' } + let starts = 0 + const child = codexScriptedChild({ + 'thread/start': () => ({ thread: { id: threadId }, model: oldChoices.model }), + 'turn/start': () => ({ turn: { id: ++starts === 1 ? turnId : 'scripted-next-turn' } }), + 'turn/steer': () => ({ turnId }), + 'model/list': () => ({ + data: [ + { + model: oldChoices.model, + isDefault: true, + supportedReasoningEfforts: [{ reasoningEffort: 'low' }] + }, + { model: capture.model, supportedReasoningEfforts: [{ reasoningEffort: 'high' }] } + ], + nextCursor: null + }) + }) + child.holdHandshakes = false + const open = child.deps.openCodexConnection! + let dispatcher: ReturnType | undefined + const { host, fence } = await attachedHost({ + ...child, + deps: { + ...child.deps, + ...scriptedCodexTransport(async (launch, handlers = {}) => { + dispatcher = createCodexAppServerRecordDispatcher({ + handlers, + writeResponse: () => {}, + onProtocolFailure: (error) => { + throw error + } + }) + return open(launch, handlers) + }) + } + }) + const dispatch = vi.spyOn(host.deps.adapter, 'dispatch') + const waits = vi.spyOn(turnOptions, 'waitsForCodexTurnOptions') + const requests = () => + child.requests().filter(({ method }) => method === 'turn/start' || method === 'turn/steer') + const a = hostTestMessage('A on current choices') + const aEnvelope = envelope('agentSession.send', fence, { body: a, options: oldChoices }) + expect( + await host.send(caller, { envelope: aEnvelope, body: a, options: oldChoices }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(requests()).toHaveLength(1)) + expect(requests()[0]).toMatchObject({ method: 'turn/start', params: oldChoices }) + const idleAt = frames.findIndex( + ({ method, params }) => + method === 'thread/status/changed' && JSON.stringify(params.status) === '{"type":"idle"}' + ) + for (const frame of frames.slice(0, 4)) { + dispatcher!.dispatch({ method: frame.method, params: frame.params }) + } + // SCRIPTED correlation only: these are not recorded mail/user echoes or rewritten capture frames. + const echo = (clientId: string, rootTurnId = turnId) => + dispatcher!.dispatch({ + method: 'item/completed', + params: { + threadId, + turn: { id: rootTurnId }, + item: { type: 'userMessage', id: `scripted-echo-${clientId}`, clientId, content: [] } + } + }) + echo(aEnvelope.clientOperationId) + await host.flushStreamedEvents(HOST_TEST_SESSION) + expect(host.currentWork(HOST_TEST_SESSION)?.activeTurnId()).toBe(turnId) + for (const [key, value] of Object.entries(capture)) { + expect( + await host.setOption(caller, { + envelope: envelope('agentSession.setOption', fence, { key, value }), + key, + value + }) + ).toMatchObject({ ok: true }) + } + const mail = { + ...hostTestMessage('worker mail on current choices'), + from: structuredMailSource({ + db: null, + mailboxHandle: 'dispatch:recorded-contract', + dispatchId: 'recorded-contract', + batch: [], + senderName: () => null + }) + } + const mailId = envelope('mail', fence, {}).clientOperationId + const sentMail = createStructuredMailboxPointerHost().send({ + sessionId: HOST_TEST_SESSION, + dispatchId: 'recorded-contract', + operationId: mailId, + expectedRuntimeFence: fence, + body: mail, + holdWhileWorking: false + }) + await vi.waitFor(() => expect(requests()).toHaveLength(2)) + expect(requests()[1]).toMatchObject({ + method: 'turn/steer', + params: { + expectedTurnId: turnId, + clientUserMessageId: mailId, + input: [{ type: 'text', text: 'worker mail on current choices' }] + } + }) + expect(requests()[1]?.params).not.toHaveProperty('model') + expect(requests()[1]?.params).not.toHaveProperty('effort') + const mailDispatch = dispatch.mock.calls.find(([input]) => input.clientMessageId === mailId)![0] + expect(mailDispatch.executionOptions).toBeUndefined() + expect( + waits.mock.calls.some( + ([, , next], index) => + next.clientMessageId === mailId && + next.options === undefined && + waits.mock.results[index]?.value === false + ) + ).toBe(true) + echo(mailId) + expect(await sentMail).toEqual({ kind: 'sent', state: 'accepted' }) + const b = hostTestMessage('ordinary B with captured choices') + const bEnvelope = envelope('agentSession.send', fence, { body: b, options: capture }) + const bId = bEnvelope.clientOperationId + expect(await host.send(caller, { envelope: bEnvelope, body: b, options: capture })).toMatchObject( + { ok: true } + ) + await vi.waitFor(() => + expect( + waits.mock.calls.some( + ([, , next], index) => + next.clientMessageId === bId && waits.mock.results[index]?.value === true + ) + ).toBe(true) + ) + for (const frame of frames.slice(4, idleAt + 1)) { + dispatcher!.dispatch({ method: frame.method, params: frame.params }) + } + await host.flushStreamedEvents(HOST_TEST_SESSION) + expect(host.currentWork(HOST_TEST_SESSION)?.activeTurnId()).toBe(turnId) + expect(requests()).toHaveLength(2) + for (const frame of frames.slice(idleAt + 1)) { + dispatcher!.dispatch({ method: frame.method, params: frame.params }) + } + await vi.waitFor(() => expect(requests()).toHaveLength(3)) + expect(requests()[2]).toMatchObject({ + method: 'turn/start', + params: { + ...capture, + clientUserMessageId: bId, + input: [{ type: 'text', text: 'ordinary B with captured choices' }] + } + }) + expect(dispatch.mock.calls.filter(([input]) => input.clientMessageId === bId)).toHaveLength(1) + expect( + dispatch.mock.calls.find(([input]) => input.clientMessageId === bId)![0].executionOptions + ).toEqual(capture) + echo(bId, 'scripted-next-turn') + await vi.waitFor(async () => { + const { submissions } = await host.journalSnapshot(HOST_TEST_SESSION) + expect(submissions.find(({ clientMessageId }) => clientMessageId === bId)).toMatchObject({ + options: capture, + dispatchState: 'accepted' + }) + expect(submissions.find(({ clientMessageId }) => clientMessageId === mailId)).toMatchObject({ + dispatchState: 'accepted' + }) + expect( + submissions.find(({ clientMessageId }) => clientMessageId === mailId)?.options + ).toBeUndefined() + }) + expect(child.spawns()).toBe(1) + expect(child.closes()).toBe(0) +}) + async function attachedHost(child: ScriptedAgentChild) { root = await mkdtemp(join(tmpdir(), 'orca-scripted-codex-')) const host = await ensureStructuredAgentSessionHost({ diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts index 0b38825b0c70..b87edc21c82b 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts @@ -42,7 +42,8 @@ const NOTICE_PIECES: readonly AgentSessionFailureCopyId[] = [ 'waitForBackgroundTasks', 'agentStarting', 'waitForStart', - 'agentStillWorking' + 'agentStillWorking', + 'waitForTurn' ] // Kana, and kanji whose simplified Chinese form differs (続 is 续, 読 is 读, ...). const JAPANESE_ONLY = /[\u3040-\u30ff続読変済図気帰戻検択転権単圧応対発処実証覧関専]/u diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index b1214aaa2000..2c2c5f7ce7a7 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -235,6 +235,8 @@ const PIECES: Record translate('components.native-chat.writeNotice.agentStillWorking', COPY.agentStillWorking), + waitForTurn: () => + translate('components.native-chat.writeNotice.waitForTurn', COPY.waitForTurn), runCommandWhenDone: (values) => translate( 'components.native-chat.failureWords.runCommandWhenDone', 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 9599bc547492..d8c275c6de5f 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 e18fc5faa357..8899a435c817 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 754aed17ba74..a686490b5632 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 02b65dbf0cf9..995212d5c568 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/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 1c935d6d911d..4089862449f9 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -17,6 +17,7 @@ export const WAIT_FOR_BACKGROUND_TASKS = 'Wait for the background tasks to finis export const AGENT_STARTING = 'The agent is still starting.' export const WAIT_FOR_START = 'Wait for the agent to finish starting.' export const AGENT_STILL_WORKING = 'The agent is still working.' +export const WAIT_FOR_TURN = 'Wait for the agent to finish responding, or stop it.' /** Whole translatable pieces; the host fills agent (name or theAgent) and command (conversation command name) in English. */ export const AGENT_SESSION_FAILURE_COPY = { @@ -129,6 +130,7 @@ export const AGENT_SESSION_FAILURE_COPY = { agentStarting: AGENT_STARTING, waitForStart: WAIT_FOR_START, agentStillWorking: AGENT_STILL_WORKING, + waitForTurn: WAIT_FOR_TURN, runCommandWhenDone: "Run /{{command}} when it's done.", commandAfterAnswer: "Answer the agent's question or approval, then run /{{command}}.", compactionFailed: 'Compaction failed.', diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index 3eb53859bc0a..b573638d85f0 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -375,3 +375,19 @@ describe('an attachment problem as a reader meets it', () => { ).toEqual({ kind: 'attachmentInvalid' }) }) }) + +it('keeps an unsettled ordinary send on the existing working and wait wording', () => { + expect( + agentSessionFailureSentence( + { + kind: 'restartFailed', + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'messagesUnsettled' } + } + }, + 'rejection', + { agentName: 'Claude' } + ) + ).toBe('The agent is still working. Wait for the agent to finish responding, or stop it.') +}) diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index c9bd5cd5189a..b065edbc6f08 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -19,10 +19,8 @@ import { } from './agent-session-failure-copy' import { START_REFUSAL_RESUMABLE } from './agent-session-start-refusal-resumable' import { providerRetryWords, withRetryCause } from './agent-session-provider-retry-words' -import { - usageLimitWords, - type AgentSessionUsageLimitResetWords -} from './agent-session-usage-limit-words' +import { usageLimitWords } from './agent-session-usage-limit-words' +import type { AgentSessionUsageLimitResetWords } from './agent-session-usage-limit-words' import { commandRefusedByReason } from './agent-session-command-refusal-words' import { attachmentProblemSentence } from './agent-session-attachment-failure-words' import { joinSentences } from './sentence-joining' @@ -125,7 +123,14 @@ function couldNot(verb: 'couldNotStart' | 'couldNotRestart'): Sentence { if (installationFailure) { return installationFailure } + if (!context.command && fact.refusal?.details?.reason === 'messagesUnsettled') { + return joinSentences([say('agentStillWorking'), say('waitForTurn')]) + } const failed = say(verb, agent(say, context)) + const blocked = commandRefusedByReason(say, context, fact) + if (blocked) { + return joinSentences([failed, blocked, ...startRetry(say, context)]) + } if (fact.argumentProblem) { const problemCopy = { unsupportedOption: 'argumentsUnsupportedOption', @@ -289,8 +294,7 @@ export function agentSessionFailureSentence( /** Desktop passes its translations; the host and the phone keep English. */ say: AgentSessionFailureSay = sayAgentSessionFailureEnglish ): string { - const sentence: Sentence = FAILURE_SENTENCES[fact.kind] - return sentence(context, fact, surface, say) + return FAILURE_SENTENCES[fact.kind](context, fact, surface, say) } /** Preserve markers released clients hide. A write failure keeps the bare marker; its error stays in the log. */ diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index 8a5b597e4113..a3ee95be20be 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -91,7 +91,7 @@ const REASON_WORDS = { turnActive: causeWords('turnActive', 'wait', 'waitForTurn'), promptPending: causeWords('promptPending', 'actFirst', 'answerFirst'), backgroundTasksRunning: causeWords('backgroundTasksRunning', 'wait', 'waitForBackgroundTasks'), - messagesUnsettled: causeWords('messagesUnsettled', 'actFirst', 'settleEarlierMessage'), + messagesUnsettled: causeWords('agentStillWorking', 'wait', 'waitForTurn'), queueTooLarge: causeWords('queueTooLarge', 'actFirst', 'shrinkQueue'), // The rewind control words its own refusals; anywhere else says only that it did not happen. rewindRefused: codeWords('hostFinding'), diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index 4ec1d82bc19c..4014f27da66a 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -4,6 +4,7 @@ import type { AgentSessionFailureFact } from './agent-session-failure' import { AGENT_STARTING, AGENT_STILL_WORKING, + WAIT_FOR_TURN, BACKGROUND_TASKS_RUNNING, QUIT_TERMINAL_AGENT, START_NEW_CHAT, @@ -62,7 +63,7 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { agentStarting: AGENT_STARTING, waitForStart: WAIT_FOR_START, turnActive: 'The agent is still responding.', - waitForTurn: 'Wait for the agent to finish responding, or stop it.', + waitForTurn: WAIT_FOR_TURN, promptPending: 'The agent is waiting for an answer to a question or approval.', answerFirst: 'Answer the question or approval first.', backgroundTasksRunning: BACKGROUND_TASKS_RUNNING, diff --git a/src/shared/structured-agent-session-permission-option.test.ts b/src/shared/structured-agent-session-permission-option.test.ts index be9bd4f7de5b..c920860a18d1 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,23 @@ 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) + it('offers all Claude modes regardless of catalog support, and shows the host mode as stated', () => { + for (const autoMode of [true, false, undefined]) { + const descriptor = permission(live('claude', { permissionMode: 'ask' }, autoMode)) + expect( + descriptor?.kind.type === 'select' && descriptor.kind.choices.map((c) => c.value) + ).toEqual(['ask', 'accept-edits', 'auto', 'full-access']) } - 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. 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 a191d8fae8d1..0d34d7ee7135 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 94cd2541e5ef..50ca090a8dda 100644 --- a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts +++ b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts @@ -199,17 +199,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() })