Files
orca/src/shared/structured-agent-session-outbox.ts
T
Brennan Benson 07e9fdfd13 fix(native-chat): a message the chat said was not sent is never sent later on its own (#24232)
* fix(native-chat): keep a message the chat said was not sent held until its Retry

A native-chat send the host refused (for example "Chats were saved by a newer
Orca. Your message was not sent.") or that never reached the host showed "not
sent" with a Retry button, but the hold that stopped it lived only in the
outbox hook's memory. The message itself was saved in the outbox, so the next
launch lifted the hold and sent it with no Retry; a copy the user retyped in
the meantime was held behind it and went out as well.

The hold is now read from the failure the message already saves: a queued
entry that carries its last failure waits for the user's Retry, on this launch
and every later one, and an entry saved by an earlier build in that shape is
held too. The drain passes over a held message instead of stopping behind it,
so what the user sends next goes out as they send it. Retry clears the saved
failure. On a host from before accepted-send, a new agent owner observed while
the chat is open still sends the refused message again, as before; a relaunch
does not.

A held message keeps its operation id, and a host refuses an id older than a
day as expired for good, so its Retry could never go through; a new id could
deliver a message an earlier attempt already delivered. Such a message now
goes back to the composer with a notice to check the chat before sending it
again, and leaves the outbox.

* fix(native-chat): keep refused messages as rows until Retry, and only release them for an older host's new owner

- A message the host refuses as expired under an id it kept stays a saved row
  reading "Orca couldn't confirm what happened. Check the chat.", and its Retry
  sends it under a new id. It no longer moves into the message box, where an
  automatic resend after a relaunch could put text the user never asked for,
  held only in memory.
- An owner change releases a refused message only on a host known to predate
  accepted sends, and only for the refusals such a host gives while it restarts
  the chat's agent. Those rows say Orca will send it again when the agent
  restarts, beside their Retry. A host whose capability check has not answered,
  or failed, no longer releases anything.
- Every failed message ahead of the one the queue stopped on keeps its Retry,
  since that Retry sends it at once.
- A journal row saying the host cannot tell whether a message landed, and the
  unconfirmed probe's resend, replace an earlier attempt's saved failure, so
  the message is probed rather than held.
- The drain stages from the hook's own outbox, so a hold kept only in memory
  after a failed save survives the next send; the hold is written once more
  after that failed save.

* fix(native-chat): a refused message waits for its Retry on every host, and a send is staged from the latest outbox

A message the chat showed as not sent no longer goes out on its own when an
older host's chat gets a new agent owner. Resending it on the owner change
sent it after messages typed later, still delivered a retyped copy twice,
and its "Orca will send it again when the agent restarts" row promised a
resend that often never came. It now waits for the user's Retry, as it does
on every current host. A send still in flight when the owner changes is
still sent again under its id; it was never shown as failed.

The drain admitted and staged the next send from the render's outbox. An
owner change requeues the send it interrupted in an effect earlier in the
same commit, and staging from the render's list wrote the old list back,
leaving that send stuck as sending. The drain now reads the latest list,
which still carries a hold kept only in memory after a failed save.

Tests pass the view's target as one stable object, as the view does: a new
object each render re-ran the owner-change requeue, which hid the drain bug.

* fix(native-chat): keep a not-sent message out of newer turns, and word its saved cause only when seen

A message shown as not sent stays in the outbox and draws below every
newer turn. It was an ordinary user row there, so it counted as the newest
user row: while a new send waited for its turn to open, that turn's
"Working for" clock drew under the old message, and once the turn ended an
empty "Worked for" divider was left under it. The projection now marks
such a bubble (held for its Retry, or rejected) as unsent; turn membership
gives it no turn and never makes it the live one, on hosts that state turn
scopes and on those that do not; and the transcript draws it after the
live activity, as it draws a message waiting behind /compact. Mobile has no
outbox, so its rows never carry the mark and its grouping is unchanged.

A held message read back from storage repeated the cause it was saved with,
which may no longer hold: "Update Orca to keep using them" after the user
updated Orca. The outbox hook now remembers, in memory only, which messages
failed while the chat was open; only those word their cause. Any other held
message reads "Your message was not sent." with its Retry, and a Retry the
cause still stops brings the full words back. An expired id keeps its words,
since that cause cannot clear.

* fix(native-chat): follow the bottom and light a tick for a chat whose only rows are not sent

A message shown as not sent draws after the windowed transcript. When it was
the only row, the windowed list was empty, and following the bottom or "Jump
to latest" asked the virtualizer for an end it computes from its own rows:
the top. It now scrolls to the container's own bottom when no row is
windowed.

A rejected send the journal recorded keeps its place but opens no turn, so
the rail lit no tick when it was the row being read. A user row in no turn
now lights its own tick.

Also pins that a refusal seen while the chat is open reaches the rendered
notice in full, and reads only "not sent" after the chat is reopened until a
Retry is refused again.

* test(native-chat): name the relaunch test parameter for how the refusal arrives

The low-evidence lint rejects "shape" as a symbol name.
2026-09-30 22:49:26 -07:00

349 lines
13 KiB
TypeScript

import type { AgentSessionFailureFact } from './agent-session-failure'
import { readWholeAgentSessionFailureFact } from './agent-session-failure'
import type { AgentJournalMessageItem, AgentJournalSubmission } from './agent-session-journal-types'
import {
parseAgentSessionWriteFailure,
type AgentSessionWriteFailure,
type AgentSessionWriteRefusal
} from './agent-session-write-failure'
import {
agentSessionOwnerVerdictAllowsFreshOperationId,
agentSessionRefusalOperationState
} from './agent-session-refusal-retry'
import type { AgentSessionMutationEnvelope } from './agent-session-wire'
import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation'
import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection'
import { parseStructuredAgentSessionOutboxQueueFields } from './structured-agent-session-outbox-delivery'
/** `rejected`: the host settled the send as not delivered. The drain never sends it again on its
* own and nothing queues behind it; only the user's Retry does. */
export type StructuredAgentSessionOutboxState =
| 'queued'
| 'dispatching'
| 'unconfirmed'
| 'rejected'
export type StructuredAgentSessionOutboxEntry = {
clientMessageId: string
sessionId: string
body: AgentJournalMessageItem
previewUris: string[]
state: StructuredAgentSessionOutboxState
queuedAt: number
lastAttemptAt: number | null
retryAfterUnknownSubmittedAt: number | null
source?: 'launch'
/** A Stop landed after this queue send went out: only the user's Retry sends it again, never the
* drain, the unconfirmed probe or an owner change, which would start a turn the user stopped. */
outlivedStop?: true
/** Whether the first attempt asked the host to hold it as a draft (`null`: plain); every replay
* of this id asks the same (structured-agent-session-outbox-delivery). On a request's own copy,
* what that request carries. */
sentDelivery?: 'queue-if-active' | null
/** Why the last attempt did not go through. Lives on the message so it goes when the message
* is sent again or delivered, instead of outliving it as a separate error. On a `queued` entry
* it is also the hold (structured-agent-session-outbox-admission). */
lastFailure?: StructuredAgentSessionAttemptFailure
}
/** A host's rejection fact as a message keeps it: never its provider detail, whose log text is not
* kept client-side, or its refusal. The journal row keeps the whole fact, and words the notice
* while it is loaded; this copy words it when it is not. */
export type StructuredAgentSessionRejectionFact = Pick<
AgentSessionFailureFact,
'kind' | 'attachment'
>
/** Kept as the fact, not the words: the Retry row chooses those when it shows the entry. */
export type StructuredAgentSessionAttemptFailure =
| AgentSessionWriteFailure
/** The host recorded the message and the provider turned it down, with the provider's reason. */
| { kind: 'rejected'; reason: string | null; rejection?: StructuredAgentSessionRejectionFact }
/** The failure a rejected submission leaves on its message. A fact this build cannot read whole is
* dropped, leaving the reason. */
export function structuredAgentSessionRejectedFailure(submission: {
reason: string | null
rejection?: unknown
}): Extract<StructuredAgentSessionAttemptFailure, { kind: 'rejected' }> {
const fact = readWholeAgentSessionFailureFact(submission.rejection)
return {
kind: 'rejected',
reason: submission.reason,
...(fact
? {
rejection: {
kind: fact.kind,
...(fact.attachment ? { attachment: fact.attachment } : {})
}
}
: {})
}
}
function parseStructuredAgentSessionAttemptFailure(
value: unknown
): StructuredAgentSessionAttemptFailure | undefined {
if (
typeof value === 'object' &&
value !== null &&
'kind' in value &&
value.kind === 'rejected' &&
'reason' in value
) {
return value.reason === null || typeof value.reason === 'string'
? structuredAgentSessionRejectedFailure({
reason: value.reason,
rejection: 'rejection' in value ? value.rejection : undefined
})
: undefined
}
return parseAgentSessionWriteFailure(value)
}
export type StructuredAgentSessionAttachment = {
path: string
previewUri: string
}
export function structuredAgentSessionSendBody(
text: string,
attachments: readonly StructuredAgentSessionAttachment[]
): AgentJournalMessageItem {
return {
kind: 'message',
role: 'user',
blocks: [
...(text.trim().length > 0 ? [{ type: 'text' as const, text: text.trimEnd() }] : []),
...attachments.map((attachment) => ({ type: 'image-ref' as const, path: attachment.path }))
]
}
}
export function createStructuredAgentSessionOutboxEntry(args: {
clientMessageId: string
sessionId: string
text: string
attachments: readonly StructuredAgentSessionAttachment[]
queuedAt: number
}): StructuredAgentSessionOutboxEntry {
return {
clientMessageId: args.clientMessageId,
sessionId: args.sessionId,
body: structuredAgentSessionSendBody(args.text, args.attachments),
previewUris: args.attachments.map((attachment) => attachment.previewUri),
state: 'queued',
queuedAt: args.queuedAt,
lastAttemptAt: null,
retryAfterUnknownSubmittedAt: null
}
}
export function updateStructuredAgentSessionOutboxEntry(
entries: readonly StructuredAgentSessionOutboxEntry[],
id: string,
update: (entry: StructuredAgentSessionOutboxEntry) => StructuredAgentSessionOutboxEntry | null
): StructuredAgentSessionOutboxEntry[] {
return entries.flatMap((entry) => {
if (entry.clientMessageId !== id) {
return [entry]
}
const next = update(entry)
return next ? [next] : []
})
}
/** Staged for another attempt; the last attempt's failure no longer describes it. */
export function stageStructuredAgentSessionOutboxEntryForSend(
{ lastFailure: _sentAgain, ...entry }: StructuredAgentSessionOutboxEntry,
now: number
): StructuredAgentSessionOutboxEntry {
return { ...entry, state: 'dispatching', lastAttemptAt: now }
}
/** The host forgot this message's id, a day after it was made, and refuses it for good: only a new
* id sends it. The id was kept because an earlier attempt under it may already be in the chat; a
* first attempt's was replaced when it was refused. */
export function structuredAgentSessionEntryIdExpired(
entry: StructuredAgentSessionOutboxEntry
): boolean {
return (
entry.state === 'queued' &&
entry.lastFailure?.kind === 'refused' &&
entry.lastFailure.code === 'agent_session_operation_expired'
)
}
export function requeueStructuredAgentSessionSendRefusal(
entry: StructuredAgentSessionOutboxEntry,
refusal: AgentSessionWriteRefusal,
createOperationId: () => string,
retainOperationId = false
): StructuredAgentSessionOutboxEntry {
const refusalSettled = agentSessionRefusalOperationState(refusal.code) === 'settled-rejected'
// An exited owner runs nothing under the old id, so a new one can't collide; the message still
// waits for its Retry, since nothing recorded it.
const ownerExited =
refusal.code === 'agent_session_ownership_unknown' &&
agentSessionOwnerVerdictAllowsFreshOperationId(refusal.details?.ownerVerdict)
if (
!(refusalSettled || ownerExited) ||
retainOperationId ||
entry.state === 'unconfirmed' ||
entry.retryAfterUnknownSubmittedAt !== null
) {
return { ...entry, state: 'queued' }
}
// Only here may the id rotate: an earlier attempt under this id, or one whose delivery was in
// doubt, may have landed, so those keep it. Only a settled refusal proves the message never
// landed.
return {
...entry,
clientMessageId: createOperationId(),
state: refusalSettled ? 'rejected' : 'queued',
lastAttemptAt: null,
retryAfterUnknownSubmittedAt: null
}
}
export function reconcileStructuredAgentSessionOutbox(
entries: readonly StructuredAgentSessionOutboxEntry[],
submissions: readonly AgentJournalSubmission[]
): StructuredAgentSessionOutboxEntry[] {
const settled = new Map(submissions.map((entry) => [entry.clientMessageId, entry]))
return entries.flatMap((entry) => {
const submission = settled.get(entry.clientMessageId)
if (submission?.dispatchState === 'accepted') {
return []
}
if (
submission?.dispatchState === 'rejected' &&
classifyDispatchRejection(submission).category === 'withdrawn'
) {
return []
}
if (submission?.dispatchState === 'pending') {
if (entry.state === 'dispatching') {
return [entry]
}
// The host has it, so no failure of an earlier attempt describes it now.
const { lastFailure: _landed, ...landed } = entry
return [{ ...landed, state: 'dispatching' as const }]
}
// Accepted, then not delivered — the agent never started, or its start was refused. The text
// and why stay here for the user's Retry, and nothing queues behind it. `unconfirmed` is how a
// remount reads an entry it left dispatching; the journal has since answered it.
if (
submission?.dispatchState === 'rejected' &&
(entry.state === 'dispatching' || entry.state === 'unconfirmed')
) {
return [
{
...entry,
state: 'rejected' as const,
lastFailure: structuredAgentSessionRejectedFailure(submission)
}
]
}
if (
submission?.dispatchState === 'unknown' &&
entry.retryAfterUnknownSubmittedAt !== -1 &&
entry.retryAfterUnknownSubmittedAt !== submission.submittedAt
) {
// In doubt now, not failed: the probe's resend decides it, as for any unconfirmed send.
const { lastFailure: _superseded, ...inDoubt } = entry
return [{ ...inDoubt, state: 'unconfirmed' as const }]
}
return [entry]
})
}
export function parseStructuredAgentSessionOutboxEntry(
value: unknown,
sessionId: string
): StructuredAgentSessionOutboxEntry | null {
if (typeof value !== 'object' || value === null) {
return null
}
const entry = value as Partial<StructuredAgentSessionOutboxEntry>
const body = entry.body
if (
entry.sessionId !== sessionId ||
typeof entry.clientMessageId !== 'string' ||
typeof entry.queuedAt !== 'number' ||
!body ||
body.kind !== 'message' ||
body.role !== 'user' ||
!Array.isArray(body.blocks) ||
!Array.isArray(entry.previewUris) ||
!entry.previewUris.every((uri) => typeof uri === 'string') ||
!['queued', 'dispatching', 'unconfirmed', 'rejected'].includes(entry.state ?? '')
) {
return null
}
// A malformed failure is dropped: the row then says only that the message was not sent.
const lastFailure = parseStructuredAgentSessionAttemptFailure(entry.lastFailure)
return {
clientMessageId: entry.clientMessageId,
sessionId,
body,
previewUris: entry.previewUris,
state: entry.state as StructuredAgentSessionOutboxState,
queuedAt: entry.queuedAt,
lastAttemptAt: typeof entry.lastAttemptAt === 'number' ? entry.lastAttemptAt : null,
retryAfterUnknownSubmittedAt:
typeof entry.retryAfterUnknownSubmittedAt === 'number'
? entry.retryAfterUnknownSubmittedAt
: null,
...(entry.source === 'launch' ? { source: 'launch' as const } : {}),
...parseStructuredAgentSessionOutboxQueueFields(entry),
...(lastFailure ? { lastFailure } : {})
}
}
export type StructuredAgentSessionSendMutation = {
envelope: AgentSessionMutationEnvelope
body: AgentJournalMessageItem
delivery?: 'queue-if-active'
}
/** The `agentSession.send` arguments an entry stands for. Typed rather than wire-shaped so a host
* calling its own send path builds the same envelope a client would, fingerprint included. */
export function structuredAgentSessionSendMutation(
entry: StructuredAgentSessionOutboxEntry,
expectedRuntimeFence: number
): StructuredAgentSessionSendMutation {
// `delivery` joins the OPERATION fingerprint exactly as the host digests it; never the body's.
const delivery = entry.sentDelivery ?? undefined
const fields = { body: entry.body, ...(delivery ? { delivery } : {}) }
return {
envelope: {
sessionId: entry.sessionId,
clientOperationId: entry.clientMessageId,
expectedRuntimeFence,
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId: entry.sessionId,
fields
})
},
...fields
}
}
export function structuredAgentSessionSendRequest(
entry: StructuredAgentSessionOutboxEntry,
expectedRuntimeFence: number
): Record<string, unknown> {
return structuredAgentSessionSendMutation(entry, expectedRuntimeFence)
}
export type StructuredAgentSessionSendFailure = 'delivery-unknown' | 'failed'
export function classifyStructuredAgentSessionSendFailure(
error: unknown,
isDeliveryUnknown: (error: unknown) => boolean
): StructuredAgentSessionSendFailure {
return isDeliveryUnknown(error) ? 'delivery-unknown' : 'failed'
}