mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 18:07:45 +00:00
* Share chat option writes across desktop and phone * Restore main's failure notice scope for chat option picks Held launch refusals toast again while the transport is still closed, desktop notices follow only the chat's presentation scope so a fence or status change on the same chat still reports, and the phone banner no longer depends on the read record.
330 lines
13 KiB
TypeScript
330 lines
13 KiB
TypeScript
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 { AgentSessionAcceptedInputSources } from './agent-session-options-result'
|
||
import { labelFromModelId } from './model-id-label'
|
||
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,
|
||
agent: StructuredAgentSessionOptionRecord['agent']
|
||
): AgentSessionOptionCatalog {
|
||
const models: CatalogModel[] = result.models.map((model) =>
|
||
discoveredModel(model, result.fastModeSupport?.supported === true, agent)
|
||
)
|
||
if (result.current.model && !models.some((model) => model.id === result.current.model)) {
|
||
models.push({
|
||
id: result.current.model,
|
||
label: labelFromModelId(result.current.model),
|
||
description: result.current.model,
|
||
options: seed.unknownModelOptions ?? []
|
||
})
|
||
}
|
||
return { ...seed, models, defaultModelIsCliDefault: true }
|
||
}
|
||
|
||
export type StructuredAgentSessionOptionState = {
|
||
catalog: AgentSessionOptionCatalog | null
|
||
/** Catalog provenance, independent of known input: seed offers no replacement models;
|
||
* builtin enables static rows only after unavailable discovery. */
|
||
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
|
||
optionsAnswerPresent?: true
|
||
acceptedInputSources?: Partial<
|
||
Record<keyof AgentSessionAcceptedInputSources, 'saved' | 'seeded' | 'policy' | 'picked'>
|
||
>
|
||
/** 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
|
||
reportedEffort?: string
|
||
listingInProgress?: boolean
|
||
fastModeRestricted?: boolean
|
||
}
|
||
|
||
/** Seed definitions supply geometry; only accepted input or host inventory can name a model. */
|
||
export function createStructuredAgentSessionOptionState(
|
||
agent = 'codex',
|
||
seedCatalog?: AgentSessionOptionCatalog | null
|
||
): StructuredAgentSessionOptionState {
|
||
return {
|
||
catalog: seedCatalog ?? null,
|
||
catalogSource: seedCatalog ? 'seed' : null,
|
||
record: createStructuredAgentSessionOptionRecord(agent),
|
||
acceptedInputSources: {},
|
||
permissionMode: 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' && !listingInProgress ? '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 = 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, state.record.agent)
|
||
)
|
||
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',
|
||
acceptedInputSources: state.optionsAnswerPresent
|
||
? state.acceptedInputSources
|
||
: {
|
||
...(namesDefault ? { model: 'policy', effort: 'policy' } : {}),
|
||
fastMode: 'policy',
|
||
...Object.fromEntries(
|
||
Object.entries(state.acceptedInputSources ?? {}).filter(
|
||
([, source]) => source !== 'policy'
|
||
)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
const keepCatalog = installed || result.modelsKnown === false
|
||
// 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,
|
||
optionsAnswerPresent: true,
|
||
reportedEffort: state.record.agent === 'pi' ? current.reportedEffort : undefined,
|
||
catalog: keepCatalog
|
||
? state.catalog
|
||
: structuredAgentSessionOptionCatalog(seed, result, state.record.agent),
|
||
fastModeRestricted:
|
||
installed &&
|
||
result.fastModeSupport?.supported === false &&
|
||
result.fastModeSupport.reason !== 'model-not-supported',
|
||
catalogSource:
|
||
result.modelsKnown === false
|
||
? state.catalogSource === 'live'
|
||
? 'host'
|
||
: state.catalogSource
|
||
: installed && state.catalogSource === 'seed'
|
||
? 'seed'
|
||
: 'live',
|
||
acceptedInputSources: current.acceptedInputSources,
|
||
olderHost: current.defaulted === undefined ? true : undefined,
|
||
defaultsUnsent: current.defaulted !== undefined && !launch.newLaunch ? true : undefined,
|
||
verifiedModelIds: keepCatalog
|
||
? 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,
|
||
origin: 'picked' | 'seeded' = 'picked'
|
||
): StructuredAgentSessionOptionState {
|
||
if (id === 'model' || id === 'effort') {
|
||
state = { ...state, reportedEffort: undefined }
|
||
}
|
||
if (id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID) {
|
||
return { ...state, permissionMode: value, reportedPermissionMode: undefined }
|
||
}
|
||
if (!state.catalog) {
|
||
return state
|
||
}
|
||
const decoded = decodeStructuredAgentSessionOptionValue(id, value)
|
||
if (decoded === null) {
|
||
return state
|
||
}
|
||
// Keep other picks after a model change; the host reconciles independent values.
|
||
state.record.values[id] = { value: decoded, source: 'dispatched' }
|
||
return {
|
||
...state,
|
||
acceptedInputSources:
|
||
state.acceptedInputSources === undefined
|
||
? undefined
|
||
: { ...state.acceptedInputSources, [id]: origin }
|
||
}
|
||
}
|
||
|
||
export function commitStructuredAgentSessionOptionValues(
|
||
state: StructuredAgentSessionOptionState,
|
||
values: Readonly<Record<string, string>>,
|
||
origin: 'picked' | 'seeded' = 'picked'
|
||
): StructuredAgentSessionOptionState {
|
||
let next = state
|
||
for (const id of STRUCTURED_LAUNCH_OPTION_IDS) {
|
||
const value = values[id]
|
||
if (value !== undefined) {
|
||
next = commitStructuredAgentSessionOption(next, id, value, origin)
|
||
}
|
||
}
|
||
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 }]
|
||
})
|
||
}
|