From f037753ee3239ea855df6fb94cba6c36e017a129 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:19:11 -0800 Subject: [PATCH] Keep chat pickers usable while options recover (#37168) * Keep chat pickers usable while options recover * Show only the current process's reported permission and effort after a restart When a native chat's agent restarted, the first frame at the new process could still show the old process's reported permission (for example "Full access" on an Own chat) as pickable, then flip to unknown a moment later. A reported value now counts only while the options answer that carried it belongs to the current process, on desktop and phone. Also updates the restart tests: a pick made while the new process's options read is pending or failing goes to the new process on both desktop and phone. --- ...se-mobile-structured-agent-options.test.ts | 8 +- .../use-mobile-structured-agent-options.ts | 27 +--- ...red-agent-session-options-restart.test.tsx | 149 ++++++++++++++++++ ...e-mobile-structured-agent-session.test.tsx | 4 + .../use-mobile-structured-option-state.ts | 24 ++- .../use-mobile-structured-option-surface.ts | 5 +- ...e-structured-agent-session-option-state.ts | 15 +- ...agent-session-options.first-frame.test.tsx | 17 +- ...t-session-options.permission-mode.test.tsx | 25 ++- ...ed-agent-session-options.recovery.test.tsx | 81 ++++++++++ .../use-structured-agent-session-options.ts | 4 +- .../use-structured-agent-session.test.tsx | 5 +- .../structured-agent-session-options.ts | 10 ++ ...d-agent-session-options-read-owner.test.ts | 81 ++++++++++ ...ctured-agent-session-options-read-owner.ts | 22 +++ 15 files changed, 426 insertions(+), 51 deletions(-) create mode 100644 mobile/src/session/use-mobile-structured-agent-session-options-restart.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.recovery.test.tsx 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