mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
fix(native-chat): route structured Codex options directly
This commit is contained in:
@@ -77,18 +77,22 @@ describe('useMobileStructuredSessionOptions', () => {
|
||||
controller = null
|
||||
})
|
||||
|
||||
it('uses only provider models and hydrates current model and effort', () => {
|
||||
it('unions seeded and provider models and hydrates current model and effort', () => {
|
||||
expect(sendRequest).toHaveBeenCalledWith('agentSession.options', { sessionId: 'mobile_1' })
|
||||
const model = controller!.snapshot.find((entry) => entry.id === 'model')
|
||||
expect(model?.kind).toEqual({
|
||||
type: 'select',
|
||||
currentValue: 'account-only',
|
||||
choices: [
|
||||
{ value: 'account-only', label: 'Account Only' },
|
||||
{ value: 'gpt-5.6-sol', label: 'GPT-5.6 Sol Live' },
|
||||
{ value: 'gpt-5.6-terra', label: 'GPT-5.6 Terra Live' }
|
||||
{ value: 'gpt-5.6-terra', label: 'GPT-5.6 Terra Live' },
|
||||
{ value: 'gpt-5.6-luna', label: 'GPT-5.6 Luna' },
|
||||
{ value: 'gpt-5.5', label: 'GPT-5.5' },
|
||||
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
|
||||
{ value: 'account-only', label: 'Account Only' }
|
||||
]
|
||||
})
|
||||
expect(model).not.toHaveProperty('action')
|
||||
expect(controller!.snapshot.find((entry) => entry.id === 'effort')).toMatchObject({
|
||||
kind: { currentValue: 'medium' },
|
||||
valueSource: 'reported'
|
||||
|
||||
@@ -96,6 +96,24 @@ function fakeCodex(): CodexScript {
|
||||
if (method === 'turn/start') {
|
||||
return { turn: { id: TURN } }
|
||||
}
|
||||
if (method === 'model/list') {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
model: 'gpt-live',
|
||||
displayName: 'GPT Live',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [
|
||||
{ reasoningEffort: 'medium', description: 'Balanced' },
|
||||
{ reasoningEffort: 'high', description: 'Deep reasoning' }
|
||||
],
|
||||
defaultReasoningEffort: 'medium',
|
||||
isDefault: true
|
||||
}
|
||||
],
|
||||
nextCursor: null
|
||||
}
|
||||
}
|
||||
return {}
|
||||
},
|
||||
notify: () => {},
|
||||
@@ -337,6 +355,31 @@ describe('a structured codex session over agentSession.*', () => {
|
||||
const stream = await subscribe('sub-1')
|
||||
expect(stream[0]).toMatchObject({ type: 'snapshot', sessionId: SESSION })
|
||||
|
||||
// ── options ─────────────────────────────────────────────────────────────
|
||||
const options = await call('agentSession.options', { sessionId: SESSION })
|
||||
expect(options).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
models: [{ id: 'gpt-live', defaultEffort: 'medium' }],
|
||||
current: { model: 'gpt-live' }
|
||||
}
|
||||
})
|
||||
await ok('agentSession.setOption', {
|
||||
envelope: envelope('agentSession.setOption', { key: 'model', value: 'gpt-live' }, fence),
|
||||
key: 'model',
|
||||
value: 'gpt-live'
|
||||
})
|
||||
await ok('agentSession.setOption', {
|
||||
envelope: envelope('agentSession.setOption', { key: 'effort', value: 'high' }, fence),
|
||||
key: 'effort',
|
||||
value: 'high'
|
||||
})
|
||||
expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({
|
||||
ok: true,
|
||||
result: { current: { model: 'gpt-live', effort: 'high' } }
|
||||
})
|
||||
expect(itemsOf(stream)).toEqual([])
|
||||
|
||||
// ── send ────────────────────────────────────────────────────────────────
|
||||
const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'list files' }] }
|
||||
const sent = await ok<{
|
||||
@@ -354,7 +397,12 @@ describe('a structured codex session over agentSession.*', () => {
|
||||
})
|
||||
expect(codex.live().calls.at(-1)).toMatchObject({
|
||||
method: 'turn/start',
|
||||
params: { threadId: THREAD, clientUserMessageId: sent.clientMessageId }
|
||||
params: {
|
||||
threadId: THREAD,
|
||||
clientUserMessageId: sent.clientMessageId,
|
||||
model: 'gpt-live',
|
||||
effort: 'high'
|
||||
}
|
||||
})
|
||||
|
||||
// ── stream ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from './native-chat-session-option-state'
|
||||
|
||||
export type NativeChatSessionOptionMode = 'draft' | 'live'
|
||||
export type NativeChatLiveOptionTransport = 'catalog' | 'agent-session'
|
||||
|
||||
function choiceWithCurrent(
|
||||
choices: readonly SessionOptionSelectChoice[],
|
||||
@@ -30,6 +31,7 @@ function choiceWithCurrent(
|
||||
|
||||
function settableState(args: {
|
||||
mode: NativeChatSessionOptionMode
|
||||
liveTransport: NativeChatLiveOptionTransport
|
||||
apply: { launchArgs?: unknown; composedIntoModel?: true; midSession?: CatalogMidSessionApply }
|
||||
composedModelApply?: { midSession?: CatalogMidSessionApply }
|
||||
}): Pick<SessionOptionDescriptor, 'settable' | 'disabledReason'> {
|
||||
@@ -38,6 +40,9 @@ function settableState(args: {
|
||||
? { settable: true }
|
||||
: { settable: false, disabledReason: 'available-after-session-start' }
|
||||
}
|
||||
if (args.liveTransport === 'agent-session') {
|
||||
return { settable: true }
|
||||
}
|
||||
if (args.apply.composedIntoModel && args.composedModelApply?.midSession?.kind === 'command') {
|
||||
return { settable: true }
|
||||
}
|
||||
@@ -50,9 +55,10 @@ function settableState(args: {
|
||||
function actionForApply(
|
||||
apply: { midSession?: CatalogMidSessionApply },
|
||||
tracked: TrackedNativeChatSessionOption | undefined,
|
||||
mode: NativeChatSessionOptionMode
|
||||
mode: NativeChatSessionOptionMode,
|
||||
liveTransport: NativeChatLiveOptionTransport
|
||||
): SessionOptionDescriptor['action'] {
|
||||
if (mode !== 'live') {
|
||||
if (mode !== 'live' || liveTransport === 'agent-session') {
|
||||
return undefined
|
||||
}
|
||||
if (apply.midSession?.kind === 'agent-picker') {
|
||||
@@ -67,12 +73,13 @@ function optionDescriptor(args: {
|
||||
option: CatalogOption
|
||||
tracked: TrackedNativeChatSessionOption | undefined
|
||||
mode: NativeChatSessionOptionMode
|
||||
liveTransport: NativeChatLiveOptionTransport
|
||||
modelIsCliDefault: boolean
|
||||
composedModelApply: AgentSessionOptionCatalog['modelApply']
|
||||
}): SessionOptionDescriptor | null {
|
||||
const { option, tracked, mode, modelIsCliDefault, composedModelApply } = args
|
||||
const action = actionForApply(option.apply, tracked, mode)
|
||||
const settable = settableState({ mode, apply: option.apply, composedModelApply })
|
||||
const { option, tracked, mode, liveTransport, modelIsCliDefault, composedModelApply } = args
|
||||
const action = actionForApply(option.apply, tracked, mode, liveTransport)
|
||||
const settable = settableState({ mode, liveTransport, apply: option.apply, composedModelApply })
|
||||
// Why: the launch only emits `values[id] ?? defaultValue` alongside a model flag, so
|
||||
// a draft names this option's value exactly when a model was picked. Under the CLI's
|
||||
// own default no flag is sent at all, and the CLI's unstated choice is not ours to name.
|
||||
@@ -195,8 +202,9 @@ export function buildNativeChatSessionOptionSnapshot(args: {
|
||||
record: NativeChatSessionOptionRecord
|
||||
mode: NativeChatSessionOptionMode
|
||||
modelLabel: string
|
||||
liveTransport?: NativeChatLiveOptionTransport
|
||||
}): SessionOptionDescriptor[] {
|
||||
const { catalog, models, record, mode, modelLabel } = args
|
||||
const { catalog, models, record, mode, modelLabel, liveTransport = 'catalog' } = args
|
||||
if (models.length === 0) {
|
||||
return []
|
||||
}
|
||||
@@ -212,7 +220,7 @@ export function buildNativeChatSessionOptionSnapshot(args: {
|
||||
const trackedModelId = typeof modelTracked?.value === 'string' ? modelTracked.value : null
|
||||
const defaultModelId = cliDefaultModelId(catalog, models, trackedModelId)
|
||||
const effectiveModelId = trackedModelId ?? defaultModelId
|
||||
const modelAction = actionForApply(catalog.modelApply, modelTracked, mode)
|
||||
const modelAction = actionForApply(catalog.modelApply, modelTracked, mode, liveTransport)
|
||||
const snapshot: SessionOptionDescriptor[] = [
|
||||
{
|
||||
id: 'model',
|
||||
@@ -224,7 +232,7 @@ export function buildNativeChatSessionOptionSnapshot(args: {
|
||||
choices: modelChoices
|
||||
},
|
||||
valueSource: modelTracked?.source ?? (defaultModelId ? 'default' : 'unknown'),
|
||||
...settableState({ mode, apply: catalog.modelApply }),
|
||||
...settableState({ mode, liveTransport, apply: catalog.modelApply }),
|
||||
...(modelAction ? { action: modelAction } : {})
|
||||
}
|
||||
]
|
||||
@@ -238,6 +246,7 @@ export function buildNativeChatSessionOptionSnapshot(args: {
|
||||
option,
|
||||
tracked: trackedValues[option.id],
|
||||
mode,
|
||||
liveTransport,
|
||||
modelIsCliDefault: effectiveModelId === defaultModelId,
|
||||
composedModelApply: catalog.modelApply
|
||||
})
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CODEX_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-claude-codex'
|
||||
import { buildNativeChatSessionOptionSnapshot } from './native-chat-session-option-snapshot'
|
||||
import { createNativeChatSessionOptionRecord } from './native-chat-session-option-state'
|
||||
import {
|
||||
applyStructuredAgentSessionOptions,
|
||||
createStructuredAgentSessionOptionState,
|
||||
structuredAgentSessionOptionSnapshot
|
||||
} from './structured-agent-session-options'
|
||||
|
||||
describe('structured agent session options', () => {
|
||||
it('projects native Codex selects while bridge Codex keeps its agent picker', () => {
|
||||
const state = applyStructuredAgentSessionOptions(
|
||||
createStructuredAgentSessionOptionState('codex'),
|
||||
CODEX_SESSION_OPTION_CATALOG,
|
||||
{
|
||||
models: [
|
||||
{
|
||||
id: 'account-model',
|
||||
label: 'Account Model',
|
||||
isDefault: true,
|
||||
defaultEffort: 'medium',
|
||||
efforts: [
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' }
|
||||
]
|
||||
}
|
||||
],
|
||||
current: { model: 'account-model', effort: 'medium' }
|
||||
}
|
||||
)
|
||||
|
||||
const structured = structuredAgentSessionOptionSnapshot(state)
|
||||
expect(structured.map((descriptor) => descriptor.id)).toEqual(['model', 'effort'])
|
||||
expect(structured[0]).toMatchObject({
|
||||
settable: true,
|
||||
kind: { type: 'select', currentValue: 'account-model' }
|
||||
})
|
||||
expect(structured[0]).not.toHaveProperty('action')
|
||||
expect(structured[1]).toMatchObject({
|
||||
settable: true,
|
||||
kind: { type: 'select', currentValue: 'medium' }
|
||||
})
|
||||
|
||||
const bridgeRecord = createNativeChatSessionOptionRecord('codex')
|
||||
bridgeRecord.model = { value: 'gpt-5.6-sol', source: 'reported' }
|
||||
const bridge = buildNativeChatSessionOptionSnapshot({
|
||||
catalog: CODEX_SESSION_OPTION_CATALOG,
|
||||
models: CODEX_SESSION_OPTION_CATALOG.models,
|
||||
record: bridgeRecord,
|
||||
mode: 'live',
|
||||
modelLabel: 'Model'
|
||||
})
|
||||
expect(bridge[0]).toMatchObject({ action: { type: 'agent-picker' } })
|
||||
expect(bridge.find((descriptor) => descriptor.id === 'effort')).toMatchObject({
|
||||
action: { type: 'agent-picker' }
|
||||
})
|
||||
})
|
||||
|
||||
it('unions catalog seeds with app-server models and retains the current unknown id', () => {
|
||||
const state = applyStructuredAgentSessionOptions(
|
||||
createStructuredAgentSessionOptionState('codex'),
|
||||
CODEX_SESSION_OPTION_CATALOG,
|
||||
{
|
||||
models: [
|
||||
{
|
||||
id: 'account-model',
|
||||
label: 'Account Model',
|
||||
isDefault: false,
|
||||
efforts: []
|
||||
}
|
||||
],
|
||||
current: { model: 'persisted-unknown' }
|
||||
}
|
||||
)
|
||||
const model = structuredAgentSessionOptionSnapshot(state)[0]
|
||||
expect(
|
||||
model.kind.type === 'select' ? model.kind.choices.map((choice) => choice.value) : []
|
||||
).toEqual([
|
||||
...CODEX_SESSION_OPTION_CATALOG.models.map((entry) => entry.id),
|
||||
'account-model',
|
||||
'persisted-unknown'
|
||||
])
|
||||
expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBe('persisted-unknown')
|
||||
})
|
||||
})
|
||||
@@ -33,20 +33,38 @@ function effortOption(model: AgentSessionOptionsResult['models'][number]): Catal
|
||||
}
|
||||
}
|
||||
|
||||
function discoveredModel(model: AgentSessionOptionsResult['models'][number]): CatalogModel {
|
||||
const effort = effortOption(model)
|
||||
return {
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
...(model.description ? { description: model.description } : {}),
|
||||
...(model.isDefault ? { isDefault: true } : {}),
|
||||
options: effort ? [effort] : []
|
||||
}
|
||||
}
|
||||
|
||||
export function structuredAgentSessionOptionCatalog(
|
||||
seed: AgentSessionOptionCatalog,
|
||||
result: AgentSessionOptionsResult
|
||||
): AgentSessionOptionCatalog {
|
||||
const models: CatalogModel[] = result.models.map((model) => {
|
||||
const effort = effortOption(model)
|
||||
return {
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
...(model.description ? { description: model.description } : {}),
|
||||
...(model.isDefault ? { isDefault: true } : {}),
|
||||
options: effort ? [effort] : []
|
||||
const discovered = new Map(result.models.map((model) => [model.id, discoveredModel(model)]))
|
||||
const models: CatalogModel[] = seed.models.map((seeded) => {
|
||||
const live = discovered.get(seeded.id)
|
||||
if (!live) {
|
||||
return seeded
|
||||
}
|
||||
discovered.delete(seeded.id)
|
||||
return live
|
||||
})
|
||||
models.push(...discovered.values())
|
||||
if (!models.some((model) => model.id === result.current.model)) {
|
||||
models.push({
|
||||
id: result.current.model,
|
||||
label: result.current.model,
|
||||
options: seed.unknownModelOptions ?? []
|
||||
})
|
||||
}
|
||||
return { ...seed, models, defaultModelIsCliDefault: true }
|
||||
}
|
||||
|
||||
@@ -85,7 +103,8 @@ export function structuredAgentSessionOptionSnapshot(
|
||||
models: state.catalog.models,
|
||||
record: state.record,
|
||||
mode: 'live',
|
||||
modelLabel: 'Model'
|
||||
modelLabel: 'Model',
|
||||
liveTransport: 'agent-session'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user