Files
orca/src/shared/structured-agent-session-options.ts
T
Brennan Benson 78ad9f4255 fix: keep chat option actions independent of inventory (#31927)
* fix: separate chat option input from inventory observations

* fix: preserve scoped chat choices and publication ownership

* fix: complete scoped catalog and applied control ownership

* test: make catalog fixture dependency boundaries explicit

* test: request catalog discovery explicitly in demand contracts

* fix(chat): preserve option observations and operation boundaries

* fix(chat): finish pending option and catalog lifetimes

* fix(chat): project saved permissions without launch preparation

* fix(chat): correct option rejection and catalog retirement

* Preserve Claude effort validation advice through chat rejection

* test: align refresh mocks and preserve fixture type evidence

* fix: share catalog claims only across equivalent owners

* test: use typed catalog corpus instrumentation

* fix: consolidate structured launch imports

* Validate SDK queue regression envelopes without assertions

* fix: complete catalog runtime and fixture type contracts

* Align native option fixtures with explicit discovery and applied facts

* Keep initial compatibility catalog reads on held memory

* Complete the installed catalog fixture envelope

* Observe learned defaults after asynchronous projection settles

* Consolidate host fixture journal type imports

* test(native-chat): align goal and seed fixtures with applied inputs

* fix(codex): retain applied proof when goal settings skip a write

* test: load rejection wording before timed queue regression

* Fix catalog fixtures and queued option snapshot typing

* Align permission publication fixture with passive option reads

* Separate startup config from held turn permission fixture

* Align input lifecycle fixtures with sparse applied choices

* Simplify scoped catalog refresh ownership and coalesce cache snapshots

* Load catalog persistence real-module fixture during collection

* Compose catalog availability fixtures from valid session records

* Align catalog and startup fixtures with current option contracts

* Fix permission read types and separate catalog fixture preparation

* Align option and restart fixtures with current lifecycle owners

* test(mobile): retain reported model alongside discovery

* test(mobile): recalibrate the measured route script envelope

* test: let the preflight shell-capture mock run its detection callback
2026-10-28 16:08:01 -07:00

286 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AgentSessionOptionCatalog, CatalogModel } from './agent-session-option-catalog'
import { discoveredModel } from './structured-agent-session-discovered-model'
import {
createStructuredAgentSessionOptionRecord,
replaceStructuredAgentSessionAnsweredValues,
type StructuredAgentSessionOptionRecord
} from './structured-agent-session-option-state'
import {
STRUCTURED_LAUNCH_OPTION_IDS,
STRUCTURED_LAUNCH_SEED_OPTION_IDS
} from './native-chat/input/native-chat-session-option-defaults'
import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode'
import { usesInstalledAgentModelCatalog } from './agent-model-catalog-ownership'
import { structuredAgentSessionShownModel } from './structured-agent-session-option-snapshot'
import type { SessionOptionValue } from './native-chat/input/native-chat-session-options'
import type {
AgentSessionModelCatalogResult,
AgentSessionOptionsResult
} from './agent-session-wire'
import {
decodeStructuredAgentSessionOptionValue,
encodeStructuredAgentSessionOptionValue
} from './structured-agent-session-option-codec'
export {
canSetStructuredAgentSessionOption,
lockedStructuredAgentSessionOptionSnapshot,
structuredAgentSessionOptionSnapshot
} from './structured-agent-session-option-snapshot'
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
/** Catalog provenance, not current-child eligibility: seed stays a placeholder until the host answers;
* builtin then enables the static list without naming a model the host may replace. */
catalogSource: 'seed' | 'builtin' | 'host' | 'live' | null
/** Live-answer IDs verified by the agent’s listing; extra rows name the chat’s own model, not listed capabilities. Old hosts omit this. */
verifiedModelIds?: readonly string[]
record: StructuredAgentSessionOptionRecord
/** The last answer came from a host that marks no defaults, whose own tier rule it shows. */
olderHost?: true
/** Resumed chats show host defaults without sending them; new chats pin their shown default on first send. */
defaultsUnsent?: true
/** The permission mode the host or launch last stated; null shows no picker. */
permissionMode: string | null
reportedPermissionMode?: string | null
permissionOwnSupported?: boolean | null
pendingId: string | null
listingInProgress?: boolean
fastModeRestricted?: boolean
}
/** Install a static seed immediately, but show the placeholder until the host answers; later sources only upgrade it. */
export function createStructuredAgentSessionOptionState(
agent = 'codex',
seedCatalog?: AgentSessionOptionCatalog | null
): StructuredAgentSessionOptionState {
return {
catalog: seedCatalog ?? null,
catalogSource: seedCatalog ? 'seed' : null,
record: createStructuredAgentSessionOptionRecord(agent),
permissionMode: null,
pendingId: null
}
}
/** A missing/failed/old-host list enables built-in controls but names no model until a host/session list does. */
export function settleStructuredAgentSessionBuiltinCatalog(
state: StructuredAgentSessionOptionState,
listingInProgress = state.listingInProgress
): StructuredAgentSessionOptionState {
const catalogSource = state.catalogSource === 'seed' ? 'builtin' : state.catalogSource
return catalogSource === state.catalogSource && state.listingInProgress === listingInProgress
? state
: { ...state, catalogSource, listingInProgress }
}
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. */
export 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
}
/** Install models only; no record writes or committed picks until an options result confirms them. */
export function applyStructuredAgentSessionModelCatalog(
state: StructuredAgentSessionOptionState,
seed: AgentSessionOptionCatalog,
catalog: AgentSessionModelCatalogResult,
options: { newLaunch: boolean; coherentCatalog?: boolean }
): StructuredAgentSessionOptionState {
const installed = usesInstalledAgentModelCatalog(state.record.agent, options.coherentCatalog)
const listingInProgress = installed && catalog.listingInProgress === true
if (Boolean(state.listingInProgress) !== listingInProgress) {
state = { ...state, listingInProgress }
}
if (state.catalogSource === 'live' && !installed) {
return state
}
const models =
catalog.origin === 'unknown'
? []
: catalog.models.map((model) =>
discoveredModel(model, catalog.fastModeSupport?.supported === true)
)
if (catalog.origin === 'unknown' || (!installed && models.length === 0)) {
return settleStructuredAgentSessionBuiltinCatalog(state)
}
// Only new launches may name the listing default; use host evidence, or agent knowledge for old hosts. Reopened chats may retain another model.
const namesDefault =
options.newLaunch &&
(catalog.listingNamesConfiguredModel ?? seed.hostListingNamesConfiguredModel === true)
return {
...state,
// Real-listing defaults are nameable but provisional, never confirmed picks.
catalog: {
...(state.catalogSource === 'live' ? (state.catalog ?? seed) : seed),
models,
...(namesDefault ? { defaultModelIsCliDefault: true } : {})
},
...(installed ? { verifiedModelIds: finishedListingIds(catalog) } : {}),
catalogSource: state.catalogSource === 'live' ? 'live' : 'host'
}
}
export function applyStructuredAgentSessionOptions(
state: StructuredAgentSessionOptionState,
seed: AgentSessionOptionCatalog,
result: AgentSessionOptionsResult,
/** Absent means this view did not start the chat, so listing defaults never become its own values. */
launch: { newLaunch: boolean; coherentCatalog?: boolean } = { newLaunch: false }
): StructuredAgentSessionOptionState {
const { current } = result
const installed = usesInstalledAgentModelCatalog(state.record.agent, launch.coherentCatalog)
// Catalog defaults supply picker context without claiming the chat chose them.
replaceStructuredAgentSessionAnsweredValues(
state.record,
{
...(current.model ? { model: current.model } : {}),
...(current.effort ? { effort: current.effort } : {}),
...(current.fastMode !== undefined ? { fastMode: current.fastMode } : {}),
...(current.serviceTier ? { serviceTier: current.serviceTier } : {})
},
(id) =>
current.defaulted?.some((key) => key === id)
? 'default'
: current.confirmed?.includes(id)
? 'reported'
: id === 'serviceTier' && current.defaulted !== undefined
? 'applied'
: 'dispatched',
state.record.agent === 'codex' && current.defaulted !== undefined
)
return {
...state,
catalog: installed ? state.catalog : structuredAgentSessionOptionCatalog(seed, result),
fastModeRestricted:
installed &&
result.fastModeSupport?.supported === false &&
result.fastModeSupport.reason !== 'model-not-supported',
catalogSource: installed && state.catalogSource === 'seed' ? 'seed' : 'live',
olderHost: current.defaulted === undefined ? true : undefined,
defaultsUnsent: current.defaulted !== undefined && !launch.newLaunch ? true : undefined,
verifiedModelIds: installed
? state.verifiedModelIds
: result.listedModelIds?.length
? result.listedModelIds
: undefined,
permissionMode: result.current.permissionMode ?? null,
reportedPermissionMode: result.current.reportedPermissionMode
}
}
/** The model a pick's other values were chosen under: the chat's own, else the default shown. */
function structuredAgentSessionControlsModelId(
state: StructuredAgentSessionOptionState
): string | null {
return state.catalog
? structuredAgentSessionShownModel(state.catalog, state.catalog.models, state.record)
.controlsModelId
: null
}
export function commitStructuredAgentSessionOption(
state: StructuredAgentSessionOptionState,
id: string,
value: string
): StructuredAgentSessionOptionState {
if (id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID) {
return { ...state, permissionMode: value, reportedPermissionMode: undefined, pendingId: null }
}
if (!state.catalog) {
return state
}
const decoded = decodeStructuredAgentSessionOptionValue(id, value)
if (decoded === null) {
return { ...state, pendingId: null }
}
// Keep other picks after a model change; the host reconciles independent values.
state.record.values[id] = { value: decoded, source: '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
}
/** Persist only reseedable IDs, using provider-committed values after reconciliation. Effort-only picks adopt their controls’ model
* because launch reads effort only under a stored model. Pre-commit state is valid: committed supplies changed model, record supplies unchanged model. */
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
: structuredAgentSessionControlsModelId(state)
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 }]
})
}