From ebc55dbd1dc36cb69d9a357b77df2cdf5d1d620f Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:50:34 +0000 Subject: [PATCH] fix(mobile): wait for runtime readiness before option picks (#37626) * fix(mobile): wait for runtime readiness before option picks * test(mobile): type readiness probe RPC mock --- ...ructured-agent-options.first-frame.test.ts | 104 +++++++++++++++++- .../use-mobile-structured-agent-options.ts | 18 +-- .../use-mobile-structured-option-surface.ts | 17 +-- 3 files changed, 120 insertions(+), 19 deletions(-) diff --git a/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts b/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts index 774d915345c..ce9634987b9 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts @@ -6,6 +6,8 @@ import type { AgentSessionOptionsResult } from '../../../src/shared/agent-sessio import type { SessionOptionDescriptor } from '../../../src/shared/native-chat/input/native-chat-session-options' import type { RpcClient } from '../transport/rpc-client' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' +import { EMPTY_STRUCTURED_AGENT_SESSION } from '../../../src/shared/agent-session/structured-agent-session-reducer' +import { useMobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation' import { deferred } from './use-mobile-structured-agent-options.test-fixture' const HOST_LIST: AgentSessionModelCatalogResult = { @@ -41,20 +43,27 @@ function modelChoices(snapshot: readonly SessionOptionDescriptor[]): string[] { return model?.kind.type === 'select' ? model.kind.choices.map((choice) => choice.value) : [] } -type Props = { rpc: RpcClient; fence: number; agent?: string } +type Props = { rpc: RpcClient; fence: number | null; agent?: string; enabled?: boolean } async function mount(initial: Props) { const frames: string[][] = [] const effortFrames: (boolean | undefined)[] = [] let snapshot: readonly SessionOptionDescriptor[] = [] - const mutate = vi.fn().mockResolvedValue({ status: 'rejected' }) + const onSendError = vi.fn() let latest!: ReturnType function Probe(props: Props): null { + const mutate = useMobileStructuredAgentMutate({ + client: props.rpc, + sessionId: 'session-1', + enabled: props.enabled ?? true, + stateRef: { current: { ...EMPTY_STRUCTURED_AGENT_SESSION, fence: props.fence } }, + onSendError + }) const options = useMobileStructuredAgentOptions({ agent: props.agent ?? 'codex', client: props.rpc, sessionId: 'session-1', - enabled: true, + enabled: props.enabled ?? true, fence: props.fence, mutate, turnId: null, @@ -80,7 +89,7 @@ async function mount(initial: Props) { effortFrames, snapshot: () => snapshot, options: () => latest, - mutate, + onSendError, update: (next: Props) => act(async () => { renderer?.update(createElement(Probe, next)) @@ -90,6 +99,93 @@ async function mount(initial: Props) { } describe('the phone picker’s first frame', () => { + it('shows catalog choices but takes picks only after the runtime fence arrives', async () => { + const models = (HOST_LIST.models ?? []).map((model) => ({ + ...model, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + })) + const rpc = client( + async () => ({ ...HOST_LIST, models }), + async () => ({ + models, + listedModelIds: models.map((model) => model.id), + current: { model: 'gpt-host', effort: 'high', permissionMode: 'ask' } + }) + ) + const pick = deferred() + const send = rpc.sendRequest + rpc.sendRequest = vi.fn(async (method, params, config) => { + if (method === 'agentSession.setOption') { + await pick.promise + return { + id: 'pick', + ok: true, + result: { ok: true, value: { options: { model: 'gpt-host' } } }, + _meta: { runtimeId: 'r' } + } + } + return send(method, params, config) + }) + const harness = await mount({ rpc, fence: null }) + const locked = (snapshot: readonly SessionOptionDescriptor[]) => { + expect(snapshot.length).toBeGreaterThan(0) + for (const row of snapshot) { + expect(row).toMatchObject({ + settable: false, + disabledReason: 'available-after-session-start' + }) + } + } + try { + expect(modelChoices(harness.snapshot())).toEqual(['gpt-host']) + const requestsBeforePicks = vi.mocked(rpc.sendRequest).mock.calls.length + await act(async () => { + for (const [id, value] of [ + ['model', 'gpt-host'], + ['effort', 'high'], + ['permissionMode', 'full-access'] + ] as const) { + expect(await harness.options().setStructuredOption(id, value)).toBe(false) + locked((await harness.options().optionSurface.setOption(id, value)).snapshot) + } + }) + locked(harness.snapshot()) + locked(harness.options().optionSurface.getSnapshot()) + expect(rpc.sendRequest).toHaveBeenCalledTimes(requestsBeforePicks) + await harness.update({ rpc, fence: 3 }) + for (const id of ['model', 'effort', 'permissionMode']) { + expect(harness.snapshot().find((row) => row.id === id)?.settable).toBe(true) + } + let settled!: Promise<{ snapshot: SessionOptionDescriptor[] }> + await act(async () => { + settled = harness.options().optionSurface.setOption('model', 'gpt-host') + }) + expect(rpc.sendRequest).toHaveBeenCalledWith( + 'agentSession.setOption', + expect.objectContaining({ + envelope: expect.objectContaining({ expectedRuntimeFence: 3 }), + key: 'model', + value: 'gpt-host' + }), + expect.any(Object) + ) + await harness.update({ rpc, fence: 3, enabled: false }) + locked(harness.snapshot()) + await act(async () => { + pick.resolve() + locked((await settled).snapshot) + expect(await harness.options().setStructuredOption('model', 'gpt-host')).toBe(false) + locked((await harness.options().optionSurface.setOption('model', 'gpt-host')).snapshot) + }) + expect(harness.onSendError).not.toHaveBeenCalled() + } finally { + await harness.unmount() + } + }) + it('projects a restored Pi off to the precise supported selection without offering off', async () => { const row = { id: 'openai/gpt-5', diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 39614470c57..8a4bc63cdcd 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -22,6 +22,7 @@ import { createStructuredAgentSessionOptionsWriteOwner } from '../../../src/shared/agent-session/options/structured-agent-session-options-write-owner' import { useMobileStructuredOptionSurface } from './use-mobile-structured-option-surface' +import { lockedStructuredAgentSessionOptionSnapshot } from '../../../src/shared/agent-session/options/structured-agent-session-option-snapshot' const NO_COMMANDS: readonly AgentSessionConversationCommand[] = [] @@ -117,10 +118,13 @@ export function useMobileStructuredAgentOptions(args: { }), [controller, projectRuntimeReports, hostSupport?.permissionOwn] ) - const shown = structuredAgentSessionShownSnapshot(readOptionState()) - const optionSnapshot = legacyHostPermissions - ? [...shown, legacyHostPermissionDescriptor()] - : shown + const acceptsPicks = !!client && !!sessionId && enabled && fence !== null + const readOptionSnapshot = useCallback(() => { + const shown = structuredAgentSessionShownSnapshot(readOptionState()) + const snapshot = legacyHostPermissions ? [...shown, legacyHostPermissionDescriptor()] : shown + return acceptsPicks ? snapshot : lockedStructuredAgentSessionOptionSnapshot(snapshot) + }, [acceptsPicks, legacyHostPermissions, readOptionState]) + const optionSnapshot = readOptionSnapshot() const readWriteContext = () => { const currentState = projectRuntimeReports({ @@ -132,8 +136,8 @@ export function useMobileStructuredAgentOptions(args: { controller, record: optionStateRef.current.record, view, - snapshot: structuredAgentSessionShownSnapshot(view), - ready: !!client && !!sessionId, + snapshot: readOptionSnapshot(), + ready: acceptsPicks, providerStarting, isCurrentContext, flush: (isCurrent: () => boolean) => { @@ -188,7 +192,7 @@ export function useMobileStructuredAgentOptions(args: { optionSnapshot, demandModelCatalog, setStructuredOption, - readOptionState + readOptionSnapshot }) return { diff --git a/mobile/src/session/use-mobile-structured-option-surface.ts b/mobile/src/session/use-mobile-structured-option-surface.ts index 06934b5d51d..c1911efb81c 100644 --- a/mobile/src/session/use-mobile-structured-option-surface.ts +++ b/mobile/src/session/use-mobile-structured-option-surface.ts @@ -1,6 +1,4 @@ -import { useCallback, useMemo, useState } from 'react' -import { structuredAgentSessionShownSnapshot } from '../../../src/shared/agent-session/options/structured-agent-session-option-view' -import type { StructuredAgentSessionOptionState } from '../../../src/shared/agent-session/options/structured-agent-session-options' +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { SessionOptionDescriptor, SessionOptionsSurface, @@ -11,10 +9,13 @@ export function useMobileStructuredOptionSurface(args: { demandModelCatalog: () => void optionSnapshot: SessionOptionDescriptor[] setStructuredOption: (id: string, value: SessionOptionValue) => Promise - /** The latest option state, which a settled pick's snapshot is read from. */ - readOptionState: () => StructuredAgentSessionOptionState + readOptionSnapshot: () => SessionOptionDescriptor[] }) { - const { optionSnapshot, readOptionState, setStructuredOption, demandModelCatalog } = args + const { optionSnapshot, readOptionSnapshot, setStructuredOption, demandModelCatalog } = args + const snapshotRef = useRef(readOptionSnapshot) + useLayoutEffect(() => { + snapshotRef.current = readOptionSnapshot + }, [readOptionSnapshot]) const [optionPickerRequest, setOptionPickerRequest] = useState<{ id: string sequence: number @@ -34,9 +35,9 @@ export function useMobileStructuredOptionSurface(args: { const setOption = useCallback( async (id: string, value: SessionOptionValue) => { await setStructuredOption(id, value) - return { snapshot: structuredAgentSessionShownSnapshot(readOptionState()) } + return { snapshot: snapshotRef.current() } }, - [readOptionState, setStructuredOption] + [setStructuredOption] ) const optionSurface = useMemo(