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
This commit is contained in:
Kelvin Amoaba
2026-11-03 02:50:34 +00:00
committed by GitHub
parent 35accd210b
commit ebc55dbd1d
3 changed files with 120 additions and 19 deletions
@@ -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<typeof useMobileStructuredAgentOptions>
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<void>()
const send = rpc.sendRequest
rpc.sendRequest = vi.fn<RpcClient['sendRequest']>(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',
@@ -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 {
@@ -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<boolean>
/** 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<SessionOptionsSurface>(