mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* fix: start a new Claude chat on the listed default when its remembered model is gone The remembered new-chat model was launched as-is even after Claude stopped offering it, so every new chat failed its first message. The host create and the desktop new-chat picker now derive the same replacement from the host's saved model list: the listed default, effort moved to the nearest level it offers. Explicit models (--model, held picks) run as given; with no saved list the remembered model is kept. * fix: read the host's model list only for a Claude create that needs it The check read host.deps before ruling out other agents and explicit models, so creates whose host carries no deps (and every non-Claude create) paid for a lookup they never use. * fix: picker derives the fallback only where the host would A held model runs as picked on the host, so the picker no longer derives an effort beside it. A cached list whose default marker was dropped for this workspace keeps the placeholder instead of naming its first row. * fix: the new-chat picker picks the replacement for a gone Claude model The host-side check read the saved list separately from the picker, so the two disagreed and a cold start never fired it. The desktop new-chat picker now picks the replacement it shows, once, through the existing pick path, so the first send runs it and the remembered selection heals. The host is back to main. * fix: decide a gone Claude model's replacement at send, from the list in hand The open-time auto-pick raced the chat's create and the list's arrival, so the first send could still launch the gone model. Now, like the common pattern, the picker shows the replacement for a model the host's loaded list lacks, and the send step applies it through the existing pick call, awaited, right before the message goes out. A failed pick never holds the send back. * fix: one verified list decides a gone model's replacement The picker, the send step and the option setter now all read the list the picker shows, less the row a session adds for its own unlisted model, so what is shown is what applies and an explicit pick from that list stays. A listing that is still running, failed, or absent keeps the model. The send step re-derives from the chat's state when it runs, so a second send behind one that already moved the model applies nothing. * fix: only rows a finished host listing names can replace a gone model A single verified flag outlived the catalog it described: live options built from the built-in fallback list, or a malformed or empty host answer, still authorized a replacement the host would refuse. The state now keeps the ids a finished, well-formed host listing names, and a replacement must be both shown in the picker and named there. * fix(native-chat): the send carries the composer's Claude model and effort Every composer send (desktop and phone) carries the model and effort the picker shows, as optional agentSession.send options, only to a host that advertises agent-session.send-options.v1. The host writes a carried value that differs from the chat's own through the existing pick path before accepting the message, so the turn starts on it and the chat records it. A queued card keeps its options and applies them when it goes out; a launch's create call carries the replacement for its first message. Replaces the separate awaited pick before each send. * chore: drop an unrelated lockfile change * refactor(native-chat): one helper names a send's control fields for client and host * ci: rerun against current main * fix(native-chat): a message runs on the model and effort it was sent with The carried selection is now stored with the message (its submission row, and a queued card's row) and applied through the shared option writer as the delivery loop hands the message over, or before the start it needs. A pick made after the message was accepted no longer changes the model it runs on. Direct sends, queue drains and Send now share that one apply point; the early apply before acceptance and the queue-only apply are gone. The picker and the send read one effective selection: the replacement for a model the host's finished listing lacks shows only for a host that takes a send's selection, the phone validates picks against what it shows, a send carries the default the composer shows, a resumed launch's first message gets the same resolution, and only the listing proves a row (no label heuristic). * docs(native-chat): a submission's options are the host's alone * fix(native-chat): an opening message keeps its model; a pending pick resolves like the picker The opening message a create sends now stores the model and effort the chat was created with, so a pick made before its handover no longer changes what it runs on, and a card kept after a restart keeps them. A pick on its way to the host resolves through the same option view the picker shows before a send carries it, so a gone model it names is replaced too; the send selection reads only the shown snapshot. The option writer builds its own record-and-publish context, so the message path no longer duplicates it, and the client and host share one function for a send's fingerprint fields. * ci: rerun against current main
304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { OMP_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-omp'
|
|
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 {
|
|
applyStructuredAgentSessionModelCatalog,
|
|
applyStructuredAgentSessionOptions,
|
|
createStructuredAgentSessionOptionState,
|
|
settleStructuredAgentSessionBuiltinCatalog,
|
|
structuredAgentSessionOptionSnapshot,
|
|
type StructuredAgentSessionOptionState
|
|
} from './structured-agent-session-options'
|
|
import { structuredAgentSessionOptionView } from './structured-agent-session-option-view'
|
|
|
|
function viewModel(
|
|
state: StructuredAgentSessionOptionState,
|
|
seed: Readonly<Record<string, string>> | undefined,
|
|
held: Readonly<Record<string, string>>
|
|
) {
|
|
const model = structuredAgentSessionOptionSnapshot(
|
|
structuredAgentSessionOptionView(state, seed, held, true)
|
|
).find((descriptor) => descriptor.id === 'model')
|
|
return model?.kind.type === 'select' ? model.kind.currentValue : undefined
|
|
}
|
|
|
|
describe('structured agent session options', () => {
|
|
it('keeps a missing model out of the picker and adopts a later report', () => {
|
|
const state = applyStructuredAgentSessionOptions(
|
|
createStructuredAgentSessionOptionState('omp'),
|
|
OMP_SESSION_OPTION_CATALOG,
|
|
{ models: [], current: { effort: 'off', confirmed: ['effort'] } }
|
|
)
|
|
expect(state.catalog?.models).toEqual([])
|
|
expect(structuredAgentSessionOptionSnapshot(state)).toEqual([])
|
|
expect(state.record.model).toBeUndefined()
|
|
|
|
const reported = applyStructuredAgentSessionOptions(state, OMP_SESSION_OPTION_CATALOG, {
|
|
models: [{ id: 'reported-model', label: 'Reported Model', isDefault: true, efforts: [] }],
|
|
current: { model: 'reported-model', confirmed: ['model'] }
|
|
})
|
|
expect(structuredAgentSessionOptionSnapshot(reported)[0]).toMatchObject({
|
|
valueSource: 'reported',
|
|
kind: {
|
|
currentValue: 'reported-model',
|
|
choices: [{ value: 'reported-model', label: 'Reported Model' }]
|
|
}
|
|
})
|
|
})
|
|
|
|
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',
|
|
liveTransport: 'catalog'
|
|
})
|
|
// Same catalog, same `dispatched` vocabulary — only the transport separates them.
|
|
expect(structured.every((descriptor) => descriptor.transport === 'agent-session')).toBe(true)
|
|
expect(bridge.every((descriptor) => descriptor.transport === 'catalog')).toBe(true)
|
|
expect(bridge[0]).toMatchObject({ action: { type: 'agent-picker' } })
|
|
expect(bridge.find((descriptor) => descriptor.id === 'effort')).toMatchObject({
|
|
action: { type: 'agent-picker' }
|
|
})
|
|
})
|
|
|
|
it('uses provider-scoped 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(['account-model', 'persisted-unknown'])
|
|
expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBe('persisted-unknown')
|
|
})
|
|
|
|
it('projects live options as directly settable descriptors', () => {
|
|
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 snapshot = structuredAgentSessionOptionSnapshot(state)
|
|
expect(snapshot.map((descriptor) => descriptor.id)).toEqual(['model', 'effort'])
|
|
expect(snapshot.every((descriptor) => descriptor.settable)).toBe(true)
|
|
expect(snapshot.every((descriptor) => descriptor.action === undefined)).toBe(true)
|
|
})
|
|
|
|
it('projects Fast mode only from positive session and model capability', () => {
|
|
const supported = applyStructuredAgentSessionOptions(
|
|
createStructuredAgentSessionOptionState('codex'),
|
|
CODEX_SESSION_OPTION_CATALOG,
|
|
{
|
|
models: [
|
|
{
|
|
id: 'account-model',
|
|
label: 'Account Model',
|
|
isDefault: true,
|
|
efforts: [],
|
|
supportsFastMode: true
|
|
}
|
|
],
|
|
fastModeSupport: { supported: true },
|
|
current: { model: 'account-model', fastMode: false, confirmed: ['fastMode'] }
|
|
}
|
|
)
|
|
expect(structuredAgentSessionOptionSnapshot(supported)).toContainEqual(
|
|
expect.objectContaining({
|
|
id: 'fastMode',
|
|
kind: { type: 'boolean', currentValue: false },
|
|
valueSource: 'reported',
|
|
settable: true
|
|
})
|
|
)
|
|
|
|
const absent = applyStructuredAgentSessionOptions(supported, CODEX_SESSION_OPTION_CATALOG, {
|
|
models: [
|
|
{
|
|
id: 'account-model',
|
|
label: 'Account Model',
|
|
isDefault: true,
|
|
efforts: [],
|
|
supportsFastMode: true
|
|
}
|
|
],
|
|
current: { model: 'account-model' }
|
|
})
|
|
expect(structuredAgentSessionOptionSnapshot(absent).map(({ id }) => id)).toEqual(['model'])
|
|
expect(absent.record.valuesByModel['account-model']?.fastMode).toBeUndefined()
|
|
})
|
|
|
|
it('offers one speed choice in place of the Fast toggle where the model lists speeds', () => {
|
|
const state = applyStructuredAgentSessionOptions(
|
|
createStructuredAgentSessionOptionState('codex'),
|
|
CODEX_SESSION_OPTION_CATALOG,
|
|
{
|
|
models: [
|
|
{
|
|
id: 'gpt-6.1-sol',
|
|
label: 'GPT-6.1 Sol',
|
|
isDefault: true,
|
|
efforts: [],
|
|
supportsFastMode: true,
|
|
serviceTiers: [
|
|
{ value: 'priority', label: 'Fast' },
|
|
{ value: 'ultrafast', label: 'Ultrafast' }
|
|
]
|
|
}
|
|
],
|
|
fastModeSupport: { supported: true },
|
|
current: { model: 'gpt-6.1-sol', serviceTier: 'ultrafast', confirmed: ['serviceTier'] }
|
|
}
|
|
)
|
|
const snapshot = structuredAgentSessionOptionSnapshot(state)
|
|
expect(snapshot.map(({ id }) => id)).toEqual(['model', 'serviceTier'])
|
|
expect(snapshot).toContainEqual(
|
|
expect.objectContaining({
|
|
id: 'serviceTier',
|
|
kind: {
|
|
type: 'select',
|
|
currentValue: 'ultrafast',
|
|
choices: [
|
|
{ value: 'default', label: 'Standard' },
|
|
{ value: 'priority', label: 'Fast' },
|
|
{ value: 'ultrafast', label: 'Ultrafast' }
|
|
]
|
|
},
|
|
valueSource: 'reported',
|
|
settable: true
|
|
})
|
|
)
|
|
})
|
|
|
|
it('renders Fast off but marked unreported when support is known and no value is', () => {
|
|
const state = applyStructuredAgentSessionOptions(
|
|
createStructuredAgentSessionOptionState('codex'),
|
|
CODEX_SESSION_OPTION_CATALOG,
|
|
{
|
|
models: [
|
|
{
|
|
id: 'account-model',
|
|
label: 'Account Model',
|
|
isDefault: true,
|
|
efforts: [],
|
|
supportsFastMode: true
|
|
}
|
|
],
|
|
fastModeSupport: { supported: true },
|
|
current: { model: 'account-model' }
|
|
}
|
|
)
|
|
|
|
// The switch has no third position, so the value resolves to the catalog's
|
|
// own `false`. `unknown` is what stops any surface calling that a default:
|
|
// `default` is unreachable in this lane (it is hardcoded `mode: 'live'`), and
|
|
// nothing here has reported the tier the thread is actually routing.
|
|
expect(structuredAgentSessionOptionSnapshot(state)).toContainEqual(
|
|
expect.objectContaining({
|
|
id: 'fastMode',
|
|
kind: { type: 'boolean', currentValue: false },
|
|
valueSource: 'unknown'
|
|
})
|
|
)
|
|
})
|
|
|
|
it('shows the launch seed until the record names a model, and held picks over both', () => {
|
|
const cold = createStructuredAgentSessionOptionState('codex', CODEX_SESSION_OPTION_CATALOG)
|
|
const seeded = applyStructuredAgentSessionModelCatalog(
|
|
cold,
|
|
CODEX_SESSION_OPTION_CATALOG,
|
|
{
|
|
origin: 'probe',
|
|
models: [{ id: 'gpt-5.5', label: 'GPT-5.5', isDefault: true, efforts: [] }],
|
|
fetchedAt: 1
|
|
},
|
|
{ newLaunch: true }
|
|
)
|
|
const seed = { model: 'gpt-5.5' }
|
|
expect(viewModel(seeded, seed, {})).toBe('gpt-5.5')
|
|
// A saved pick the host's list does not name shows the quiet placeholder, never its raw id.
|
|
expect(
|
|
structuredAgentSessionOptionSnapshot(
|
|
structuredAgentSessionOptionView(seeded, { model: 'gpt-next' }, {}, true)
|
|
)
|
|
).toEqual([expect.objectContaining({ id: 'model', settable: false, valueSource: 'unknown' })])
|
|
// Before the host answers, and on the built-in list, no saved pick is named.
|
|
expect(viewModel(cold, seed, {})).toBeUndefined()
|
|
expect(viewModel(settleStructuredAgentSessionBuiltinCatalog(cold), seed, {})).toBeUndefined()
|
|
// Derived only: the record itself never takes the seed.
|
|
expect(seeded.record.model).toBeUndefined()
|
|
const live = applyStructuredAgentSessionOptions(seeded, CODEX_SESSION_OPTION_CATALOG, {
|
|
models: [{ id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', isDefault: true, efforts: [] }],
|
|
current: { model: 'gpt-5.6-luna', confirmed: ['model'] }
|
|
})
|
|
expect(viewModel(live, seed, {})).toBe('gpt-5.6-luna')
|
|
expect(viewModel(live, seed, { model: 'gpt-5.5' })).toBe('gpt-5.5')
|
|
expect(live.record.model?.value).toBe('gpt-5.6-luna')
|
|
})
|
|
})
|