Files
orca/src/shared/structured-agent-session-send-disposition.ts
T
Brennan Benson f02d09c1ba fix(native-chat): deliver queued messages while the chat pane is hidden (#20659)
* fix(native-chat): deliver queued messages while the chat pane is hidden

With two or more messages queued, everything behind the head waited on the
user's attention. The drain only inspected the head and returned unless it was
`queued`, and a `pending` send deliberately leaves the head `dispatching`. An
entry only leaves that state through the journal subscription, which is torn
down when the pane goes hidden -- and a worktree switch hides it.

Two changes, both needed:

- One shared admission rule now says what the queue does next, and the drain
  takes its `dispatch`: the first `queued` entry, skipping entries the host has
  already acknowledged. It still stops at an `unconfirmed` entry or a refusal
  the user must act on. Order is not the outbox's to keep -- the host appends
  the submission inside the per-session serialize chain before dispatching, so
  journal order is arrival order. Holding the tail bought no ordering guarantee
  and cost delivery. Single-flight still keeps sends strictly sequential, and a
  launch prompt's in-flight send, which runs outside it, still stops the queue.
- The journal subscription now stays open while a session has undelivered outbox
  entries, published from the `writeOutbox` choke point. The subscription's
  retaining hold is what also keeps the host from evicting the session 15s after
  the last turn, which would otherwise turn the stall into a blocked head
  refusing `agent_session_ownership_unknown`.

An acknowledged entry stays in the outbox rather than retiring on `pending`: the
text is safe either way, since the journal upserts a render item from the
submission's own body, but a `pending` can still settle `rejected` or `unknown`
and only the entry carries the retry state that answer needs.

Follow-on corrections the head-only assumption had hidden:

- Single-flight is released where the disposition is applied, not in a later
  `.finally`. That state write is what re-runs the drain, so the release has to
  land first or the queue has no trigger left.
- One ref now holds the in-flight entry's id instead of a bare boolean, and the
  reconcile effect keys its release on that, not on the head, so a journal update
  about the head can no longer discard a still-unsettled send of the tail.
- A refusal blocks the entry it refused, read back by index so a rotated id is
  preserved.
- The automatic unknown probe and the Retry affordance both read the blocker at
  whatever index it sits, the Retry through the same shared rule as the drain.

`raises no delivery notice for a stuck message behind a healthy head` asserted
that a message behind an admitted head raises nothing, because a Retry could not
act on it. It now can, so that guard is rewritten to assert the notice names
that entry and its Retry sends that entry.

* fix(native-chat): resume outbox after journal admission and scope subscriptions

* test: name outbox send request by domain role
2026-09-16 11:26:53 -07:00

207 lines
8.2 KiB
TypeScript

// How one send outcome changes the outbox.
//
// The sibling of `reconcileStructuredAgentSessionOutbox`: that one folds the
// journal's view of a submission into the queue, this one folds the answer to a
// single `agentSession.send`. Both write the same state, so they live together
// and speak the same vocabulary. Pure on purpose — the hook that calls this owns
// the refs, the React state and the storage write, and nothing else decides an
// entry's state.
import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire'
import {
dispatchRejectionReasonIsInternal,
dispatchRejectionWasTransportWriteFailure
} from './structured-agent-session-dispatch-rejection'
import {
classifyStructuredAgentSessionSendFailure,
requeueStructuredAgentSessionSendRefusal,
type StructuredAgentSessionOutboxEntry
} from './structured-agent-session-outbox'
export type StructuredAgentSessionSendDisposition = {
entries: StructuredAgentSessionOutboxEntry[]
error: string | null
/** The entry the queue is stuck on, or null when nothing blocks it. Always the
* next value, never "unchanged": the caller assigns it verbatim. */
blockedClientMessageId: string | null
/** A rejected result arrived before the journal snapshot; Retry must rotate this id. */
retryWithFreshClientMessageId: string | null
}
type SendDispositionInput = {
entries: readonly StructuredAgentSessionOutboxEntry[]
entry: StructuredAgentSessionOutboxEntry
blockedClientMessageId: string | null
}
function replaceEntryState(
input: SendDispositionInput,
state: StructuredAgentSessionOutboxEntry['state']
): StructuredAgentSessionOutboxEntry[] {
return input.entries.map((candidate) =>
candidate.clientMessageId === input.entry.clientMessageId ? { ...candidate, state } : candidate
)
}
function dropEntry(input: SendDispositionInput): StructuredAgentSessionOutboxEntry[] {
return input.entries.filter(
(candidate) => candidate.clientMessageId !== input.entry.clientMessageId
)
}
/**
* The user force-retried a host-confirmed `unknown` and got the same submission
* back. That is now the only answer such a retry can get: `unknown` means the
* host cannot tell whether the provider has the message, and no reason it
* records ever makes a second delivery safe. Parking the entry would offer a
* Retry that does nothing in front of a queue nothing can drain, so it leaves
* the outbox. Nothing is lost from the conversation: the durable submission row
* already renders the message.
*
* A `rejected` submission takes the other path — the message provably did not
* happen, so Retry rotates the id and sends it as a genuinely new message.
*/
function refusedRedelivery(
entry: StructuredAgentSessionOutboxEntry,
submission: AgentSessionSendResult['submission']
): boolean {
return (
entry.retryAfterUnknownSubmittedAt !== null &&
submission.dispatchState === 'unknown' &&
submission.submittedAt === entry.retryAfterUnknownSubmittedAt
)
}
/**
* What to put on screen for a rejection.
*
* A content rejection's reason is the provider explaining itself, so it is shown
* verbatim — "Claude does not support the image type .bmp" is the whole answer and
* a generic string would throw it away. A transport rejection's reason is an
* internal marker; printing it put `provider_write_failed: broken pipe` in front of
* users, which names nothing they can act on. That case gets copy that says what
* happened and that the message is safe to send again — which it is, because the
* frame provably never left, so a resend cannot duplicate.
*
* The null default claims no cause, because at that point we know none: all it
* asserts is the one thing every rejection shares.
*
* Exported because a client without an outbox needs the same copy: the rule about
* which reasons a person may read is a property of the reason, not of the queue.
*/
export function structuredAgentSessionRejectionNotice(reason: string | null): string {
if (reason === null) {
return 'Message was not sent.'
}
if (dispatchRejectionWasTransportWriteFailure(reason)) {
return "Couldn't reach the agent. Your message was not sent — Retry to send it again."
}
// Any other reason we minted is an internal cause with no user-facing meaning;
// only a provider's own explanation is worth reading verbatim.
return dispatchRejectionReasonIsInternal(reason)
? 'Orca could not send your message — Retry to send it again.'
: reason
}
export function disposeStructuredAgentSessionSendResult(
input: SendDispositionInput & {
result: AgentSessionMutationResult<AgentSessionSendResult>
createOperationId: () => string
}
): StructuredAgentSessionSendDisposition {
const result = input.result
if (!result.ok) {
const refusedIndex = input.entries.findIndex(
(candidate) => candidate.clientMessageId === input.entry.clientMessageId
)
const entries = input.entries.map((candidate) =>
candidate.clientMessageId === input.entry.clientMessageId
? requeueStructuredAgentSessionSendRefusal(
candidate,
result.refusal.code,
input.createOperationId,
input.entry.lastAttemptAt !== null
)
: candidate
)
return {
entries,
error: result.refusal.message,
// Read back by index rather than from the input: a refusal can rotate the id, and the
// refused entry is not always the head now that an admitted one no longer holds the queue.
blockedClientMessageId: entries[refusedIndex]?.clientMessageId ?? null,
retryWithFreshClientMessageId: null
}
}
const submission = result.value.submission
if (refusedRedelivery(input.entry, submission)) {
return {
entries: dropEntry(input),
error: 'Message delivery is unconfirmed and Orca will not send it again',
blockedClientMessageId: input.blockedClientMessageId,
retryWithFreshClientMessageId: null
}
}
if (submission.dispatchState === 'accepted') {
return {
entries: dropEntry(input),
error: null,
blockedClientMessageId: input.blockedClientMessageId,
retryWithFreshClientMessageId: null
}
}
if (submission.dispatchState === 'rejected') {
return {
entries: replaceEntryState(input, 'queued'),
error: structuredAgentSessionRejectionNotice(submission.reason),
blockedClientMessageId: input.entry.clientMessageId,
retryWithFreshClientMessageId: input.entry.clientMessageId
}
}
if (submission.dispatchState === 'unknown' && submission.recovered) {
return {
entries: input.entries.map((candidate) =>
candidate.clientMessageId === input.entry.clientMessageId
? { ...candidate, state: 'unconfirmed', retryAfterUnknownSubmittedAt: -1 }
: candidate
),
error: null,
blockedClientMessageId: input.blockedClientMessageId,
retryWithFreshClientMessageId: null
}
}
// `pending` is the host saying the message was written and is awaiting the
// provider's acknowledgement, which cannot arrive until the turn ahead of it
// ends. That is not doubt, and keeping order is no longer the reason to hold
// the entry -- the host fixed the order when it wrote the row. It stays
// because a `pending` can still settle `rejected` or `unknown`, and only the
// entry carries the retry state that answer needs.
return {
entries: replaceEntryState(
input,
submission.dispatchState === 'unknown' ? 'unconfirmed' : 'dispatching'
),
error: null,
blockedClientMessageId: input.blockedClientMessageId,
retryWithFreshClientMessageId: null
}
}
export function disposeStructuredAgentSessionSendFailure(
input: SendDispositionInput & {
cause: unknown
isDeliveryUnknown: (error: unknown) => boolean
}
): StructuredAgentSessionSendDisposition {
const failure = classifyStructuredAgentSessionSendFailure(input.cause, input.isDeliveryUnknown)
const deliveryUnknown = failure === 'delivery-unknown'
return {
entries: replaceEntryState(input, deliveryUnknown ? 'unconfirmed' : 'queued'),
error: deliveryUnknown ? 'Message delivery is unconfirmed' : String(input.cause),
blockedClientMessageId: deliveryUnknown
? input.blockedClientMessageId
: input.entry.clientMessageId,
retryWithFreshClientMessageId: null
}
}