mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
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:
@@ -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>(
|
||||
|
||||
Reference in New Issue
Block a user