Files
orca/src/shared/structured-agent-session-options.ts
T
Brennan Benson b1e12d7bb4 fix(native-chat): a new structured chat's model list comes from the machine that runs it (#25143)
* fix(native-chat): a new structured chat's model list comes from the host that runs it

agentSession.modelCatalog builds the structured-session host like agentSession.options,
so a host with no saved chats since it started answers instead of refusing. When the
host answers unknown because its first listing runs in the background, the picker
re-reads on a bounded schedule until that listing lands. Local terminal-backed chat
skips the structured catalog when a custom launch command is configured.

* fix(native-chat): wait on the host's first model listing instead of re-reading on a timer

A new structured chat's picker read the host catalog once; on an account the
host had never listed, the answer was "unknown" while a background listing ran,
and the client re-read on a 1-30 s schedule. Replace the schedule with the
host's own completion signal:

- The host answers a cold read with `listingInProgress: true` (new optional
  field) once it has started or joined that listing. A read that passes the new
  optional `waitForListing` param awaits the same joined listing and answers
  with it, or a plain "unknown" if it failed. The at-rest options read never
  waits. A host that predates the field never sends it, so the client never
  sends the param to a host that would refuse it.
- The picker reads once per open and attach; after the host's report it sends
  one waiting read, with a 90 s client timeout above the slowest listing.
  While that read is out, the model pill keeps its label but cannot open or be
  set (typed /model included). An answer, failure, timeout, hide, attach or
  the provider's own list releases it.
- `agentSession.modelCatalog` builds the structured-session host only for a
  read that names a session (a structured chat). Terminal-backed chat's
  session-less read keeps the non-building gate, so a desktop that never runs
  structured chat never opens the session journal.

* fix(native-chat): one waiting model-list read per chat, and no late menu open

The waiting catalog read was owned by one run of the picker's effect. Attach
(a new fence), hide/show or a send re-ran the effect: the cleanup released the
model picker onto the built-in list for a round trip, and the new run sent a
second waiting read while the first, which cannot be withdrawn, kept a remote
call slot until the listing ended.

The waiting read now belongs to the chat (runtime target + agent + session):
a small registry keeps one in flight per chat, every re-run or remount joins
it, and the entry is deleted when the read settles. The picker hold is derived
from that entry being in flight, so it lasts across attach and hide/show and
ends when the read settles, the provider reports its own list, or the pane
switches to another session. Answers still pass the stale and record checks.

A bare /model typed while the list loads no longer opens the model menu by
itself when the list lands: the menu stays keyed on the request, and only its
initial open is suppressed while pending, so the request is spent shut and the
end of the pending period never remounts it.

* fix(native-chat): release the model picker in the same commit as the host list

When the waiting catalog read settled in the chat that started it, the
registry dropped its entry and told subscribers first, and the host list was
applied a few microtasks later. React committed once with the picker enabled
on the built-in list, then again with the host's list.

Joiners now hand the registry their apply callback, and the registry runs
every joiner (with the answer, or nothing when the read failed or timed out)
before it deletes the entry and notifies. The release and the list land in one
commit. An effect cleanup leaves the wait instead of flagging itself stale.

* fix(runtime): queue model catalog reads in the long-wait lane

A model catalog read that waits on a host's first listing replies only when
that listing ends, yet it took one of the 8 foreground call slots for its
server. Enough chats opened during one cold listing would stall that server's
sends and interrupts until a wait settled.

agentSession.modelCatalog now joins worktree.rm in the long-wait lane: same
concurrency, counted apart from the foreground calls. The queue classifies by
method only, and a warm catalog read answers at once, so the whole method
moves.
2026-10-04 14:37:40 -07:00

335 lines
11 KiB
TypeScript

import type {
AgentSessionOptionCatalog,
CatalogModel,
CatalogOption
} from './agent-session-option-catalog'
import {
buildNativeChatSessionOptionSnapshot,
resolveEffectiveNativeChatModelId,
withTrackedNativeChatModel
} from './native-chat-session-option-snapshot'
import {
applyNativeChatReportedSessionOptions,
clearTrackedSessionOption,
cloneNativeChatSessionOptionRecord,
createNativeChatSessionOptionRecord,
setTrackedSessionOption,
type NativeChatSessionOptionRecord
} from './native-chat-session-option-state'
import { STRUCTURED_LAUNCH_SEED_OPTION_IDS } from './native-chat-session-option-defaults'
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'
function effortOption(model: AgentSessionOptionsResult['models'][number]): CatalogOption | null {
if (model.efforts.length <= 1) {
return null
}
return {
id: 'effort',
label: 'Reasoning effort',
category: 'thought_level',
kind: {
type: 'select',
choices: model.efforts,
defaultValue: model.defaultEffort ?? model.efforts[0]!.value,
...(model.defaultEffort ? { defaultIsCliDefault: true as const } : {})
},
apply: { midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` } }
}
}
function fastModeOption(): CatalogOption {
return {
id: 'fastMode',
label: 'Fast mode',
category: 'mode',
kind: { type: 'boolean', defaultValue: false },
apply: {}
}
}
function discoveredModel(
model: AgentSessionOptionsResult['models'][number],
sessionSupportsFastMode: boolean
): CatalogModel {
const effort = effortOption(model)
return {
id: model.id,
label: model.label,
...(model.description ? { description: model.description } : {}),
...(model.isDefault ? { isDefault: true } : {}),
options: [
...(effort ? [effort] : []),
...(sessionSupportsFastMode && model.supportsFastMode === true ? [fastModeOption()] : [])
]
}
}
export function structuredAgentSessionOptionCatalog(
seed: AgentSessionOptionCatalog,
result: AgentSessionOptionsResult
): AgentSessionOptionCatalog {
const models: CatalogModel[] = result.models.map((model) =>
discoveredModel(model, result.fastModeSupport?.supported === true)
)
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 }
}
export type StructuredAgentSessionOptionState = {
catalog: AgentSessionOptionCatalog | null
/** What produced `catalog`; a weaker source never replaces a stronger one. */
catalogSource: 'seed' | 'host' | 'live' | null
record: NativeChatSessionOptionRecord
pendingId: string | null
}
/** With `seedCatalog`, the picker renders (and accepts picks against) the
* static seed from the first frame; 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),
pendingId: null
}
}
/**
* What the picker shows before the host has confirmed this session's values:
* `seed` (the selection a launch seeds) stands in until the record names a
* model, and `held` picks outrank both until the host settles them. Both show
* as `dispatched`; derived on every read, never written into the record.
*/
export function structuredAgentSessionOptionView(
state: StructuredAgentSessionOptionState,
seed: Readonly<Record<string, string>> | undefined,
held: Readonly<Record<string, string>>
): StructuredAgentSessionOptionState {
const seeded = seed !== undefined && state.record.model === undefined
if (!state.catalog || (!seeded && Object.keys(held).length === 0)) {
return state
}
let view: StructuredAgentSessionOptionState = {
...state,
record: cloneNativeChatSessionOptionRecord(state.record)
}
if (seeded) {
view = commitStructuredAgentSessionOptionValues(view, seed)
}
return { ...commitStructuredAgentSessionOptionValues(view, held), pendingId: state.pendingId }
}
/**
* 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: { namesDefault: boolean }
): StructuredAgentSessionOptionState {
if (state.catalogSource === 'live' || catalog.origin === 'unknown') {
return state
}
const models = catalog.models.map((model) =>
discoveredModel(model, catalog.fastModeSupport?.supported === true)
)
if (models.length === 0) {
return state
}
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,
...(options.namesDefault ? { defaultModelIsCliDefault: true } : {})
},
catalogSource: 'host'
}
}
export function applyStructuredAgentSessionOptions(
state: StructuredAgentSessionOptionState,
seed: AgentSessionOptionCatalog,
result: AgentSessionOptionsResult
): StructuredAgentSessionOptionState {
if (result.current.fastMode === undefined) {
clearTrackedSessionOption(state.record, result.current.model, 'fastMode')
}
applyNativeChatReportedSessionOptions(
state.record,
{
model: result.current.model,
...(result.current.effort ? { effort: result.current.effort } : {}),
...(result.current.fastMode !== undefined ? { fastMode: result.current.fastMode } : {})
},
result.current.confirmed ?? []
)
return {
...state,
catalog: structuredAgentSessionOptionCatalog(seed, result),
catalogSource: 'live'
}
}
export function structuredAgentSessionOptionSnapshot(
state: StructuredAgentSessionOptionState
): SessionOptionDescriptor[] {
if (!state.catalog) {
return []
}
return 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'
})
}
/** 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'
}))
}
/** While the host runs its first model listing, nothing is picked from the stand-in list. */
export function pendingModelListStructuredAgentSessionOptionSnapshot(
snapshot: readonly SessionOptionDescriptor[]
): SessionOptionDescriptor[] {
return snapshot.map((descriptor) =>
descriptor.category === 'model'
? { ...descriptor, settable: false, choicesPending: true }
: descriptor
)
}
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 (!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_SEED_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 }]
})
}