Files
orca/src/shared/agent-session-failure-words.ts
T
Brennan Benson 4833b736f7 fix(native-chat): preserve Claude approval choices across model changes (#30914)
* fix(native-chat): a Claude chat saved as Approve for me keeps it when Orca can't read its model list

A chat's auto mode dropped to Ask whenever the saved model list was missing
or couldn't name the chat's model (another account home, first start in a
folder, an unlisted model id), so a resumed chat launched asking for approval.
Auto now drops to Ask only when the list shows the model without it; Claude
itself starts a model that can't run auto in its default mode.

Tracked in STA-10953.

* fix(native-chat): a model list from a CLI older than auto mode still reads as Ask

A CLI that marks no model with auto support predates the auto value (2.1.71)
and would refuse --permission-mode auto, so its listing is evidence, not a gap.

Tracked in STA-10953.

* docs(chat): correct auto support reporting version

* test(chat): type saved launch option cases

* Honor requested chat permissions across model changes

* Match outgrown child fixture to journal null contract

* fix(native-chat): preserve admitted input and current mail choices

* perf(native-chat): derive wake debt and held sends once per retirement decision

The retirement check derived wake debt (a child-work projection and a scan
for the newest turn) before asking whether open work had already decided
the outcome, and its admitted-input fallback rebuilt the provider's held-send
set once per unanswered submission, O(S x W) per decision.

Open work is now read first and wake debt only where it picks the outcome or
the refusal reason. Current work gains one batch query that reads the held
set and the handover set once, sharing the single holds rule with the
per-send reader. Outcomes and refusal reasons are unchanged.

Adds N/10N operation-count contracts on the real Claude host rig: two
retirement checks per ordinary send, each one pass over history, with a
control that the second sees a sign-out reported between them; no wake debt
mid-turn; one held-set read per refusal in a queued model-change burst.

* Keep active Claude input on its turn choices

* Fix chat admission type imports and cover turn-scan costs

* Preserve root lifecycle delivery under journal pressure

* test: construct Codex mail fixture with its source

* test: expect root retention metadata in Claude turn timing

* test: cover captured Claude full-ID Auto launch

* test(native-chat): explain queued-send test timeout

* test: supply explicit mailbox working policy
2026-10-25 17:53:19 -07:00

345 lines
15 KiB
TypeScript

// Build legacy text/reason/error and newer typed facts together. Quote provider text only when authored for a person; shared sentence pieces let desktop translate the same fact.
import {
isSubmissionRejectionFact,
type AgentSessionFailureFact,
type AgentSessionFailureKind,
type ProviderDiagnostic,
type SubmissionRejectionFact,
type SubmissionRejectionKind
} from './agent-session-failure'
import type { AgentSessionConversationCommand } from './agent-session/agent-session-conversation-command'
import { cliMissingSentence, notSignedInSentence } from './agent-session-availability-sentences'
import {
agentCliInstallationFailureWords,
sayAgentSessionFailureEnglish,
type AgentSessionFailureCopyId,
type AgentSessionFailureCopyValues,
type AgentSessionFailureSay
} from './agent-session-failure-copy'
import { START_REFUSAL_RESUMABLE } from './agent-session-start-refusal-resumable'
import { providerRetryWords, withRetryCause } from './agent-session-provider-retry-words'
import { usageLimitWords } from './agent-session-usage-limit-words'
import type { AgentSessionUsageLimitResetWords } from './agent-session-usage-limit-words'
import { commandRefusedByReason } from './agent-session-command-refusal-words'
import { attachmentProblemSentence } from './agent-session-attachment-failure-words'
import { joinSentences } from './sentence-joining'
import { requestTimeoutSeconds } from './agent-session-request-timeout'
import {
DISPATCH_REJECTED_CANCELLED,
DISPATCH_REJECTED_CODEX_QUEUE_FULL,
DISPATCH_REJECTED_QUEUE_FULL,
DISPATCH_REJECTED_WRITE_FAILED
} from './structured-agent-session-dispatch-rejection'
declare const failureSentence: unique symbol
/** Only agentSessionFailureWords constructs these, preventing competing wording beside a fact. */
export type AgentSessionFailureSentence = string & { readonly [failureSentence]: true }
export type AgentSessionFailureRowWords = {
text: AgentSessionFailureSentence
failure: AgentSessionFailureFact
}
/** Legacy sentence/hidden marker and the newer fact, written together by the host. */
export type AgentJournalDispatchRejection = {
reason: AgentSessionFailureSentence
rejection: SubmissionRejectionFact
}
/** row describes the chat; rejection describes a rejected message; answer describes a refused approval/question answer. */
export type AgentSessionFailureSurface = 'row' | 'rejection' | 'answer'
export type AgentSessionFailureWordsContext = {
/** The chat's agent, when the writer knows it. */
agentName?: string
/** Names the legacy queue-full marker; without it a full queue is worded as a sentence. */
provider?: 'claude' | 'codex'
/** Retry the failed start's conversation command instead of sending a message. */
command?: AgentSessionConversationCommand
/** Omit retry advice when the surface already retries or offers its own Retry control. */
retryControl?: boolean
/** A direct create has no submitted message to send again. */
messageSubmitted?: boolean
/** How this reader says a usage limit's reset; without it the words name no time. */
usageLimitReset?: AgentSessionUsageLimitResetWords
}
/** Person-facing provider text is quoted, but bounded so the sentence stays one. */
const MAX_QUOTED_DETAIL_CHARS = 512
type Sentence = (
context: AgentSessionFailureWordsContext,
fact: AgentSessionFailureFact,
surface: AgentSessionFailureSurface,
say: AgentSessionFailureSay
) => string
function agent(say: AgentSessionFailureSay, { agentName }: AgentSessionFailureWordsContext) {
return { agent: agentName ?? say('theAgent') }
}
function namingAgent(id: AgentSessionFailureCopyId): Sentence {
return (context, _fact, _surface, say) => say(id, agent(say, context))
}
function quotingPersonDetail(
say: AgentSessionFailureSay,
lead: AgentSessionFailureCopyId,
quotedLead: AgentSessionFailureCopyId,
detail: ProviderDiagnostic | undefined,
values: AgentSessionFailureCopyValues = {}
): string {
const quoted =
detail?.audience === 'person'
? detail.text
.slice(0, MAX_QUOTED_DETAIL_CHARS)
.trim()
.replace(/[.\s]+$/, '')
: ''
return quoted ? say(quotedLead, { ...values, detail: quoted }) : say(lead, values)
}
/** The next step after a start or restart that failed: the command, or the message, again. */
function startRetry(
say: AgentSessionFailureSay,
{ command, retryControl, messageSubmitted }: AgentSessionFailureWordsContext,
sendAgain: 'sendToTryAgain' | 'sendAgainToTryOnceMore' = 'sendToTryAgain'
): string[] {
if (retryControl) {
return []
}
return [
command
? say('runCommandAgain', { command })
: say(messageSubmitted === false ? 'sendToTryAgain' : sendAgain)
]
}
function couldNot(verb: 'couldNotStart' | 'couldNotRestart'): Sentence {
return (context, fact, _surface, say) => {
const installationFailure = agentCliInstallationFailureWords(fact.refusal, say)
if (installationFailure) {
return installationFailure
}
if (!context.command && fact.refusal?.details?.reason === 'messagesUnsettled') {
return joinSentences([say('agentStillWorking'), say('waitForTurn')])
}
const failed = say(verb, agent(say, context))
const blocked = commandRefusedByReason(say, context, fact)
if (blocked) {
return joinSentences([failed, blocked, ...startRetry(say, context)])
}
if (fact.argumentProblem) {
const problemCopy = {
unsupportedOption: 'argumentsUnsupportedOption',
missingValue: 'argumentsMissingValue',
multipleValues: 'argumentsMultipleValues',
positionalPrompt: 'argumentsPositionalPrompt'
} as const
return joinSentences([
failed,
say(problemCopy[fact.argumentProblem.problem], { option: fact.argumentProblem.option }),
say('editSavedArguments'),
...startRetry(say, context)
])
}
if (fact.requestTimeout) {
return joinSentences([
say('startTimedOut', {
...agent(say, context),
method: fact.requestTimeout.method,
seconds: requestTimeoutSeconds(fact.requestTimeout)
}),
...startRetry(say, context),
// No cause named: MCP startup, the network or config can stall a start too.
say('startTimedOutKeepsHappening')
])
}
// Only a terminal agent an older build recorded holds a claim; quitting it frees the chat.
if (fact.refusal?.details?.reason === 'claimConflicted') {
return joinSentences([failed, say('terminalAgentHoldsChat'), say('quitTerminalAgent')])
}
// The previous process may still run, so nothing started: that, never that it exited.
if (fact.refusal?.details?.reason === 'previousExitUnverifiable') {
return joinSentences([
say('previousExitUnverifiable', agent(say, context)),
...startRetry(say, context, 'sendAgainToTryOnceMore')
])
}
const code = fact.refusal?.code
return joinSentences(
code && !START_REFUSAL_RESUMABLE[code]
? [failed, say('startNewChat')]
: [failed, ...startRetry(say, context)]
)
}
}
const FAILURE_SENTENCES = {
providerStartFailed: (context, _fact, _surface, say) =>
joinSentences([say('providerStartFailed', agent(say, context)), ...startRetry(say, context)]),
startFailed: couldNot('couldNotStart'),
notSignedIn: (context, fact, surface, say) => notSignedInSentence(context, fact, say, surface),
cliMissing: (context, _fact, _surface, say) => cliMissingSentence(context, say),
historyTooLarge: (_context, _fact, _surface, say) =>
joinSentences([say('historyTooLarge'), say('startNewChat')]),
managedAccountEnvOverride: (_context, _fact, _surface, say) => say('managedAccountEnvOverride'),
accountSwitchInProgress: namingAgent('accountSwitchInProgress'),
launchFolderMissing: (_context, _fact, _surface, say) => say('launchFolderMissing'),
historyInOtherAccount: (_context, _fact, _surface, say) => say('historyInOtherAccount'),
claudeAccountFolderMissing: (_context, _fact, _surface, say) => say('claudeAccountFolderMissing'),
claudeAccountSetupFailed: (_context, _fact, _surface, say) => say('claudeAccountSetupFailed'),
agentCommandNotRunnable: namingAgent('agentCommandNotRunnable'),
runtimeNotInstalled: namingAgent('runtimeNotInstalled'),
conversationOpenInTerminal: namingAgent('conversationOpenInTerminal'),
conversationOpenElsewhere: (_context, _fact, _surface, say) => say('conversationOpenElsewhere'),
managedAccountUnsupported: (context, _fact, _surface, say) =>
joinSentences([
say('managedAccountUnsupported'),
context.retryControl || context.messageSubmitted === false
? say('chooseClaudeAccount')
: context.command
? say('chooseClaudeAccountThenRunCommand', { command: context.command })
: say('chooseClaudeAccountThenSend')
]),
providerExited: (context, _fact, surface, say) =>
say(
surface === 'row'
? 'providerExitedRow'
: surface === 'answer'
? 'providerExitedAnswer'
: 'providerExitedRejection',
agent(say, context)
),
restartFailed: couldNot('couldNotRestart'),
providerRejected: (context, fact, _surface, say) =>
quotingPersonDetail(
say,
'providerRejected',
'providerRejectedQuoted',
fact.detail,
agent(say, context)
),
attachmentInvalid: (context, fact, _surface, say) =>
fact.attachment
? attachmentProblemSentence(say, context, fact.attachment)
: say('attachmentInvalid'),
attachmentUnreadable: (_context, _fact, _surface, say) => say('attachmentUnreadable'),
emptyMessage: (_context, _fact, _surface, say) => say('emptyMessage'),
commandUnavailable: namingAgent('commandUnavailable'),
queueFull: (_context, _fact, _surface, say) => say('queueFull'),
writeFailed: (_context, _fact, _surface, say) => say('writeFailed'),
cancelled: (_context, _fact, _surface, say) => say('cancelled'),
chatClosed: (_context, _fact, _surface, say) => say('chatClosed'),
hostRestarted: (_context, _fact, _surface, say) => say('hostRestarted'),
notDelivered: ({ retryControl }, _fact, _surface, say) =>
say(retryControl ? 'notDelivered' : 'notDeliveredSendAgain'),
commandRefused: (context, fact, _surface, say) =>
commandRefusedByReason(say, context, fact) ??
say(context.retryControl ? 'commandRefused' : 'commandRefusedTryAgain'),
compactionFailed: (_context, fact, _surface, say) =>
quotingPersonDetail(say, 'compactionFailed', 'compactionFailedQuoted', fact.detail),
compactionUnconfirmed: (_context, _fact, _surface, say) => say('compactionUnconfirmed'),
cancelUnconfirmed: (_context, _fact, _surface, say) => say('cancelUnconfirmed'),
// The agent was reached and declined, so the sentence says that, not that the Stop was lost.
stopRefused: (context, fact, _surface, say) =>
fact.detail?.audience === 'person'
? quotingPersonDetail(
say,
'stopRefused',
'stopRefusedQuoted',
fact.detail,
agent(say, context)
)
: say('noTurnToStop', agent(say, context)),
// Mid-sentence, so an unnamed agent is not capitalized.
stopFailed: ({ agentName }, _fact, _surface, say) =>
agentName ? say('couldNotStop', { agent: agentName }) : say('couldNotStopTheAgent'),
backgroundTaskNotStopped: (_context, fact, _surface, say) =>
say(fact.backgroundStop === 'task' ? 'backgroundTaskNotStopped' : 'backgroundTasksNotStopped'),
answerUnconfirmed: (_context, _fact, _surface, say) => say('answerUnconfirmed'),
hostFault: ({ retryControl }, _fact, _surface, say) =>
say(retryControl ? 'hostFault' : 'hostFaultTryAgain'),
hostStopped: (context, _fact, _surface, say) => say('hostStopped', agent(say, context)),
// A provider that says how its retry is going, for a person, is quoted: that is the progress.
providerRetrying: (context, { retry, detail }, _surface, say) =>
detail?.audience === 'person'
? withRetryCause(
quotingPersonDetail(
say,
'providerRetrying',
'providerRetryingQuoted',
detail,
agent(say, context)
),
retry?.cause
)
: providerRetryWords(say, agent(say, context), retry),
usageLimitReached: (context, { usageLimit }, _surface, say) =>
usageLimitWords(say, agent(say, context), usageLimit, {
resetWords: context.usageLimitReset,
continueControl: context.retryControl
}),
previousExitUnverifiable: namingAgent('previousExitUnverifiable'),
sessionNotRestored: namingAgent('sessionNotRestored')
} satisfies Record<AgentSessionFailureKind, Sentence>
/** The sentence a person reads for this fact on this surface; never a marker. */
export function agentSessionFailureSentence(
fact: AgentSessionFailureFact,
surface: AgentSessionFailureSurface,
context: AgentSessionFailureWordsContext = {},
/** Desktop passes its translations; the host and the phone keep English. */
say: AgentSessionFailureSay = sayAgentSessionFailureEnglish
): string {
return FAILURE_SENTENCES[fact.kind](context, fact, surface, say)
}
/** Preserve markers released clients hide. A write failure keeps the bare marker; its error stays in the log. */
const LEGACY_REJECTION_MARKERS: Partial<
Record<SubmissionRejectionKind, (context: AgentSessionFailureWordsContext) => string | undefined>
> = {
cancelled: () => DISPATCH_REJECTED_CANCELLED,
writeFailed: () => DISPATCH_REJECTED_WRITE_FAILED,
queueFull: ({ provider }) =>
provider === 'codex'
? DISPATCH_REJECTED_CODEX_QUEUE_FULL
: provider === 'claude'
? DISPATCH_REJECTED_QUEUE_FULL
: undefined
}
/** The words a status row reporting this fact records. */
export function agentSessionFailureWords(
fact: AgentSessionFailureFact,
context: AgentSessionFailureWordsContext & { surface: 'row' }
): AgentSessionFailureRowWords
/** The words a message rejected for this fact records. */
export function agentSessionFailureWords(
fact: SubmissionRejectionFact,
context: AgentSessionFailureWordsContext & { surface: 'rejection' }
): AgentJournalDispatchRejection
export function agentSessionFailureWords(
fact: AgentSessionFailureFact,
context: AgentSessionFailureWordsContext & { surface: AgentSessionFailureSurface }
): AgentSessionFailureRowWords | AgentJournalDispatchRejection {
if (context.surface === 'row') {
return { text: branded(agentSessionFailureSentence(fact, 'row', context)), failure: fact }
}
// The rejection overload admits only these; a caller that got past the types is Orca's bug.
if (!isSubmissionRejectionFact(fact)) {
throw new Error(`agent session failure kind ${fact.kind} cannot reject a message`)
}
const words =
LEGACY_REJECTION_MARKERS[fact.kind]?.(context) ??
agentSessionFailureSentence(fact, 'rejection', context)
return { reason: branded(words), rejection: fact }
}
function branded(words: string): AgentSessionFailureSentence {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only `agentSessionFailureWords` calls this, with words from the table or a legacy marker for its fact.
return words as AgentSessionFailureSentence
}