mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
* fix: start a new Claude chat on the listed default when its remembered model is gone The remembered new-chat model was launched as-is even after Claude stopped offering it, so every new chat failed its first message. The host create and the desktop new-chat picker now derive the same replacement from the host's saved model list: the listed default, effort moved to the nearest level it offers. Explicit models (--model, held picks) run as given; with no saved list the remembered model is kept. * fix: read the host's model list only for a Claude create that needs it The check read host.deps before ruling out other agents and explicit models, so creates whose host carries no deps (and every non-Claude create) paid for a lookup they never use. * fix: picker derives the fallback only where the host would A held model runs as picked on the host, so the picker no longer derives an effort beside it. A cached list whose default marker was dropped for this workspace keeps the placeholder instead of naming its first row. * fix: the new-chat picker picks the replacement for a gone Claude model The host-side check read the saved list separately from the picker, so the two disagreed and a cold start never fired it. The desktop new-chat picker now picks the replacement it shows, once, through the existing pick path, so the first send runs it and the remembered selection heals. The host is back to main. * fix: decide a gone Claude model's replacement at send, from the list in hand The open-time auto-pick raced the chat's create and the list's arrival, so the first send could still launch the gone model. Now, like the common pattern, the picker shows the replacement for a model the host's loaded list lacks, and the send step applies it through the existing pick call, awaited, right before the message goes out. A failed pick never holds the send back. * fix: one verified list decides a gone model's replacement The picker, the send step and the option setter now all read the list the picker shows, less the row a session adds for its own unlisted model, so what is shown is what applies and an explicit pick from that list stays. A listing that is still running, failed, or absent keeps the model. The send step re-derives from the chat's state when it runs, so a second send behind one that already moved the model applies nothing. * fix: only rows a finished host listing names can replace a gone model A single verified flag outlived the catalog it described: live options built from the built-in fallback list, or a malformed or empty host answer, still authorized a replacement the host would refuse. The state now keeps the ids a finished, well-formed host listing names, and a replacement must be both shown in the picker and named there. * fix(native-chat): the send carries the composer's Claude model and effort Every composer send (desktop and phone) carries the model and effort the picker shows, as optional agentSession.send options, only to a host that advertises agent-session.send-options.v1. The host writes a carried value that differs from the chat's own through the existing pick path before accepting the message, so the turn starts on it and the chat records it. A queued card keeps its options and applies them when it goes out; a launch's create call carries the replacement for its first message. Replaces the separate awaited pick before each send. * chore: drop an unrelated lockfile change * refactor(native-chat): one helper names a send's control fields for client and host * ci: rerun against current main * fix(native-chat): a message runs on the model and effort it was sent with The carried selection is now stored with the message (its submission row, and a queued card's row) and applied through the shared option writer as the delivery loop hands the message over, or before the start it needs. A pick made after the message was accepted no longer changes the model it runs on. Direct sends, queue drains and Send now share that one apply point; the early apply before acceptance and the queue-only apply are gone. The picker and the send read one effective selection: the replacement for a model the host's finished listing lacks shows only for a host that takes a send's selection, the phone validates picks against what it shows, a send carries the default the composer shows, a resumed launch's first message gets the same resolution, and only the listing proves a row (no label heuristic). * docs(native-chat): a submission's options are the host's alone * fix(native-chat): an opening message keeps its model; a pending pick resolves like the picker The opening message a create sends now stores the model and effort the chat was created with, so a pick made before its handover no longer changes what it runs on, and a card kept after a restart keeps them. A pick on its way to the host resolves through the same option view the picker shows before a send carries it, so a gone model it names is replaced too; the send selection reads only the shown snapshot. The option writer builds its own record-and-publish context, so the message path no longer duplicates it, and the client and host share one function for a send's fingerprint fields. * ci: rerun against current main
348 lines
13 KiB
TypeScript
348 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,
|
|
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 === null &&
|
|
((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 }
|
|
}
|
|
setTrackedSessionOption(state.record, id, decoded, 'dispatched', effectiveModel)
|
|
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 }]
|
|
})
|
|
}
|