Files
orca/src/shared/agent-session-failure.ts
T
Brennan Benson 2926b6e713 fix(native-chat): preserve models missing from provider listings (#35266)
* 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

* fix(native-chat): preserve models missing from provider listings

* Preserve failure causes and correlate model rejection notices

* Cover catalog omissions and explicit model defaults in client hooks

* Keep ACP model refusals on the generic rejection path

* Include model failure copy in the runtime English catalog

* Align model retention tests and bound result correlation work

* Use checked array access in result correlation contract
2026-10-29 00:44:31 -07:00

383 lines
15 KiB
TypeScript

// Write typed facts with legacy text/reason sentences that released clients print directly. Set provider detail only at composition; later strings cannot prove authorship.
import type { AgentSessionAccountKind } from './agent-session-availability'
import {
readAgentSessionArgumentProblem,
type AgentSessionArgumentProblem
} from './agent-session-argument-problem'
export type { AgentSessionArgumentProblem } from './agent-session-argument-problem'
import {
readAgentSessionUsageLimit,
type AgentSessionUsageLimit
} from './agent-session-usage-limit'
import {
readAgentSessionRequestTimeout,
type AgentSessionRequestTimeout
} from './agent-session-request-timeout'
import { structuralValuesEqualIgnoringUndefined } from './structural-value-equality'
import {
readAgentSessionRefusalReference,
type AgentSessionRefusalReference
} from './agent-session-wire-refusals'
/** One per situation with its own honest next step; a new one needs copy before it compiles. */
export const AGENT_SESSION_FAILURE_KINDS = [
/** Only an observed exit: the provider stopped while starting. */
'providerStartFailed',
/** A start that did not land, with no one to blame: Orca's fault, a failed spawn, a close. */
'startFailed',
'notSignedIn',
/** The agent's CLI is not installed where the chat runs: its spawn found no such file. */
'cliMissing',
'historyTooLarge',
'managedAccountEnvOverride',
'accountSwitchInProgress',
'managedAccountUnsupported',
'launchFolderMissing',
'historyInOtherAccount',
'claudeAccountFolderMissing',
'claudeAccountSetupFailed',
'agentCommandNotRunnable',
'runtimeNotInstalled',
/** The agent refuses Full access while it runs as root. */
'fullAccessAsRoot',
/** Orca's own launch preparation on the host failed before any agent process was spawned. */
'launchPreparationFailed',
'conversationOpenInTerminal',
'conversationOpenElsewhere',
'providerExited',
'restartFailed',
'providerRejected',
'modelUnavailable',
'claudeEffortInvalid',
'attachmentInvalid',
'attachmentUnreadable',
'emptyMessage',
/** One of the agent's own commands that its chats do not run, sent as a whole message. */
'commandUnavailable',
'queueFull',
'writeFailed',
'cancelled',
'chatClosed',
'hostRestarted',
'notDelivered',
/** A conversation command the chat's state refused when its turn to run came. */
'commandRefused',
'compactionFailed',
'compactionUnconfirmed',
'cancelUnconfirmed',
/** A Stop naming no turn reached the agent, which ended nothing while the chat read working. */
'stopRefused',
/** A Stop Orca could not save: only a refusal says it. */
'stopFailed',
/** A background-task Stop the agent answered with the task still running. */
'backgroundTaskNotStopped',
'answerUnconfirmed',
'hostFault',
/** Orca stopped an agent whose start never finished. */
'hostStopped',
/** The provider is retrying a request its API refused; not a failure yet. */
'providerRetrying',
/** The account hit one of the agent's usage limits, which ended the turn until it resets. */
'usageLimitReached',
/** A child a Stop could not prove gone: its exit is unverifiable. Kept for rows hosts wrote. */
'previousExitUnverifiable',
/** The agent could not reopen the chat's saved session, so a fresh one without its memory continues it. */
'sessionNotRestored'
] as const
export type AgentSessionFailureKind = (typeof AGENT_SESSION_FAILURE_KINDS)[number]
export function isAgentSessionFailureKind(value: unknown): value is AgentSessionFailureKind {
return typeof value === 'string' && AGENT_SESSION_FAILURE_KINDS.some((kind) => kind === value)
}
/** Kinds only a status row reports: none is ever why a message was not sent. */
const STATUS_ROW_ONLY_FAILURE_KINDS = [
'compactionFailed',
'compactionUnconfirmed',
'cancelUnconfirmed',
'stopRefused',
'stopFailed',
'backgroundTaskNotStopped',
'answerUnconfirmed',
'providerRetrying',
'usageLimitReached',
'previousExitUnverifiable',
'sessionNotRestored'
] as const satisfies readonly AgentSessionFailureKind[]
/** Why a message was not sent. A new failure kind is one of these until listed above. */
export type SubmissionRejectionKind = Exclude<
AgentSessionFailureKind,
(typeof STATUS_ROW_ONLY_FAILURE_KINDS)[number]
>
export function isSubmissionRejectionKind(value: unknown): value is SubmissionRejectionKind {
return (
isAgentSessionFailureKind(value) &&
!STATUS_ROW_ONLY_FAILURE_KINDS.some((statusOnly) => statusOnly === value)
)
}
/** `person`: written by the provider for whoever reads the chat, shown inline. `log`: a stderr
* tail or exit status, shown only behind Details. */
export type ProviderDiagnosticAudience = 'person' | 'log'
export type ProviderDiagnostic = {
text: string
audience: ProviderDiagnosticAudience
}
/** Bound stderr dumps to the lease exit-reason cap so diagnostics always fit the record. */
export const MAX_PROVIDER_DIAGNOSTIC_CHARS = 512
/** Which of Orca's checks an image failed. A new one needs a sentence before it compiles. */
export const AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS = [
'empty',
'tooLarge',
'tooMany',
'totalTooLarge',
'unsupportedType',
'notAFile',
'noSource'
] as const
export type AgentSessionAttachmentProblemReason =
(typeof AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS)[number]
export type AgentSessionAttachmentProblem = {
reason: AgentSessionAttachmentProblemReason
/** The limit it broke: bytes for `tooLarge` and `totalTooLarge`, a count for `tooMany`. */
limit?: number
}
/** What the provider said it is retrying, in its own fields. */
export type AgentSessionProviderRetry = {
/** The provider's error type, e.g. `rate_limit` or `overloaded`. */
error?: string
/** The HTTP status the request failed with. */
status?: number
/** The provider's own account of what failed, written for a person: the row's second line. */
cause?: string
/** Which retry this is, counted from 1. */
attempt?: number
/** The most retries the provider makes before it gives up. */
maxRetries?: number
}
export type AgentSessionFailureFact = {
kind: AgentSessionFailureKind
account?: AgentSessionAccountKind
/** Provider diagnostic, the rejected model ID on `modelUnavailable`, or the host error on `launchPreparationFailed`; never Orca's sentence. */
detail?: ProviderDiagnostic
/** On `restartFailed` and `startFailed`: the refusal that kept the agent from starting. On
* `commandRefused`: the refusal that kept the command from running. */
refusal?: AgentSessionRefusalReference
/** On `attachmentInvalid`: which check the attachment failed. */
attachment?: AgentSessionAttachmentProblem
/** On `providerRetrying`: why the provider is retrying. */
retry?: AgentSessionProviderRetry
/** On `usageLimitReached`: which limit, and when it resets. */
usageLimit?: AgentSessionUsageLimit
/** A safe option name from Orca's saved Arguments parser, never an error message. */
argumentProblem?: AgentSessionArgumentProblem
/** Host-only proof the provider never received the message: Stop owes stopped history and a refused carrier owes history again. Absence means unknown; clients read kind. */
neverReachedAgent?: true
/** On `backgroundTaskNotStopped`: one named task, or every task the Stop reached. */
backgroundStop?: AgentSessionBackgroundStop
/** On `startFailed`: the provider request that got no answer before Orca's deadline. */
requestTimeout?: AgentSessionRequestTimeout
}
export type AgentSessionBackgroundStop = 'task' | 'all'
/** Persisted kinds may be newer; place them only through readAgentSessionFailureFact or the rejection classifier. */
export type UnreadAgentSessionFailureFact = { kind: string }
/** A fact a rejected message may carry. */
export type SubmissionRejectionFact = AgentSessionFailureFact & { kind: SubmissionRejectionKind }
export function isSubmissionRejectionFact(
fact: AgentSessionFailureFact
): fact is SubmissionRejectionFact {
return isSubmissionRejectionKind(fact.kind)
}
export function providerDiagnostic(
text: string,
audience: ProviderDiagnosticAudience
): ProviderDiagnostic | undefined {
const bounded = text.trim().slice(0, MAX_PROVIDER_DIAGNOSTIC_CHARS).trim()
return bounded ? { text: bounded, audience } : undefined
}
export function agentSessionFailureFact<TKind extends AgentSessionFailureKind>(
kind: TKind,
extra: {
account?: AgentSessionAccountKind
detail?: ProviderDiagnostic
refusal?: AgentSessionRefusalReference
attachment?: AgentSessionAttachmentProblem
retry?: AgentSessionProviderRetry
usageLimit?: AgentSessionUsageLimit
argumentProblem?: AgentSessionArgumentProblem
neverReachedAgent?: true
backgroundStop?: AgentSessionBackgroundStop
requestTimeout?: AgentSessionRequestTimeout
} = {}
): AgentSessionFailureFact & { kind: TKind } {
// Re-bounded here, so no writer can store more than the cap however it built the detail.
const detail = extra.detail
? providerDiagnostic(extra.detail.text, extra.detail.audience)
: undefined
return {
kind,
...(extra.account ? { account: extra.account } : {}),
...(detail ? { detail } : {}),
...(extra.refusal ? { refusal: extra.refusal } : {}),
...(extra.attachment ? { attachment: extra.attachment } : {}),
...(extra.retry ? { retry: extra.retry } : {}),
...(extra.usageLimit ? { usageLimit: extra.usageLimit } : {}),
...(extra.argumentProblem ? { argumentProblem: extra.argumentProblem } : {}),
...(extra.neverReachedAgent ? { neverReachedAgent: true as const } : {}),
...(extra.backgroundStop ? { backgroundStop: extra.backgroundStop } : {}),
...(extra.requestTimeout ? { requestTimeout: extra.requestTimeout } : {})
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function isProviderDiagnostic(value: unknown): value is ProviderDiagnostic {
return (
isRecord(value) &&
typeof value.text === 'string' &&
(value.audience === 'person' || value.audience === 'log')
)
}
function readAttachmentProblem(value: unknown): AgentSessionAttachmentProblem | undefined {
if (!isRecord(value)) {
return undefined
}
const reason = AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS.find((known) => known === value.reason)
if (!reason) {
return undefined
}
const limit = value.limit
return typeof limit === 'number' && Number.isFinite(limit) && limit > 0
? { reason, limit }
: { reason }
}
/** A retry as a reader meets it; undefined when it names no field. */
export function readProviderRetry(value: unknown): AgentSessionProviderRetry | undefined {
if (!isRecord(value)) {
return undefined
}
const error =
typeof value.error === 'string' && value.error.trim() ? value.error.trim() : undefined
const status = positiveInteger(value.status)
const cause =
typeof value.cause === 'string' ? providerDiagnostic(value.cause, 'person')?.text : undefined
const attempt = positiveInteger(value.attempt)
// A maximum bounds a retry; alone, or below the retry it bounds, it says nothing.
const max = positiveInteger(value.maxRetries)
const maxRetries = attempt && max && max >= attempt ? max : undefined
return error || status || cause || attempt
? {
...(error ? { error } : {}),
...(status ? { status } : {}),
...(cause ? { cause } : {}),
...(attempt ? { attempt } : {}),
...(maxRetries ? { maxRetries } : {})
}
: undefined
}
function positiveInteger(value: unknown): number | undefined {
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined
}
/** Return undefined for unplaceable/newer facts so readers use their no-fact fallback. */
export function readAgentSessionFailureFact(value: unknown): AgentSessionFailureFact | undefined {
if (!isRecord(value) || !isAgentSessionFailureKind(value.kind)) {
return undefined
}
const refusal = readAgentSessionRefusalReference(value.refusal)
const attachment = readAttachmentProblem(value.attachment)
const retry = readProviderRetry(value.retry)
const usageLimit = readAgentSessionUsageLimit(value.usageLimit)
const argumentProblem = readAgentSessionArgumentProblem(value.argumentProblem)
const requestTimeout = readAgentSessionRequestTimeout(value.requestTimeout)
return agentSessionFailureFact(value.kind, {
...(value.account === 'managed' || value.account === 'system'
? { account: value.account }
: {}),
...(isProviderDiagnostic(value.detail) ? { detail: value.detail } : {}),
...(refusal ? { refusal } : {}),
...(attachment ? { attachment } : {}),
...(retry ? { retry } : {}),
...(usageLimit ? { usageLimit } : {}),
...(argumentProblem ? { argumentProblem } : {}),
...(value.neverReachedAgent === true ? { neverReachedAgent: true as const } : {}),
...(value.backgroundStop === 'task' || value.backgroundStop === 'all'
? { backgroundStop: value.backgroundStop }
: {}),
...(requestTimeout ? { requestTimeout } : {})
})
}
/** Reword only fully understood facts; dropped/newer details may change the advice, so otherwise keep the host sentence. */
export function readWholeAgentSessionFailureFact(
value: unknown
): AgentSessionFailureFact | undefined {
const fact = readAgentSessionFailureFact(value)
return fact && structuralValuesEqualIgnoringUndefined(fact, value) ? fact : undefined
}
/** Read provider-authored diagnostics through cause chains retained by wrappers such as acquisition errors. */
export function providerDiagnosticOf(error: unknown): ProviderDiagnostic | undefined {
return providerDiagnosticWithin(error, 0)
}
// One depth bound across `cause` and aggregated errors, so an error that contains itself ends.
function providerDiagnosticWithin(error: unknown, start: number): ProviderDiagnostic | undefined {
let current: unknown = error
for (let depth = start; depth < 6 && current instanceof Error; depth += 1) {
if ('providerDiagnostic' in current && isProviderDiagnostic(current.providerDiagnostic)) {
return current.providerDiagnostic
}
if (current instanceof AggregateError) {
for (const inner of current.errors) {
const found = providerDiagnosticWithin(inner, depth + 1)
if (found) {
return found
}
}
}
current = current.cause
}
return undefined
}
/** Attaches the provider's words to the error Orca built around them. */
export function withProviderDiagnostic<TError extends Error>(
error: TError,
diagnostic: ProviderDiagnostic | undefined
): TError {
return diagnostic ? Object.assign(error, { providerDiagnostic: diagnostic }) : error
}
/** The detail behind a failure's Details: provider words, or a log tail such as a CLI's startup stderr.
* A retry's log is the raw frame its row already shows. */
export function agentSessionFailureDetailText(
fact: AgentSessionFailureFact | undefined
): string | undefined {
const detail = fact?.detail
return detail && !(detail.audience === 'log' && fact.kind === 'providerRetrying')
? detail.text
: undefined
}