Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
accepted,
optionsClient,
recordingMutate,
mountOptions
mountOptions,
settle
} from '../../use-mobile-structured-agent-options.test-fixture'
import { createElement, useState, type ReactNode } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
Expand Down Expand Up @@ -110,7 +111,11 @@ describe('MobileNativeChatSessionOptionPickers', () => {
const setOption = vi.fn<MobileNativeChatSessionOptionsController['setOption']>()
const invokeAction = vi.fn<MobileNativeChatSessionOptionsController['invokeAction']>()

const mount = (snapshot: SessionOptionDescriptor[], isWorking = false) => {
const mount = (
snapshot: SessionOptionDescriptor[],
isWorking = false,
requestedPage: string | null = null
) => {
const controller: MobileNativeChatSessionOptionsController = {
snapshot,
pendingId: null,
Expand All @@ -120,7 +125,7 @@ describe('MobileNativeChatSessionOptionPickers', () => {
}
function Pickers({ working }: { working: boolean }) {
const [presentation, setPresentation] = useState<MobileNativeChatPickerPresentation | null>(
null
requestedPage ? { page: requestedPage, refocus: false } : null
)
return createElement(MobileNativeChatSessionOptionPickers, {
controller,
Expand Down Expand Up @@ -179,6 +184,62 @@ describe('MobileNativeChatSessionOptionPickers', () => {
renderer = null
})

it('consumes a refused page without replay, then permits a fresh open and permission pick', async () => {
const placeholder: SessionOptionDescriptor = {
...MODEL_DESCRIPTOR,
kind: { type: 'select', choices: [] },
valueSource: 'unknown',
settable: false
}
const update = mount([placeholder], false, 'model')
expect(pill('Model').props.disabled).toBe(true)
act(() => pill('Model').props.onPress())
expect(findAllByHostType(renderer!.root, 'BottomDrawer')).toHaveLength(0)
update(false, [placeholder, PERMISSION_DESCRIPTOR])
expect(pill('Model').props.disabled).toBe(false)
expect(findAllByHostType(renderer!.root, 'BottomDrawer')).toHaveLength(0)
await act(async () => pill('Model').props.onPress())
await act(async () => rowByText('Permissions').props.onPress())
await act(async () => rowByText('Full access').props.onPress())
expect(setOption).toHaveBeenCalledExactlyOnceWith('permissionMode', 'full-access')
})

it('accepts a click on the shown Codex default, captures it, and skips an accepted equal pick', async () => {
const pending = deferred<AgentSessionOptionsResult>()
let reads = 0
const rpc = optionsClient(() =>
++reads === 1
? Promise.resolve({
...OPTIONS,
listedModelIds: OPTIONS.models.map((row) => row.id),
current: { model: 'gpt-fast', acceptedInputSources: { model: 'saved' }, defaulted: [] }
})
: pending.promise
)
const { calls, mutate } = recordingMutate(async () =>
accepted({ key: 'effort', value: 'low', options: { effort: 'low' } }, true)
)
const view = await mountOptions({ ...BASE, client: rpc.client, mutate, sendSelection: true })
const snapshot = () => [...view.current().optionSnapshot]
try {
const update = mount(snapshot())
expect(view.current().sendSelection()).toEqual({ selection: { model: 'gpt-fast' } })
await act(async () => pill('Model').props.onPress())
setOption.mockImplementation(view.current().setStructuredOption)
await act(async () => pill('Low').props.onPress())
await settle()
expect(calls.map((call) => call.fields)).toEqual([{ key: 'effort', value: 'low' }])
expect(view.current().sendSelection()).toEqual({
selection: { model: 'gpt-fast', effort: 'low' }
})
update(false, snapshot())
await act(async () => pill('Low').props.onPress())
expect(setOption).toHaveBeenCalledTimes(1)
} finally {
await view.unmount()
}
})

it('renders actual settled permission/reset picks without busy state and locks ordinary pending model work', async () => {
const idle = deferred<AgentSessionOptionsResult>()
let reads = 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,20 @@ export function MobileNativeChatSessionOptionPickers({
wasOpen.current = open
}, [open, controller.optionSurface])
const { snapshot, pendingId } = controller
const hasControls = snapshot.some((descriptor) => descriptor.settable)
const canOpen = !sendInFlight && pendingId === null && hasControls
useEffect(() => {
if (open && !hasControls) {
onNavigate(null)
}
}, [open, hasControls, onNavigate])
const model = snapshot.find((descriptor) => descriptor.category === 'model')
const permission = snapshot.find(
(descriptor) => descriptor.id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID
)
const activeDescriptor = snapshot.find((descriptor) => descriptor.id === presentation?.page)
const activeDescriptor = hasControls
? snapshot.find((descriptor) => descriptor.id === presentation?.page)
: undefined
const permissionView = activeDescriptor !== undefined && activeDescriptor === permission
const showWorkingNotice =
(model !== undefined || permission !== undefined) &&
Expand Down Expand Up @@ -171,6 +180,7 @@ export function MobileNativeChatSessionOptionPickers({
if (
descriptor !== permission &&
descriptor.valueSource !== 'unknown' &&
descriptor.valueSource !== 'default' &&
descriptor.kind.type === 'select' &&
descriptor.kind.currentValue === value
) {
Expand Down Expand Up @@ -218,8 +228,11 @@ export function MobileNativeChatSessionOptionPickers({
value: primary
})
}
disabled={busy || (!model && permission?.settable === false)}
disabled={!canOpen}
onPress={() => {
if (!canOpen) {
return
}
onDrawerOpen(home.id)
Keyboard.dismiss()
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,13 @@ describe('useMobileNativeChatSessionOptions', () => {
expect(model.kind).toMatchObject({
type: 'select',
currentValue: 'deepseek/deepseek-v4-pro',
choices: [{ value: 'deepseek/deepseek-v4-pro', label: 'deepseek/deepseek-v4-pro' }]
choices: [
{
value: 'deepseek/deepseek-v4-pro',
label: 'Deepseek Deepseek V4 Pro',
description: 'deepseek/deepseek-v4-pro'
}
]
})
})

Expand Down Expand Up @@ -300,7 +306,9 @@ describe('useMobileNativeChatSessionOptions', () => {
action: { type: 'agent-picker' },
kind: {
currentValue: 'gpt-6.1-sol',
choices: expect.arrayContaining([{ value: 'gpt-6.1-sol', label: 'gpt-6.1-sol' }])
choices: expect.arrayContaining([
{ value: 'gpt-6.1-sol', label: 'GPT 6.1 Sol', description: 'gpt-6.1-sol' }
])
}
})
const snapshot = api!.snapshot
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ describe('mobile OMP model discovery and picker choices', () => {
currentValue: reportedModel,
choices: [
expect.objectContaining({ value: 'provider/new', label: 'New model' }),
{ value: reportedModel, label: reportedModel }
{ value: reportedModel, label: 'Custom Current', description: reportedModel }
]
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
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 { deferred } from './use-mobile-structured-agent-options.test-fixture'

const HOST_LIST: AgentSessionModelCatalogResult = {
origin: 'probe',
Expand Down Expand Up @@ -42,7 +43,7 @@ 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?: 'pi' | 'codex' }
type Props = { rpc: RpcClient; fence: number; agent?: string }

async function mount(initial: Props) {
const frames: string[][] = []
Expand Down Expand Up @@ -127,6 +128,47 @@ describe('the phone picker’s first frame', () => {
await harness.unmount()
})

it('keeps unknown options current/permission and paints a later catalog while the recovery read waits', async () => {
const listing = deferred<AgentSessionModelCatalogResult>()
const recovery = deferred<AgentSessionOptionsResult>()
const current = {
model: 'provider/saved',
effort: 'high',
permissionMode: 'ask',
defaulted: []
}
let catalogs = 0
let reads = 0
const rpc = client(
() =>
++catalogs === 1
? Promise.resolve({ origin: 'unknown', listingInProgress: true })
: listing.promise,
() =>
++reads === 1
? Promise.resolve({ models: [], modelsKnown: false, current })
: recovery.promise
)
const harness = await mount({ rpc, fence: 1, agent: 'omp' })
const descriptor = (id: string) => harness.snapshot().find((row) => row.id === id)
try {
expect(modelChoices(harness.snapshot())).toEqual(['provider/saved'])
await act(async () => listing.resolve(HOST_LIST))
expect(reads).toBe(2)
expect(modelChoices(harness.snapshot())).toEqual(['gpt-host', 'provider/saved'])
expect(descriptor('model')?.kind.currentValue).toBe('provider/saved')
expect(descriptor('effort')?.kind.currentValue).toBe('high')
expect(descriptor('permissionMode')?.kind.currentValue).toBe('ask')
// A completed empty options answer remains authoritative over later catalog refreshes.
await act(async () => recovery.resolve({ models: [], current }))
await act(async () => harness.options().optionSurface.demandModelCatalog?.())
expect(modelChoices(harness.snapshot())).toEqual(['provider/saved'])
expect(descriptor('permissionMode')?.kind.currentValue).toBe('ask')
} finally {
await harness.unmount()
}
})

it('keeps the host list through a new fence instead of falling back to the placeholder', async () => {
let reads = 0
const rpc = client(() => (reads++ === 0 ? Promise.resolve(HOST_LIST) : new Promise(() => {})))
Expand Down
28 changes: 16 additions & 12 deletions mobile/src/session/use-mobile-structured-agent-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,7 @@ describe('useMobileStructuredAgentOptions host catalog', () => {
await harness.unmount()
})

it('keeps a cold Codex picker usable on the built-in list and lands the listing in place', async () => {
it('keeps a cold Codex picker disabled until the host listing makes literal choices available', async () => {
const listed = deferred<AgentSessionModelCatalogResult>()
const catalog = vi.fn(async (params: unknown): Promise<AgentSessionModelCatalogResult> =>
typeof params === 'object' && params !== null && 'waitForListing' in params
Expand All @@ -486,28 +486,32 @@ describe('useMobileStructuredAgentOptions host catalog', () => {
)
const client = optionsClient(() => deferred<AgentSessionOptionsResult>().promise, catalog)
const { calls, mutate } = recordingMutate(async () =>
accepted({ key: 'model', value: '' }, true)
accepted({ key: 'model', value: 'grok-4' }, true)
)
const harness = await mountOptions({ ...BASE, client: client.client, mutate })
await settle()

const seeded = descriptorFor(harness.current().optionSnapshot, 'model')
expect(seeded?.settable).toBe(true)
const seedChoices = seeded?.kind.type === 'select' ? seeded.kind.choices : []
expect(seedChoices.length).toBeGreaterThan(0)
await act(async () => {
await harness.current().setStructuredOption('model', seedChoices[0]!.value)
expect(descriptorFor(harness.current().optionSnapshot, 'model')).toMatchObject({
settable: false,
valueSource: 'unknown',
kind: { type: 'select', choices: [] }
})
expect(calls).toHaveLength(1)
expect(calls).toEqual([])

listed.resolve(HOST_CATALOG)
await settle()
const model = descriptorFor(harness.current().optionSnapshot, 'model')
expect(model?.settable).toBe(true)
expect(model?.kind.type === 'select' && model.kind.choices.map((c) => c.value)).toEqual([
'grok-4',
seedChoices[0]!.value
'grok-4'
])
await act(async () => {
expect(await harness.current().setStructuredOption('model', 'grok-4')).toBe(true)
})
expect(calls).toEqual([
{ method: 'agentSession.setOption', fields: { key: 'model', value: 'grok-4' } }
])
expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe(seedChoices[0]!.value)
expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('grok-4')
await harness.unmount()
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,14 @@ describe('Claude effort default at rest', () => {
const resting = await readAtRest(new AgentModelCatalogStore(), record)

expect(resting.models).toEqual([
{ id: 'gpt-5.5', label: 'gpt-5.5', isDefault: false, efforts: [], effortsKnown: false }
{
id: 'gpt-5.5',
label: 'GPT 5.5',
description: 'gpt-5.5',
isDefault: false,
efforts: [],
effortsKnown: false
}
])
expect(pickerEffort(resting, 'codex')).toBe('high')
})
Expand Down
3 changes: 2 additions & 1 deletion src/main/codex/codex-structured-session-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
models: [
{
id: 'gpt-live',
label: 'gpt-live',
label: 'GPT Live',
description: 'gpt-live',
isDefault: false,
efforts: [],
effortsKnown: false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
AgentSessionModelOption,
AgentSessionOptionsResult
} from '../../../shared/agent-session-wire'
import { labelFromModelId } from '../../../shared/model-id-label'
import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry'

export type StructuredAgentSessionOptionProjection = {
Expand Down Expand Up @@ -36,7 +37,14 @@ export function structuredAgentSessionOptionModels<TModel extends AgentSessionMo
const models = [...catalog]
if (current && !models.some((entry) => entry.id === current)) {
models.push(
row({ id: current, label: current, isDefault: false, efforts: [], effortsKnown: false })
row({
id: current,
label: labelFromModelId(current),
description: current,
isDefault: false,
efforts: [],
effortsKnown: false
})
)
}
return models
Expand Down
Loading
Loading