diff --git a/mobile/src/session/use-mobile-structured-agent-options.test.ts b/mobile/src/session/use-mobile-structured-agent-options.test.ts index 60fb936b659..efb7183f694 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.test.ts @@ -711,7 +711,7 @@ it('phone captures accepted echoed inputs, preserves false, and freezes empty vs await harness.unmount() }) -it('refuses a typed literal at an unresolved fence and accepts it when that fence is ready with no menu', async () => { +it('accepts a typed literal at an attached fence while options refresh with no menu', async () => { const pending = deferred() let reads = 0 const client = optionsClient(() => @@ -726,15 +726,15 @@ it('refuses a typed literal at an unresolved fence and accepts it when that fenc await act(async () => { result = await view.current().setStructuredOption('model', 'literal') }) - expect(result).toBe(false) - expect(calls).toHaveLength(0) + expect(result).toBe(true) + expect(calls).toHaveLength(1) await act(async () => pending.resolve({ models: [], current: {} })) await settle() await act(async () => { result = await view.current().setStructuredOption('model', 'literal') }) expect(result).toBe(true) - expect(calls).toHaveLength(1) + expect(calls).toHaveLength(2) await view.unmount() }) diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 2f6bddd012e..f3b061758d1 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -4,7 +4,6 @@ import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { isAgentSessionHandleProvider } from '../../../src/shared/agent-session/lifecycle/agent-session-provider-handle' import { structuredAgentSessionSeedCatalog } from '../../../src/shared/agent-session/options/structured-agent-session-seed-catalog' import type { AgentSessionOptionResult } from '../../../src/shared/agent-session/agent-session-wire' -import { lockedStructuredAgentSessionOptionSnapshot } from '../../../src/shared/agent-session/options/structured-agent-session-option-snapshot' import { structuredAgentSessionOptionView, structuredAgentSessionSendSelection, @@ -57,7 +56,7 @@ export function useMobileStructuredAgentOptions(args: { ) // An options answer made before the account's listing landed listed none of it. const { - projectPermission, + projectRuntimeReports, optionState, optionStateRef, activeOptionRecordRef, @@ -65,7 +64,6 @@ export function useMobileStructuredAgentOptions(args: { updateOptionState, conversationSupport, imageSupport, - awaitingOptions, receiveCatalog, isCurrentContext, controller, @@ -107,19 +105,9 @@ export function useMobileStructuredAgentOptions(args: { imageSupport?.sessionId === sessionId && !imageSupport.statesPermissionMode && optionState.permissionMode === null - const interactionReadyRef = useRef(!awaitingOptions) - const lockSnapshotRef = useRef( - (snapshot: ReturnType) => snapshot - ) - const lockSnapshot = (snapshot: ReturnType) => - awaitingOptions ? lockedStructuredAgentSessionOptionSnapshot(snapshot) : snapshot - useLayoutEffect(() => { - interactionReadyRef.current = !awaitingOptions - lockSnapshotRef.current = lockSnapshot - }) const readOptionState = useCallback( () => - projectPermission({ + projectRuntimeReports({ ...structuredAgentSessionOptionView( optionStateRef.current, undefined, @@ -127,15 +115,15 @@ export function useMobileStructuredAgentOptions(args: { ), permissionOwnSupported: hostSupport ? hostSupport.permissionOwn === true : null }), - [controller, projectPermission, hostSupport?.permissionOwn] + [controller, projectRuntimeReports, hostSupport?.permissionOwn] ) - const shown = lockSnapshot(structuredAgentSessionShownSnapshot(readOptionState())) + const shown = structuredAgentSessionShownSnapshot(readOptionState()) const optionSnapshot = legacyHostPermissions ? [...shown, legacyHostPermissionDescriptor()] : shown const readWriteContext = () => { - const currentState = projectPermission({ + const currentState = projectRuntimeReports({ ...optionStateRef.current, permissionOwnSupported: hostSupport ? hostSupport.permissionOwn === true : null }) @@ -144,8 +132,8 @@ export function useMobileStructuredAgentOptions(args: { controller, record: optionStateRef.current.record, view, - snapshot: lockSnapshotRef.current(structuredAgentSessionShownSnapshot(view)), - ready: !awaitingOptions && !!client && !!sessionId && interactionReadyRef.current, + snapshot: structuredAgentSessionShownSnapshot(view), + ready: !!client && !!sessionId, providerStarting, isCurrentContext, flush: (isCurrent: () => boolean) => { @@ -199,7 +187,6 @@ export function useMobileStructuredAgentOptions(args: { useMobileStructuredOptionSurface({ optionSnapshot, demandModelCatalog, - lockSnapshot: (snapshot) => lockSnapshotRef.current(snapshot), setStructuredOption, readOptionState }) diff --git a/mobile/src/session/use-mobile-structured-agent-session-options-restart.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-options-restart.test.tsx new file mode 100644 index 00000000000..9945701b5f8 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session-options-restart.test.tsx @@ -0,0 +1,149 @@ +// A replacement process's options read can fail; the chat's known choices still reach that process. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' +import { + CAPABLE, + SESSION_ID, + ok, + snapshotEvent +} from './use-mobile-structured-agent-session-queued.test-fixture' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => undefined), + removeItem: vi.fn(async () => undefined) +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) + +let renderer: ReactTestRenderer | null = null +let hook: ReturnType | null = null +let listener: ((value: unknown) => void) | null = null +const sendRequest = vi.fn() +const client: RpcClient = { + sendRequest, + subscribe: vi.fn((_method, _params, onData) => { + if (_method === 'agentSession.subscribe') { + listener = onData + } + return vi.fn() + }), + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} +} + +const OPTIONS = { + models: ['gpt-fast', 'gpt-slow'].map((id, index) => ({ + id, + label: id, + isDefault: index === 0, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + })), + current: { model: 'gpt-fast', effort: 'low', permissionMode: 'ask' } +} + +// This host predates the model listing, so the options answer is the chat's only model list. +async function hostAnswer(method: string) { + return method === 'agentSession.modelCatalog' + ? { + id: 'request-1', + ok: false as const, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + } + : ok(method === 'agentSession.options' ? OPTIONS : {}) +} + +function Harness(): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId: SESSION_ID, + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected: true, + agent: 'codex', + hostSupport: { ...CAPABLE, permissionModes: true, permissionOwn: true }, + onSendError: vi.fn() + }) + return null +} + +const optionReads = () => + sendRequest.mock.calls.filter(([method]) => method === 'agentSession.options').length +const descriptor = (id: string) => hook!.optionSnapshot.find((entry) => entry.id === id) + +beforeEach(() => { + vi.clearAllMocks() + sendRequest.mockImplementation(hostAnswer) +}) + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + hook = null + listener = null +}) + +it('sends a pick on a restarted process to its new fence while its options reads fail', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotEvent({ fence: 3 }))) + await vi.waitFor(() => expect(descriptor('permissionMode')).toMatchObject({ settable: true })) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + const beforeRestart = optionReads() + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.options') { + throw new Error('request timed out') + } + return method === 'agentSession.setOption' + ? ok({ + ok: true, + replayed: false, + fence: 4, + cursor: { epoch: 'epoch-1', sequence: 2 }, + value: { key: 'model', value: 'gpt-slow', options: { model: 'gpt-slow' } } + }) + : hostAnswer(method) + }) + + act(() => listener?.(snapshotEvent({ fence: 4 }))) + // The replacement process's read and both retries fail; the chat's known choices stay usable. + await vi.waitFor(() => expect(optionReads()).toBe(beforeRestart + 3), { timeout: 3000 }) + for (const id of ['model', 'effort', 'permissionMode']) { + expect(descriptor(id), id).toMatchObject({ settable: true }) + } + await act(async () => { + expect(await hook!.setStructuredOption('model', 'gpt-slow')).toBe(true) + }) + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.setOption', + expect.objectContaining({ + envelope: expect.objectContaining({ sessionId: SESSION_ID, expectedRuntimeFence: 4 }), + key: 'model', + value: 'gpt-slow' + }), + expect.any(Object) + ) + // Only an accepted pick is remembered as the agent's choice. + expect(sendRequest).toHaveBeenCalledWith('settings.mutateNativeChatSessionOptions', { + type: 'apply-picks', + agent: 'codex', + picks: [{ modelId: 'gpt-slow', optionId: 'model', value: 'gpt-slow' }] + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.test.tsx index eb70f390b1a..4e95daa2b51 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session.test.tsx @@ -690,6 +690,10 @@ describe('useMobileStructuredAgentSession', () => { settable: true }) }) + // The picker is usable before fence 3's own read answers; only a read issued after the pick settles it. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) let reconcile!: (response: Awaited>) => void sendRequest.mockImplementation(async (method, params) => { if (method === 'agentSession.setOption') { diff --git a/mobile/src/session/use-mobile-structured-option-state.ts b/mobile/src/session/use-mobile-structured-option-state.ts index 769f40ff392..888804a636a 100644 --- a/mobile/src/session/use-mobile-structured-option-state.ts +++ b/mobile/src/session/use-mobile-structured-option-state.ts @@ -9,7 +9,10 @@ import { createStructuredAgentSessionOptionsReadOwner } from '../../../src/share import type { RpcClient } from '../transport/rpc-client' import type { AgentSessionOptionCatalog } from '../../../src/shared/agent-session/options/agent-session-option-catalog-types' import { usesInstalledAgentModelCatalog } from '../../../src/shared/agent-model-catalog-ownership' -import { createStructuredAgentSessionOptionState } from '../../../src/shared/agent-session/options/structured-agent-session-options' +import { + createStructuredAgentSessionOptionState, + structuredAgentSessionReportsAtFence +} from '../../../src/shared/agent-session/options/structured-agent-session-options' export function useMobileStructuredOptionState(args: { client: RpcClient | null @@ -53,14 +56,23 @@ export function useMobileStructuredOptionState(args: { sessionId ? feed?.getSnapshot().get(sessionId) : undefined ) const providerStarting = summary?.hostExecutionPhase === 'starting' - const projectPermission = (state: T): T => - projectRuntimePermission(state, summary, fence) const coherentCatalog = usesInstalledAgentModelCatalog(agent ?? '', hostCoherentCatalog) const [reader] = useState(() => createStructuredAgentSessionOptionsReadOwner( createStructuredAgentSessionOptionState(agent ?? 'codex', optionCatalog) ) ) + // An older fence's report is stale from the render its fence changes in, before re-attach clears it. + const projectRuntimeReports = < + T extends { reportedPermissionMode?: string | null; reportedEffort?: string } + >( + state: T + ): T => + projectRuntimePermission( + structuredAgentSessionReportsAtFence(state, reader.getSnapshot().support?.fence, fence), + summary, + fence + ) const { optionState, support } = useSyncExternalStore(reader.subscribe, reader.getSnapshot) const { optionStateRef, @@ -91,7 +103,6 @@ export function useMobileStructuredOptionState(args: { ? { conversationSupport: { sessionId: support.sessionId, - fence: support.fence, commands: support.result.conversationCommands ?? [] }, imageSupport: { @@ -103,8 +114,6 @@ export function useMobileStructuredOptionState(args: { : { conversationSupport: null, imageSupport: null }, [support] ) - const awaitingOptions = - conversationSupport?.sessionId === sessionId && conversationSupport.fence !== fence useEffect( () => reader.attach({ @@ -168,10 +177,9 @@ export function useMobileStructuredOptionState(args: { }, [readStamp, reread]) return { - projectPermission, + projectRuntimeReports, conversationSupport, imageSupport, - awaitingOptions, controller, reread, receiveCatalog, diff --git a/mobile/src/session/use-mobile-structured-option-surface.ts b/mobile/src/session/use-mobile-structured-option-surface.ts index 9031cc02f33..06934b5d51d 100644 --- a/mobile/src/session/use-mobile-structured-option-surface.ts +++ b/mobile/src/session/use-mobile-structured-option-surface.ts @@ -9,7 +9,6 @@ import type { export function useMobileStructuredOptionSurface(args: { demandModelCatalog: () => void - lockSnapshot: (snapshot: SessionOptionDescriptor[]) => SessionOptionDescriptor[] optionSnapshot: SessionOptionDescriptor[] setStructuredOption: (id: string, value: SessionOptionValue) => Promise /** The latest option state, which a settled pick's snapshot is read from. */ @@ -35,9 +34,9 @@ export function useMobileStructuredOptionSurface(args: { const setOption = useCallback( async (id: string, value: SessionOptionValue) => { await setStructuredOption(id, value) - return { snapshot: args.lockSnapshot(structuredAgentSessionShownSnapshot(readOptionState())) } + return { snapshot: structuredAgentSessionShownSnapshot(readOptionState()) } }, - [args.lockSnapshot, readOptionState, setStructuredOption] + [readOptionState, setStructuredOption] ) const optionSurface = useMemo( diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts index 2e54b09f88d..d6be5d8df4f 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts @@ -11,7 +11,10 @@ import type { AgentType } from '../../../../shared/agent-status-types' import type { AgentSessionOptionCatalog } from '../../../../shared/agent-session/options/agent-session-option-catalog-types' import { usesInstalledAgentModelCatalog } from '../../../../shared/agent-model-catalog-ownership' import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../../../shared/agent-session/structured-agent-session-surface-capabilities' -import type { StructuredAgentSessionOptionState } from '../../../../shared/agent-session/options/structured-agent-session-options' +import { + structuredAgentSessionReportsAtFence, + type StructuredAgentSessionOptionState +} from '../../../../shared/agent-session/options/structured-agent-session-options' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { structuredAgentSessionOptionView } from '../../../../shared/agent-session/options/structured-agent-session-option-view' import { @@ -209,13 +212,19 @@ export function useStructuredAgentSessionOptionState(args: { explicit?: Readonly> ) => ({ ...projectRuntimePermission( - structuredAgentSessionOptionView(state, seed, controller.overlay(), explicit), + structuredAgentSessionOptionView( + // An older fence's report is stale from the render its fence changes in, before re-attach clears it. + structuredAgentSessionReportsAtFence(state, reader.supportFor(owner)?.fence, fence), + seed, + controller.overlay(), + explicit + ), summary, fence ), permissionOwnSupported }), - [controller, summary, fence, permissionOwnSupported] + [reader, owner, controller, summary, fence, permissionOwnSupported] ) const takesSendOptions = useStructuredAgentSessionHostCapability( target, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx index 69c7d10948d..908e99e8467 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx @@ -460,7 +460,7 @@ describe('a new chat picker’s first frame', () => { }) }) -it('refuses typed literals while the current fence awaits options, then accepts with an empty catalog', async () => { +it('accepts typed literals at an attached fence while options refresh with an empty catalog', async () => { const pending = deferred() let reads = 0 mocks.call.mockImplementation((_target: unknown, method: string) => @@ -497,10 +497,21 @@ it('refuses typed literals while the current fence awaits options, then accepts view.rerender({ fence: 2 }) await waitFor(() => expect(reads).toBe(2)) await act(async () => - expect(await view.result.current.setStructuredOption('model', 'literal')).toBe(false) + expect(await view.result.current.setStructuredOption('model', 'literal')).toBe(true) ) expect(mutate).not.toHaveBeenCalled() - expect(mocks.call.mock.calls.filter((call) => call[1] === 'agentSession.setOption')).toEqual([]) + expect(mocks.call.mock.calls.filter((call) => call[1] === 'agentSession.setOption')).toEqual([ + [ + LOCAL_TARGET, + 'agentSession.setOption', + { + envelope: expect.objectContaining({ sessionId: 'locked-chat', expectedRuntimeFence: 2 }), + key: 'model', + value: 'literal' + }, + {} + ] + ]) await act(async () => pending.resolve({ models: [], current: {} })) await act(async () => expect(await view.result.current.setStructuredOption('model', 'literal')).toBe(true) 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 e7dee0035f8..ba152618f05 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 @@ -12,6 +12,7 @@ import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option- // @vitest-environment happy-dom import { act, renderHook, waitFor } from '@testing-library/react' +import { useLayoutEffect } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ call: vi.fn(), write: vi.fn() })) @@ -80,10 +81,14 @@ function permission(snapshot: readonly SessionOptionDescriptor[]): string | null return entry?.kind.type === 'select' ? entry.kind.currentValue : null } -function renderReopened(fence: number, agent: 'codex' | 'claude' = 'codex') { +function renderReopened( + fence: number, + agent: 'codex' | 'claude' = 'codex', + onCommit?: (snapshot: readonly SessionOptionDescriptor[]) => void +) { return renderHook( - (props: { fence: number; stamp: string }) => - useStructuredAgentSessionOptions({ + (props: { fence: number; stamp: string }) => { + const options = useStructuredAgentSessionOptions({ agent, sessionId: 'session-1', target: LOCAL_TARGET, @@ -95,7 +100,10 @@ function renderReopened(fence: number, agent: 'codex' | 'claude' = 'codex') { unloadedTurnRevisions: undefined, hostOptionStamp: props.stamp, mutate - }), + }) + useLayoutEffect(() => onCommit?.(options.optionSnapshot)) + return options + }, { initialProps: { fence, stamp: 'model-a|ask' } } ) } @@ -137,11 +145,18 @@ describe('the permission mode another device picks', () => { const answer = await read(...args) return { ...answer, current: { ...answer.current, reportedPermissionMode: 'full-access' } } }) - const { result, rerender, unmount } = renderReopened(1) + const committed: string[] = [] + const { result, rerender, unmount } = renderReopened(1, 'codex', (snapshot) => { + const entry = snapshot.find((descriptor) => descriptor.id === 'permissionMode') + committed.push(`${entry?.description}/${entry?.settable}`) + }) const row = () => result.current.optionSnapshot.find((entry) => entry.id === 'permissionMode') await waitFor(() => expect(row()).toMatchObject({ description: 'Full access', settable: true })) mocks.call.mockImplementation(() => new Promise(() => {})) + committed.length = 0 rerender({ fence: 2, stamp: 'model-a|own' }) + // Every frame of the new process, including the one before its read lease attaches. + expect([...new Set(committed)]).toEqual(['Permissions/false']) expect(row()).toMatchObject({ description: 'Permissions', settable: false }) unmount() }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.recovery.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.recovery.test.tsx new file mode 100644 index 00000000000..2b7153e2937 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.recovery.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment happy-dom +import { + mocks, + answer, + mutateWith, + ATTACHED, + renderOptions, + descriptor, + LIVE_OPTIONS, + SAVED_LIST, + tick +} from './use-structured-agent-session-options.test-fixture' +import { act, waitFor } from '@testing-library/react' +import { expect, it } from 'vitest' +import { forgetStructuredSessionSelection } from '@/lib/structured-agent-session-selection-controller' +import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import { + AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY, + AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY +} from '../../../../shared/agent-session/agent-session-permission-modes-capability' + +it('keeps known pickers writable after a restarted process options read fails', async () => { + forgetStructuredSessionSelection('session-1') + resetHostModelCatalogSnapshotsForTests() + mocks.call.mockReset() + mocks.pick.mockReset() + mocks.coherentCatalog = true + mocks.sendSelection = false + setLocalRuntimeCapabilitiesForTests([ + AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY, + AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY + ]) + const failed = Promise.withResolvers() + let reads = 0 + answer({ + modelCatalog: () => Promise.resolve(SAVED_LIST), + options: () => + ++reads === 1 + ? Promise.resolve({ + ...LIVE_OPTIONS, + models: LIVE_OPTIONS.models.map((model) => ({ + ...model, + efforts: [{ value: 'medium', label: 'Medium' }] + })), + current: { ...LIVE_OPTIONS.current, effort: 'medium', permissionMode: 'ask' } + }) + : failed.promise + }) + const { result, rerender, unmount } = renderOptions(ATTACHED, mutateWith(async () => null).mutate) + try { + await waitFor(() => + expect(result.current.sendSource.optionStateRef.current.optionsAnswerPresent).toBe(true) + ) + const beforeRestart = reads + rerender({ ...ATTACHED, fence: 2 }) + await act(async () => { + failed.reject(new Error('host timeout')) + await tick() + }) + await waitFor(() => expect(reads).toBe(beforeRestart + 3)) + for (const id of ['model', 'effort', 'permissionMode']) { + expect(descriptor(result.current.optionSnapshot, id)?.settable, id).toBe(true) + } + mocks.pick.mockResolvedValue({ + key: 'model', + value: 'gpt-5.6-luna', + options: { model: 'gpt-5.6-luna' } + }) + let applied = false + await act(async () => { + applied = await result.current.setStructuredOption('model', 'gpt-5.6-luna') + }) + expect(applied).toBe(true) + expect(mocks.pick).toHaveBeenCalled() + } finally { + unmount() + forgetStructuredSessionSelection('session-1') + setLocalRuntimeCapabilitiesForTests(null) + } +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index 3bee6b2f283..4dbab50122d 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -62,9 +62,7 @@ export function useStructuredAgentSessionOptions(args: StructuredAgentSessionOpt unloadedTurnRevisions: args.unloadedTurnRevisions }) - const awaitingOptions = support?.sessionId === sessionId && support.fence !== fence - const acceptsPicks = - (!transportEnabled || fence !== null) && launch?.seedPending !== true && !awaitingOptions + const acceptsPicks = (!transportEnabled || fence !== null) && launch?.seedPending !== true const hostCatalog = useHostModelCatalogUpgrade({ agent, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx index 5a2415b8bef..67f6880c99a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx @@ -375,11 +375,12 @@ describe('useStructuredAgentSession options', () => { }) fence = 4 rerender() - expect(result.current.optionSnapshot.every((entry) => !entry.settable)).toBe(true) - await act(async () => refreshedOptions.resolve(OPTIONS)) + // The replacement process's options read is still pending; the known model stays pickable. + expect(result.current.optionSnapshot.find((entry) => entry.id === 'model')?.settable).toBe(true) await act(async () => { expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(true) }) + await act(async () => refreshedOptions.resolve(OPTIONS)) const mutations = mocks.call.mock.calls.filter( ([, method]) => method === 'agentSession.setOption' diff --git a/src/shared/agent-session/options/structured-agent-session-options.ts b/src/shared/agent-session/options/structured-agent-session-options.ts index 6b9dd0c773b..f5b8a925f5b 100644 --- a/src/shared/agent-session/options/structured-agent-session-options.ts +++ b/src/shared/agent-session/options/structured-agent-session-options.ts @@ -240,6 +240,16 @@ function structuredAgentSessionControlsModelId( : null } +/** Runtime reports describe the process whose options answer carried them; a newer fence has none until it answers. */ +export function structuredAgentSessionReportsAtFence< + T extends Pick +>(state: T, answeredFence: number | null | undefined, fence: number | null): T { + return answeredFence === fence || + (state.reportedPermissionMode === undefined && state.reportedEffort === undefined) + ? state + : { ...state, reportedPermissionMode: undefined, reportedEffort: undefined } +} + export function commitStructuredAgentSessionOption( state: StructuredAgentSessionOptionState, id: string, diff --git a/src/shared/structured-agent-session-options-read-owner.test.ts b/src/shared/structured-agent-session-options-read-owner.test.ts index ca1b46c64ae..9e196d62331 100644 --- a/src/shared/structured-agent-session-options-read-owner.test.ts +++ b/src/shared/structured-agent-session-options-read-owner.test.ts @@ -234,3 +234,84 @@ it('clears account-switch evidence only on a current owner read failure and reta expect(reader.supportFor(owner)?.result.canSwitchAccount).toBe(true) close() }) + +it('retries a failed options read for a restarted process without an unrelated event', async () => { + vi.useFakeTimers() + const { reader, context, reads } = fixture() + let close = reader.attach(context) + try { + await tick() + reads[0].resolve(answer) + await tick() + close() + close = reader.attach({ ...context, reset: [2], fence: 2 }) + await tick() + reads[1].reject(new Error('host timeout')) + await tick() + await vi.advanceTimersByTimeAsync(1_000) + expect(reads).toHaveLength(3) + reads[2].resolve(answer) + await tick() + expect(reader.getSnapshot().support?.fence).toBe(2) + } finally { + close() + vi.useRealTimers() + } +}) + +it('bounds failed reads, wakes on invalidation, and cancels a detached lease retry', async () => { + vi.useFakeTimers() + const { reader, context, reads, controller } = fixture() + let close = reader.attach(context) + try { + await tick() + for (let attempt = 0; attempt < 3; attempt++) { + reads[attempt].reject(new Error('offline')) + await tick() + await vi.advanceTimersByTimeAsync(1_000) + } + expect(reads).toHaveLength(3) + await vi.advanceTimersByTimeAsync(60_000) + expect(reads).toHaveLength(3) + expect(reader.getSnapshot().support).toBeNull() + reader.invalidate() + await tick() + expect(reads).toHaveLength(4) + const pick = controller.issue('model', 'picked', null) + controller.settle(pick, { kind: 'accepted', options: { model: 'picked' } }) + reads[3].reject(new Error('offline')) + await tick() + await vi.advanceTimersByTimeAsync(250) + expect(reads).toHaveLength(5) + const newer = controller.issue('effort', 'high', 'picked') + controller.settle(newer, { kind: 'accepted', options: { effort: 'high' } }) + reads[4].resolve(answer) + await tick() + expect(reader.getSnapshot().support?.result).toBe(answer) + expect(controller.captureRead()).toEqual([newer.identity]) + expect(controller.overlay()).toEqual({ effort: 'high' }) + reader.invalidate() + await tick() + reads[5].reject(new Error('offline')) + await tick() + close() + close = reader.attach({ ...context, reset: [2], fence: 2 }) + await tick() + expect(reads).toHaveLength(7) + reads[6].resolve(answer) + await tick() + await vi.advanceTimersByTimeAsync(60_000) + expect(reads).toHaveLength(7) + expect(reader.getSnapshot().support?.fence).toBe(2) + reader.invalidate() + await tick() + reads[7].reject(new Error('offline')) + await tick() + close() + await vi.advanceTimersByTimeAsync(60_000) + expect(reads).toHaveLength(8) + } finally { + close() + vi.useRealTimers() + } +}) diff --git a/src/shared/structured-agent-session-options-read-owner.ts b/src/shared/structured-agent-session-options-read-owner.ts index 16bffccbcc8..7fd835c60e8 100644 --- a/src/shared/structured-agent-session-options-read-owner.ts +++ b/src/shared/structured-agent-session-options-read-owner.ts @@ -50,6 +50,12 @@ export function createStructuredAgentSessionOptionsReadOwner( let leases = 0 let version = 0 let dirty = true + let retries = 0 + let retryTimer: ReturnType | undefined + const cancelRetry = () => { + clearTimeout(retryTimer) + retryTimer = undefined + } const listeners = new Set<() => void>() const notify = () => listeners.forEach((listener) => listener()) const run = createCoalescingKeyedRunner() @@ -100,11 +106,24 @@ export function createStructuredAgentSessionOptionsReadOwner( } notify() } + if (isCurrentRead() && !dirty && retries < 2) { + retries++ + retryTimer = setTimeout(() => { + retryTimer = undefined + if (!isCurrentRead()) { + return + } + dirty = true + schedule() + }, 250) + } return } if (!isCurrentRead()) { return } + retries = 0 + cancelRetry() snapshot = { optionState: attached.optionCatalog ? applyStructuredAgentSessionOptions( @@ -127,6 +146,8 @@ export function createStructuredAgentSessionOptionsReadOwner( }).catch(() => undefined) } const invalidate = () => { + cancelRetry() + retries = 0 version++ dirty = true schedule() @@ -181,6 +202,7 @@ export function createStructuredAgentSessionOptionsReadOwner( invalidate() return () => { if (lease === attached) { + cancelRetry() lease = undefined version++ dirty = true