mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 11:07:42 +00:00
* 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
199 lines
8.0 KiB
TypeScript
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
|
|
}
|