mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
* Use one model catalog per execution scope * Accept a missing session record in host scope * Merge duplicate launch-resolution import * Fix catalog typecheck errors * Align catalog tests with the one-list design * Type the integration fixture's send without a cast * Give the discovery test complete runtime dependencies * Fix model catalog completion and scoped configured display * Preserve model catalog authority across released clients * Fix mobile first-frame catalog fixture narrowing * Keep startup catalog persistence with one completion owner * fix: preserve chat selections across CLI model updates A newer CLI can omit a model that an existing chat still runs. Keep that chat's selected model and effort instead of replacing them from the available list. Discover one model list per host, account, launch command and folder, refreshing after two minutes. Keep current values and the running model's exact effort choices with its chat. Show only the known effort when Claude supplies no matching menu. Preserve older paired-client delivery and configured launch arguments. Make Codex use independent discovery and retain the established ACP and Pi paths. Tracked in STA-10947. * fix: keep the Pi options read within its file limit after main's growth Tracked in STA-10947. * fix: import the option snapshot's shared module once Tracked in STA-10947. * fix: keep Grok and Pi model menus and fast controls across restarts Restore the established ACP and Pi catalog route: newer listed models and qualified startup defaults stay visible, and genuine retained effort menus survive later reads. Keep exact-child Fast and Speed controls. Update the phone option contracts and the remaining stale expectations to the selection rules. Tracked in STA-10947. * fix: share each workspace's model list and keep live Grok and Pi menus New chats read their workspace's own model list through the one shared request for that workspace, so a second chat in the same folder starts on the model and effort the agent actually runs there, and a slow first answer can no longer overwrite a newer list. A running Grok, OpenCode or Pi chat keeps its live model menu when an older shared list arrives later. Update the tests that still expected the replaced behavior. Tracked in STA-10947. * fix: keep separate agent and chat model lists for Grok, OpenCode and Pi A brand-new Claude chat now offers the selected model's effort levels before the first message, as it did before. Grok, OpenCode, omp and Pi again keep the list read from the installed agent apart from the list a running chat reports, and each newer list replaces only its own source. So a chat shows the model the agent is actually running with that model's own effort menu, and an effort change can no longer act on an older model's menu. Saved model lists on WSL are readable again without starting the agent. Tracked in STA-10947. * fix: keep desktop catalog storage out of the phone's type graph and show Grok's late model answer The phone app's type check no longer pulls in desktop-only catalog storage. The model-list code no longer reaches the saved-catalog file through a type import. When a Grok model change times out and Grok later confirms the model, the chat shows the model Grok actually runs. Tracked in STA-10947. * Remove unrelated merge formatter and package manager churn * Clarify independent catalog ownership and refresh contracts * Declare picker demand callback as an effect dependency * Complete mobile catalog surface and catalog merge types * Complete catalog contract fixtures for current host types * fix: preserve catalog source contracts and account fallback * test: inject independent Codex discovery transport * test: remove accidental client fixture assertion * test: qualify the saved account fallback read * fix(chat): preserve catalog evidence and current child controls * Fix catalog CI types and mobile option readiness * Reconcile session controls after inventory changes * Fix catalog import and deferred test resolver types * Keep model capability observations separate from picker views * Use installed model choices for chat options * Fix model catalog test fixture contracts * Restore model option compatibility and stable settlement * Return a catalog reply in provisional launch fixture * Compose unreported live model defaults in the host * Preserve explicit replacement model on recovery send * fix(chat): use stable scoped catalogs on local desktop * test(chat): preserve generic catalog runner type * fix: scope catalogs to configured environment and keep saved reads passive * fix: satisfy catalog regression and controller CI checks
326 lines
13 KiB
TypeScript
326 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-session-option-defaults'
|
|
import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode'
|
|
import { usesInstalledAgentModelCatalog } from './agent-model-catalog-ownership'
|
|
import {
|
|
structuredAgentSessionDescribedModel,
|
|
structuredAgentSessionShownModel
|
|
} from './structured-agent-session-option-snapshot'
|
|
import type { SessionOptionValue } from './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
|
|
/** What answered `catalog`, not proof of current child eligibility. `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 rows of a live answer its agent's own listing names; a row outside them is the chat's
|
|
* own model, which no list describes. Absent from an older host. */
|
|
verifiedModelIds?: readonly string[]
|
|
record: StructuredAgentSessionOptionRecord
|
|
/** The last answer came from a host that marks no defaults, whose own tier rule it shows. */
|
|
olderHost?: true
|
|
/** A resumed chat names the default its host marks but never sends it: its next start runs the
|
|
* agent's own. A new chat sends the default it shows, so its first send pins it. */
|
|
defaultsUnsent?: true
|
|
/** The permission mode the host or launch last stated; null shows no picker. */
|
|
permissionMode: string | null
|
|
pendingId: string | null
|
|
listingInProgress?: boolean
|
|
fastModeRestricted?: boolean
|
|
}
|
|
|
|
/** 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: createStructuredAgentSessionOptionRecord(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,
|
|
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
|
|
}
|
|
|
|
/**
|
|
* 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 an options result confirms it.
|
|
*/
|
|
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)
|
|
}
|
|
// 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: {
|
|
...(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,
|
|
/** Whether this view started the chat, which runs the default it shows until its first send.
|
|
* Absent reads as a chat it did not start, which never names a default as its own. */
|
|
launch: { newLaunch: boolean; coherentCatalog?: boolean } = { newLaunch: false }
|
|
): StructuredAgentSessionOptionState {
|
|
const { current } = result
|
|
const installed = usesInstalledAgentModelCatalog(state.record.agent, launch.coherentCatalog)
|
|
// A value the host supplied from a catalog default is context for the picker, not the chat's own.
|
|
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'
|
|
: 'dispatched'
|
|
)
|
|
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
|
|
}
|
|
}
|
|
|
|
/** 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, pendingId: null }
|
|
}
|
|
if (!state.catalog) {
|
|
return state
|
|
}
|
|
const decoded = decodeStructuredAgentSessionOptionValue(id, value)
|
|
if (decoded === null) {
|
|
return { ...state, pendingId: null }
|
|
}
|
|
// Each value is the chat's own: a model pick leaves the others for the host to reconcile.
|
|
state.record.values[id] = { value: decoded, source: 'dispatched' }
|
|
return { ...state, pendingId: null }
|
|
}
|
|
|
|
/** Whether a pick of `modelId` held before launch keeps another held value (decoded or encoded):
|
|
* no host exists yet to reconcile it, so only what the model's controls offer stays. A model the
|
|
* list does not describe keeps every value, since nothing says what it takes. */
|
|
export function structuredAgentSessionModelOffers(
|
|
state: Pick<StructuredAgentSessionOptionState, 'catalog' | 'verifiedModelIds'>,
|
|
modelId: string,
|
|
id: string,
|
|
value: SessionOptionValue
|
|
): boolean {
|
|
const model = structuredAgentSessionDescribedModel(state, modelId)
|
|
if (id === 'model' || id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID || !model) {
|
|
return true
|
|
}
|
|
const option = model.options.find((entry) => entry.id === id)
|
|
return option?.kind.type === 'select'
|
|
? option.kind.choices.some((choice) => choice.value === value)
|
|
: option !== undefined
|
|
}
|
|
|
|
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
|
|
: 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 }]
|
|
})
|
|
}
|