Files
orca/src/shared/structured-agent-session-options.ts
T
Brennan Benson b3048a2b9c Wait for agent readiness without blocking native chat (#27925)
* Finish structured agent readiness before delivering held work

* Adapt permission fixtures to startup lifecycle

* Keep model discovery for chats before their first start

* Use one host readiness promise for provider operations

* Await provider opening in the execution host

* Await Claude initialization in host-owned open

* Observe Claude readiness through the host status

* Keep optional readback behind host readiness

* Recheck launch permission after host readiness

* Preserve the awaited child during permission revalidation

* Record failed owned opening before the first message

* Abort a matching provider open when its child exits

* Cover provider exit during an unanswered host open

* Cover explicit failure facts on a host-stopped child

* Keep startup waits cancellable and preserve operation order

* Keep live choices and delivery order while agents start

* Apply startup picks and let parked messages release operations [skip ci]

* Save a waiting startup pick after Stop ends its opening [skip ci]

* Save choices when startup ends or a replacement is still opening

* Check saved choices separately from Claude startup reports

* Align startup fixtures and preserve observed Claude exits

* Order startup picks before later messages and retain flow regressions

* Keep reviewed lifecycle contracts while pruning duplicate tests

* Save picks after a proven exit without requiring a child

* Type the startup reply fixture against the Claude control contract

* Honor the before-input callback in the startup reply fixture

* Keep startup option menus independent and show the saved choice

* Exercise startup picks across real menus and the runtime

* Keep saved effort visible when picking a model during startup

* Include the running-turn scope in repeated Stop fixture

* Use the current attach contract in import fixtures

* Align carried-message fixture with startup event contract

* Keep launch-only permission picks usable while prior writes wait

* ci: rerun on main with the locale fix

* Preserve goal retries and chat launch selections

* Give launch regression its request identity

* test(native-chat): type the provisional-launch hook's session prop
2026-10-16 12:44:35 -07:00

355 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,
getTrackedSessionOption,
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 !== id &&
((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 }
}
const effort =
id === 'model' ? getTrackedSessionOption(state.record, effectiveModel, 'effort') : undefined
setTrackedSessionOption(state.record, id, decoded, 'dispatched', effectiveModel)
// The host saves effort for the conversation, including across model picks.
if (effort) {
setTrackedSessionOption(state.record, 'effort', effort.value, 'dispatched')
}
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 }]
})
}