Files
orca/src/shared/structured-agent-session-options.ts
T
Brennan Benson 04b4e962a7 fix: a Claude model that's gone switches to the list's default when you send (#27826)
* 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
2026-10-13 21:21:47 -07:00

348 lines
13 KiB
TypeScript

import type { AgentSessionOptionCatalog, CatalogModel } from './agent-session-option-catalog'
import { discoveredModel } from './structured-agent-session-discovered-model'
import {
buildNativeChatSessionOptionSnapshot,
resolveEffectiveNativeChatModelId,
withTrackedNativeChatModel
} from './native-chat-session-option-snapshot'
import {
applyNativeChatReportedSessionOptions,
clearTrackedSessionOption,
createNativeChatSessionOptionRecord,
setTrackedSessionOption,
type NativeChatSessionOptionRecord
} from './native-chat-session-option-state'
import {
STRUCTURED_LAUNCH_OPTION_IDS,
STRUCTURED_LAUNCH_SEED_OPTION_IDS
} from './native-chat-session-option-defaults'
import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode'
import { structuredAgentSessionPermissionDescriptor } from './structured-agent-session-permission-option'
import type { SessionOptionDescriptor, SessionOptionValue } from './native-chat-session-options'
import type {
AgentSessionModelCatalogResult,
AgentSessionOptionsResult
} from './agent-session-wire'
import {
decodeStructuredAgentSessionOptionValue,
encodeStructuredAgentSessionOptionValue
} from './structured-agent-session-option-codec'
export function structuredAgentSessionOptionCatalog(
seed: AgentSessionOptionCatalog,
result: AgentSessionOptionsResult
): AgentSessionOptionCatalog {
const models: CatalogModel[] = result.models.map((model) =>
discoveredModel(model, result.fastModeSupport?.supported === true)
)
if (result.current.model && !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 }
}
export type StructuredAgentSessionOptionState = {
catalog: AgentSessionOptionCatalog | null
/** What produced `catalog`; a weaker source never replaces a stronger one. `seed` is the
* built-in list before the host answered (shown as the quiet placeholder); `builtin` is the
* same list once the host said it has none, usable but naming nothing the host may replace. */
catalogSource: 'seed' | 'builtin' | 'host' | 'live' | null
/** The models the host's latest answer names when it is a finished, well-formed listing: the
* only rows that can stand in for a model it lacks, whatever list the picker shows. */
verifiedModelIds?: readonly string[]
record: NativeChatSessionOptionRecord
/** The permission mode the host or launch last stated; null shows no picker. */
permissionMode: string | null
pendingId: string | null
}
/** With `seedCatalog`, the state holds the static seed from the first frame, but the picker shows
* the quiet placeholder until the host answers; every later source only upgrades it. */
export function createStructuredAgentSessionOptionState(
agent = 'codex',
seedCatalog?: AgentSessionOptionCatalog | null
): StructuredAgentSessionOptionState {
return {
catalog: seedCatalog ?? null,
catalogSource: seedCatalog ? 'seed' : null,
record: createNativeChatSessionOptionRecord(agent),
permissionMode: null,
pendingId: null
}
}
/** The host answered with no list (none saved, a failed read, or an older host): the built-in
* list becomes usable, still naming nothing until a host or session list does. */
export function settleStructuredAgentSessionBuiltinCatalog(
state: StructuredAgentSessionOptionState
): StructuredAgentSessionOptionState {
const settled = withVerifiedModelIds(state, undefined)
return settled.catalogSource === 'seed' ? { ...settled, catalogSource: 'builtin' } : settled
}
function withVerifiedModelIds(
state: StructuredAgentSessionOptionState,
ids: readonly string[] | undefined
): StructuredAgentSessionOptionState {
return String(state.verifiedModelIds) === String(ids)
? state
: { ...state, verifiedModelIds: ids }
}
function isListedModelRow(row: unknown): row is { id: string } {
return (
typeof row === 'object' &&
row !== null &&
'id' in row &&
typeof row.id === 'string' &&
'efforts' in row &&
Array.isArray(row.efforts)
)
}
/** The ids a finished listing names; undefined for one still running, failed, empty or malformed. */
function finishedListingIds(catalog: AgentSessionModelCatalogResult): string[] | undefined {
const rows: unknown = catalog.origin === 'unknown' ? null : catalog.models
return Array.isArray(rows) &&
rows.length > 0 &&
rows.every(isListedModelRow) &&
!catalog.listingInProgress &&
!catalog.unavailable
? rows.map((row) => row.id)
: undefined
}
/**
* Applies the host's stored model catalog: models only, no current selection
* and no record writes, so nothing here reads as a committed value — the pick
* stays provisional until a live options result confirms it. A live catalog
* is never downgraded by this.
*/
export function applyStructuredAgentSessionModelCatalog(
state: StructuredAgentSessionOptionState,
seed: AgentSessionOptionCatalog,
catalog: AgentSessionModelCatalogResult,
options: { newLaunch: boolean }
): StructuredAgentSessionOptionState {
const verifiedModelIds = finishedListingIds(catalog)
if (state.catalogSource === 'live') {
return withVerifiedModelIds(state, verifiedModelIds)
}
const models =
catalog.origin === 'unknown'
? []
: catalog.models.map((model) =>
discoveredModel(model, catalog.fastModeSupport?.supported === true)
)
if (catalog.origin === 'unknown' || models.length === 0) {
return settleStructuredAgentSessionBuiltinCatalog(state)
}
// The host says whether its listed default is what this launch runs; an older host never says,
// so the client's own knowledge of the agent stands in. A reopened chat may keep its own model.
const namesDefault =
options.newLaunch &&
(catalog.listingNamesConfiguredModel ?? seed.hostListingNamesConfiguredModel === true)
return {
...state,
// `isDefault` came from a real listing, so a launch's CLI default is nameable —
// as a provisional `default`-source value, never a confirmed one.
catalog: {
...seed,
models,
...(namesDefault ? { defaultModelIsCliDefault: true } : {})
},
catalogSource: 'host',
verifiedModelIds
}
}
export function applyStructuredAgentSessionOptions(
state: StructuredAgentSessionOptionState,
seed: AgentSessionOptionCatalog,
result: AgentSessionOptionsResult
): StructuredAgentSessionOptionState {
if (result.current.fastMode === undefined) {
clearTrackedSessionOption(state.record, result.current.model ?? null, 'fastMode')
}
if (result.current.serviceTier === undefined) {
clearTrackedSessionOption(state.record, result.current.model ?? null, 'serviceTier')
}
applyNativeChatReportedSessionOptions(
state.record,
{
...(result.current.model ? { model: result.current.model } : {}),
...(result.current.effort ? { effort: result.current.effort } : {}),
...(result.current.fastMode !== undefined ? { fastMode: result.current.fastMode } : {}),
...(result.current.serviceTier ? { serviceTier: result.current.serviceTier } : {})
},
result.current.confirmed ?? []
)
return {
...state,
catalog: structuredAgentSessionOptionCatalog(seed, result),
catalogSource: 'live',
permissionMode: result.current.permissionMode ?? null
}
}
/** Before the host answers, or an agent with no list reports, the quiet model pill: never
* pickable, so displaying it can never become a launch pick. */
const PROVIDER_DEFAULT_MODEL_PLACEHOLDER: SessionOptionDescriptor = {
id: 'model',
label: 'Model',
category: 'model',
kind: { type: 'select', choices: [] },
valueSource: 'unknown',
transport: 'agent-session',
settable: false,
disabledReason: 'available-after-session-start'
}
export function structuredAgentSessionOptionSnapshot(
state: StructuredAgentSessionOptionState
): SessionOptionDescriptor[] {
const permission = structuredAgentSessionPermissionDescriptor(state)
const options = modelOptionSnapshot(state)
return permission ? [...options, permission] : options
}
function modelOptionSnapshot(state: StructuredAgentSessionOptionState): SessionOptionDescriptor[] {
if (!state.catalog) {
return []
}
// Until the host answers, the built-in list would paint a label its list may replace.
if (state.catalogSource === 'seed') {
return [PROVIDER_DEFAULT_MODEL_PLACEHOLDER]
}
const snapshot = buildNativeChatSessionOptionSnapshot({
catalog: state.catalog,
// A seeded default can name a model the static seed does not list yet.
models: withTrackedNativeChatModel(state.catalog, state.catalog.models, state.record),
record: state.record,
mode: 'live',
modelLabel: 'Model',
liveTransport: 'agent-session'
})
return snapshot.length > 0 || state.catalogSource === 'live'
? snapshot
: [PROVIDER_DEFAULT_MODEL_PLACEHOLDER]
}
/** No launch holds a pick and no fence can carry one yet, so the picker only shows. */
export function lockedStructuredAgentSessionOptionSnapshot(
snapshot: readonly SessionOptionDescriptor[]
): SessionOptionDescriptor[] {
return snapshot.map((descriptor) => ({
...descriptor,
settable: false,
disabledReason: 'available-after-session-start'
}))
}
export function canSetStructuredAgentSessionOption(
state: StructuredAgentSessionOptionState,
id: string,
value: SessionOptionValue
): boolean {
const descriptor = structuredAgentSessionOptionSnapshot(state).find((entry) => entry.id === id)
return Boolean(
state.catalog &&
state.pendingId === null &&
((typeof value === 'string' &&
descriptor?.kind.type === 'select' &&
descriptor.kind.choices.some((choice) => choice.value === value)) ||
(typeof value === 'boolean' && descriptor?.kind.type === 'boolean'))
)
}
export function commitStructuredAgentSessionOption(
state: StructuredAgentSessionOptionState,
id: string,
value: string
): StructuredAgentSessionOptionState {
if (id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID) {
return { ...state, permissionMode: value, pendingId: null }
}
if (!state.catalog) {
return state
}
const effectiveModel = resolveEffectiveNativeChatModelId(
state.catalog,
state.catalog.models,
state.record
)
const decoded = decodeStructuredAgentSessionOptionValue(id, value)
if (decoded === null) {
return { ...state, pendingId: null }
}
setTrackedSessionOption(state.record, id, decoded, 'dispatched', effectiveModel)
return { ...state, pendingId: null }
}
export function commitStructuredAgentSessionOptionValues(
state: StructuredAgentSessionOptionState,
values: Readonly<Record<string, string>>
): StructuredAgentSessionOptionState {
let next = state
for (const id of STRUCTURED_LAUNCH_OPTION_IDS) {
const value = values[id]
if (value !== undefined) {
next = commitStructuredAgentSessionOption(next, id, value)
}
}
return next
}
export type StructuredSessionOptionPick = {
modelId: string
optionId: string
value: SessionOptionValue
}
/**
* The picks a mutation must remember so the next launch starts where the user left off.
* Keyed off the same ids the launch seed reads back, so a pick this surface cannot
* re-seed is never written.
*
* Model and effort travel as a pair: a launch resolves a stored effort only under a
* stored model, so an effort-only pick adopts the model it was chosen against. Values
* come from what the provider committed, not what was requested — it reconciles an
* effort the newly selected model cannot run before reporting back.
*
* `state` may still be pre-commit: a changed model arrives in `committed`, and an
* unchanged one is already what the record tracks, so neither reading depends on the
* commit having landed.
*/
export function structuredAgentSessionOptionPicks(
state: StructuredAgentSessionOptionState,
committed: Readonly<Record<string, string>>
): StructuredSessionOptionPick[] {
if (!state.catalog) {
return []
}
const committedModel = committed.model
const modelId =
typeof committedModel === 'string' && committedModel.trim()
? committedModel
: resolveEffectiveNativeChatModelId(state.catalog, state.catalog.models, state.record)
if (!modelId) {
return []
}
return STRUCTURED_LAUNCH_SEED_OPTION_IDS.flatMap((optionId) => {
const value = committed[optionId]
if (value === undefined) {
return []
}
const decoded = decodeStructuredAgentSessionOptionValue(optionId, value)
return decoded === null ||
(typeof decoded === 'string' && !decoded.trim()) ||
encodeStructuredAgentSessionOptionValue(optionId, decoded) === null
? []
: [{ modelId, optionId, value: decoded }]
})
}