mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
* 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
355 lines
13 KiB
TypeScript
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 }]
|
|
})
|
|
}
|