Files
orca/src/shared/agent-session-write-failure.ts
T
Brennan Benson 4735ecefac fix(native-chat): a /compact sent while the agent works waits in line instead of being refused (#25704)
* fix(native-chat): a /compact sent while the agent works waits in line instead of being refused

* test(native-chat): build the admission test's submissions without a type assertion

* fix(native-chat): a /clear refused while the agent works says so plainly; a /compact behind an unsent message waits for it quietly

* test(native-chat): name the admission test's cases for the sends they model

* fix(native-chat): a /compact refused while the agent works reads plainly too; a command card offers no Edit

* test(native-chat): a refused /compact reads the plain sentence

* fix(native-chat): a refused command card is spent and said once; a command card offers Send while the agent is idle; /compact behind an unsent message is not taken

* test(native-chat): keep the queued receipt's narrowing inside the wait

* fix(native-chat): spend a command card only when its own turn says why; refuse a queued command without a hand-off, with its reason; a queue send on its way reads as sending

* fix(native-chat): a recorded send is no sending card; queue lists land at once; a spent card's wait says to run it again; only /compact or /clear give way to returned text

* refactor(native-chat): derive the sending cards inside the queued-messages hook

* fix(native-chat): keep protocol-version and the agent-session params under the line limit; the send capabilities get their own module

* fix(native-chat): with the agent idle, a command behind this window's own unsent message says it is still being sent

* fix(native-chat): the queued-commands capability stays a plain constant beside the others; main's send capabilities back to its text

* test(native-chat): keep the rewind suite under its line limit after main's growth

* fix(native-chat): one import per module in the command send; a rewind on its way holds commands, tested

* test(native-chat): type the rewind module mock with a type import

* test(native-chat): a command card cut short by a restart waits or is kept like any card; kept-card captions name only controls shown

* fix(native-chat): a held command card, which the queue skips, no longer forces later sends to queue

* fix(native-chat): a command's refusal line goes once what it waits on is gone

A refusal that names something the chat shows (the agent working, a pending prompt, background
tasks, an unsent message) is said only while that still holds, derived each render, and dropped
once it ends. Other composer errors are unchanged.

* fix(mobile): a command's refusal banner goes once what it waits on is gone

The phone's banner keeps a refusal naming the agent working or a pending prompt only while the
chat still shows it, dropped each render once it ends. Background tasks, which the phone can't
see, and generic errors behave as before. The host-reason mapping is shared with desktop.

* fix(native-chat): a refusal behind an earlier message names whether it is being sent or offers Retry

Each line now goes when its own state ends: a send that fails stops being sent, and a resend
stops offering Retry.

* test(native-chat): a Stop behind a typed /compact keeps the taken-back message on screen; the in-doubt case says so

* fix(native-chat): a command refused while the queue is about to send reads working, as the pane does

* Update held command fixture for the merged queue model

* Restart one host at a time in the recovery ordering test

* fix: preserve person queue reserve for chat commands
2026-10-08 13:32:20 -07:00

199 lines
8.0 KiB
TypeScript

// A chat write that did not happen, as a fact: what the person was doing, and what the host said.
// Saved with a queued message, so it holds only what stays true after a reload; the words are
// chosen from it when it is shown (`agent-session-refusal-notice.ts`).
import type { AgentSessionAccountKind } from './agent-session-availability'
import {
readAgentSessionRefusalDetails,
type AgentSessionRefusalReason
} from './agent-session-refusal-details'
import type { AgentSessionRewindReason } from './agent-session-rewind'
import type { AgentSessionArgumentProblem } from './agent-session-argument-problem'
import {
isAgentSessionWireRefusalCode,
readAgentSessionRefusalReference,
type AgentSessionOwnerVerdict,
type AgentSessionRefusalReference,
type AgentSessionWireRefusal,
type AgentSessionWireRefusalCode
} from './agent-session-wire-refusals'
/** What the person was doing, which decides what the notice says did not happen. `send` keeps
* the message behind a Retry control; `composer-send` puts it back in the composer, as the phone
* does. `read-history` is opening the chat to show its history, which writes nothing. */
export type AgentSessionWriteKind =
| 'read-history'
| 'send'
| 'composer-send'
| 'stop'
| 'stop-task'
| 'stop-tasks'
| 'answer'
| 'option'
| 'command'
/** A /clear or /compact: refused while the agent works, in words of its own. */
| 'clear'
| 'compact'
| 'goal'
/** The kind of write an `agentSession.*` call stands for. */
export function agentSessionWriteKindForMethod(
fingerprintMethod: string,
fields: Record<string, unknown>
): AgentSessionWriteKind {
if (fingerprintMethod === 'agentSession.send') {
return 'send'
}
if (fingerprintMethod === 'agentSession.cancel') {
// A background-task stop never asked the agent to stop.
if (fields.scope !== 'background-tasks') {
return 'stop'
}
return typeof fields.taskId === 'string' ? 'stop-task' : 'stop-tasks'
}
if (fingerprintMethod.startsWith('agentSession.respondTo')) {
return 'answer'
}
if (fingerprintMethod === 'agentSession.setOption') {
return 'option'
}
if (fingerprintMethod === 'agentSession.threadGoal') {
return 'goal'
}
if (
fingerprintMethod === 'agentSession.conversationCommand' &&
(fields.command === 'clear' || fields.command === 'compact')
) {
return fields.command
}
return 'command'
}
/** What a saved refusal keeps beside its reason: facts about the refused operation that stay true
* after a reload. Never the fence, revision or resolution, which move, nor any provider text. */
type DurableRefusalFacts = {
agent_session_operation_invalid: {
rewindReason?: AgentSessionRewindReason
account?: AgentSessionAccountKind
argumentProblem?: AgentSessionArgumentProblem
}
agent_session_operation_unknown: { rewindReason?: AgentSessionRewindReason }
/** A snapshot from when it was refused; see `agentSessionOwnerVerdictAllowsFreshOperationId`. */
agent_session_ownership_unknown: { ownerVerdict?: AgentSessionOwnerVerdict }
}
const DURABLE_FACT_KEYS: Partial<Record<AgentSessionWireRefusalCode, readonly string[]>> = {
agent_session_operation_invalid: ['rewindReason', 'account', 'argumentProblem'],
agent_session_operation_unknown: ['rewindReason'],
agent_session_ownership_unknown: ['ownerVerdict']
} satisfies { [C in keyof DurableRefusalFacts]: readonly (keyof DurableRefusalFacts[C])[] }
export type AgentSessionWriteRefusalDetails<C extends AgentSessionWireRefusalCode> = {
reason?: AgentSessionRefusalReason<C>
} & (C extends keyof DurableRefusalFacts ? DurableRefusalFacts[C] : Record<never, never>)
/** A refused write: its code, and what the host said about it that outlives the reply. */
export type AgentSessionWriteRefusal = {
[C in AgentSessionWireRefusalCode]: {
kind: 'refused'
code: C
/** Absent from older hosts and from entries saved before refusals carried a reason. */
details?: AgentSessionWriteRefusalDetails<C>
}
}[AgentSessionWireRefusalCode]
/** Why a write did not happen, or that nothing proves it did not. */
export type AgentSessionWriteFailure =
| AgentSessionWriteRefusal
/** The request failed without a refusal before the host ran it, so nothing is known about why. */
| { kind: 'failed' }
/** The request failed where the host may already have run it (a timeout, a lost connection, an
* error inside the method). */
| { kind: 'unconfirmed' }
/** The refusal as a write keeps it. Details are read against the code, so a reason or verdict
* another build added is dropped and the code's own words stand. */
function agentSessionWriteRefusal(
code: AgentSessionWireRefusalCode,
details: unknown
): AgentSessionWriteRefusal {
const durable: readonly string[] = ['reason', ...(DURABLE_FACT_KEYS[code] ?? [])]
const kept = Object.fromEntries(
Object.entries(readAgentSessionRefusalDetails(code, details) ?? {}).filter(([key]) =>
durable.includes(key)
)
)
const refusal = {
kind: 'refused',
code,
...(Object.keys(kept).length > 0 ? { details: kept } : {})
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `kept` holds only a reason and facts read against `code` and allowed for it above.
return refusal as AgentSessionWriteRefusal
}
export function agentSessionRefusalFailure(
refusal: Pick<AgentSessionWireRefusal, 'code' | 'details'>
): AgentSessionWriteRefusal {
// A newer host can send a code this client has never heard of; its details mean nothing here.
return isAgentSessionWireRefusalCode(refusal.code)
? agentSessionWriteRefusal(refusal.code, refusal.details)
: { kind: 'refused', code: refusal.code }
}
/** A request that threw, from the RPC error code the host answered with (undefined when none came
* back). Only a host that turned it away before running the method proves the write did not
* happen. */
export function agentSessionRpcErrorFailure(code: string | undefined): AgentSessionWriteFailure {
if (code === 'method_not_found' || code === 'method_not_supported') {
return { kind: 'refused', code: 'structured_agent_session_unsupported' }
}
return code === 'invalid_argument' || code === 'unauthorized'
? { kind: 'refused', code: 'agent_session_operation_invalid' }
: { kind: 'unconfirmed' }
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
/** The refusal a failed request's error carries in its data. Takes both shapes a client meets: an
* RPC call's thrown error, whose payload is on `response.error`, and the payload a stream hands
* its error callback. Undefined from an older host, or for a failure that was not a refusal. */
export function readAgentSessionErrorRefusal(
error: unknown
): AgentSessionRefusalReference | undefined {
const payload =
isRecord(error) && isRecord(error.response) && isRecord(error.response.error)
? error.response.error
: error
const data = isRecord(payload) ? payload.data : undefined
return isRecord(data) ? readAgentSessionRefusalReference(data.refusal) : undefined
}
/** What to say about a request that threw: the host's refusal when its error carried one, else
* what the RPC error code proves. Words only: whether the write may have happened stays the
* caller's own classification. */
export function agentSessionThrownFailure(
error: unknown,
rpcCode: string | undefined
): AgentSessionWriteFailure {
const refusal = readAgentSessionErrorRefusal(error)
return refusal ? agentSessionRefusalFailure(refusal) : agentSessionRpcErrorFailure(rpcCode)
}
/** A saved failure, or undefined when it is not one this build wrote. */
export function parseAgentSessionWriteFailure(
value: unknown
): AgentSessionWriteFailure | undefined {
if (typeof value !== 'object' || value === null || !('kind' in value)) {
return undefined
}
if (value.kind === 'failed') {
return { kind: 'failed' }
}
return value.kind === 'refused' && 'code' in value && isAgentSessionWireRefusalCode(value.code)
? agentSessionWriteRefusal(value.code, 'details' in value ? value.details : undefined)
: undefined
}