refactor(native-chat): the client's outbox settlement gets a name the host's send settlement doesn't share

This commit is contained in:
Brennan Benson
2026-10-04 05:08:01 -07:00
parent 862a444064
commit b4ee251273
11 changed files with 58 additions and 56 deletions
@@ -4,7 +4,7 @@
// it never can, so the hold lapses on its own.
import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
import { structuredAgentSessionEntryHostWindowEndsAt } from '../../../../shared/structured-agent-session-send-settlement'
import { structuredAgentSessionEntryHostWindowEndsAt } from '../../../../shared/structured-agent-session-outbox-settlement'
type CarriedNote = { key: string; windowEndsAt: number }
@@ -1,6 +1,6 @@
// The one sender of an outbox entry, for the open chat's drain and a launch prompt alike, and the
// one place an answer changes the outbox: through the shared settlement
// (structured-agent-session-send-settlement).
// (structured-agent-session-outbox-settlement).
import type {
AgentSessionMutationResult,
@@ -11,12 +11,12 @@ import {
readAgentSessionErrorRefusal
} from '../../../../shared/agent-session-write-failure'
import {
applyStructuredAgentSessionSendSettlement,
applyStructuredAgentSessionOutboxSettlement,
settleStructuredAgentSessionSendAnswer,
type StructuredAgentSessionSendAnswer,
type StructuredAgentSessionSendSettlement,
type StructuredAgentSessionOutboxSettlement,
type StructuredAgentSessionSettledOutbox
} from '../../../../shared/structured-agent-session-send-settlement'
} from '../../../../shared/structured-agent-session-outbox-settlement'
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
@@ -116,7 +116,7 @@ export function commitStructuredAgentSessionSettledOutbox(
export function sayStructuredAgentSessionSettlement(
sessionId: string,
clientMessageId: string,
settlement: StructuredAgentSessionSendSettlement
settlement: StructuredAgentSessionOutboxSettlement
): void {
if (settlement.kind === 'unanswered') {
if (settlement.words) {
@@ -131,7 +131,7 @@ export function sayStructuredAgentSessionSettlement(
/** How a settlement ends its entry for whoever waits on it, or null while it has not ended. */
export function structuredAgentSessionSettlementEnding(
settlement: StructuredAgentSessionSendSettlement
settlement: StructuredAgentSessionOutboxSettlement
): StructuredAgentSessionEntryEnding | null {
switch (settlement.kind) {
case 'recorded':
@@ -149,7 +149,7 @@ export function structuredAgentSessionSettlementEnding(
export function settleStructuredAgentSessionOutboxEntry(
sessionId: string,
clientMessageId: string,
settlement: StructuredAgentSessionSendSettlement
settlement: StructuredAgentSessionOutboxSettlement
): void {
const current = getStructuredAgentSessionOutbox(sessionId)
const entry = current.find((candidate) => candidate.clientMessageId === clientMessageId)
@@ -164,7 +164,7 @@ export function settleStructuredAgentSessionOutboxEntry(
: settlement
commitStructuredAgentSessionSettledOutbox(
sessionId,
applyStructuredAgentSessionSendSettlement(current, clientMessageId, kept),
applyStructuredAgentSessionOutboxSettlement(current, clientMessageId, kept),
() => {
if (ending) {
endStructuredAgentSessionEntry(entry, ending)
@@ -238,10 +238,10 @@ export async function sendStructuredAgentSessionOutboxEntry(args: {
beforeSettle?: () => void
/** Whether the loaded journal holds a row for the id; absent where no journal is loaded. */
journalHasRow?: (clientMessageId: string) => boolean
}): Promise<StructuredAgentSessionSendSettlement | null> {
}): Promise<StructuredAgentSessionOutboxSettlement | null> {
const { next } = args
const sessionId = next.sessionId
const settle = (settlement: StructuredAgentSessionSendSettlement): void => {
const settle = (settlement: StructuredAgentSessionOutboxSettlement): void => {
args.beforeSettle?.()
settleStructuredAgentSessionOutboxEntry(sessionId, next.clientMessageId, settlement)
}
@@ -255,7 +255,7 @@ export async function sendStructuredAgentSessionOutboxEntry(args: {
if (!commitStructuredAgentSessionOutbox(sessionId, staged, { onlyIfSaved: true })) {
// Unsaved, a send could go out that a reload never settles, and handing it back could repeat
// one an earlier attempt landed: it waits, and the probe tries again.
const unsaved: StructuredAgentSessionSendSettlement = {
const unsaved: StructuredAgentSessionOutboxSettlement = {
kind: 'unanswered',
words: ['messageNotSaved', 'stillSending']
}
@@ -297,8 +297,8 @@ export function dispatchStructuredAgentSessionOutboxEntry(args: {
dispatchGenerationRef: MutableRef<number>
inFlightIdRef: MutableRef<string | null>
journalHasRow: (clientMessageId: string) => boolean
}): { promise: Promise<StructuredAgentSessionSendSettlement | null>; started: boolean } {
const start = async (): Promise<StructuredAgentSessionSendSettlement | null> => {
}): { promise: Promise<StructuredAgentSessionOutboxSettlement | null>; started: boolean } {
const start = async (): Promise<StructuredAgentSessionOutboxSettlement | null> => {
args.inFlightIdRef.current = args.next.clientMessageId
const isCurrent = (): boolean => args.dispatchGenerationRef.current === args.dispatchGeneration
const release = (): void => {
@@ -4,13 +4,13 @@
import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
import { structuredAgentSessionEntryAwaitsSettlement } from '../../../../shared/structured-agent-session-outbox-admission'
import {
applyStructuredAgentSessionSendSettlement,
applyStructuredAgentSessionOutboxSettlement,
settleStructuredAgentSessionEntryFromJournal,
structuredAgentSessionEntryHostWindowEndsAt,
type StructuredAgentSessionJournalReading,
type StructuredAgentSessionSendSettlement,
type StructuredAgentSessionOutboxSettlement,
type StructuredAgentSessionSettledOutbox
} from '../../../../shared/structured-agent-session-send-settlement'
} from '../../../../shared/structured-agent-session-outbox-settlement'
import {
commitStructuredAgentSessionOutbox,
getStructuredAgentSessionOutbox
@@ -35,12 +35,12 @@ export function settleStructuredAgentSessionOutboxFromJournal(
const returned: NonNullable<StructuredAgentSessionSettledOutbox['returned']>[] = []
const settled: {
entry: StructuredAgentSessionOutboxEntry
settlement: StructuredAgentSessionSendSettlement
settlement: StructuredAgentSessionOutboxSettlement
}[] = []
for (const entry of current) {
const settlement = settleStructuredAgentSessionEntryFromJournal(entry, reading)
if (settlement) {
const next = applyStructuredAgentSessionSendSettlement(
const next = applyStructuredAgentSessionOutboxSettlement(
entries,
entry.clientMessageId,
settlement
@@ -6,7 +6,7 @@ import { structuredAgentSessionEntryResendsUnconfirmed } from '../../../../share
import {
STRUCTURED_AGENT_SESSION_SEND_UNCONFIRMED_WORDS,
structuredAgentSessionEntryOutlivedHostWindow
} from '../../../../shared/structured-agent-session-send-settlement'
} from '../../../../shared/structured-agent-session-outbox-settlement'
import {
commitStructuredAgentSessionOutbox,
getStructuredAgentSessionOutbox
@@ -2,11 +2,11 @@
// launch settlement runs; both own the same persisted entry, so they share one in-flight send
// instead of issuing two RPCs.
import type { StructuredAgentSessionSendSettlement } from '../../../shared/structured-agent-session-send-settlement'
import type { StructuredAgentSessionOutboxSettlement } from '../../../shared/structured-agent-session-outbox-settlement'
/** How the send settled, or null when nothing was sent. */
export type StructuredAgentLaunchPromptDispatch =
Promise<StructuredAgentSessionSendSettlement | null>
Promise<StructuredAgentSessionOutboxSettlement | null>
const inFlightDispatches = new Map<string, StructuredAgentLaunchPromptDispatch>()
@@ -8,7 +8,7 @@ import {
type StructuredAgentSessionOutboxEntry
} from './structured-agent-session-outbox'
import { reconcileStructuredAgentSessionOutboxWithQueue } from './structured-agent-session-draft-hand-off'
import { settleStructuredAgentSessionSendAnswer } from './structured-agent-session-send-settlement'
import { settleStructuredAgentSessionSendAnswer } from './structured-agent-session-outbox-settlement'
import { hasUnsentStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-stop-withdrawal'
const entry: StructuredAgentSessionOutboxEntry = {
@@ -7,13 +7,13 @@ import {
type StructuredAgentSessionOutboxEntry
} from './structured-agent-session-outbox'
import {
applyStructuredAgentSessionSendSettlement,
applyStructuredAgentSessionOutboxSettlement,
settleStructuredAgentSessionEntryFromJournal,
settleStructuredAgentSessionSendAnswer,
structuredAgentSessionEntryOutlivedHostWindow,
type StructuredAgentSessionSendAnswer,
type StructuredAgentSessionSendSettlementContext
} from './structured-agent-session-send-settlement'
type StructuredAgentSessionOutboxSettlementContext
} from './structured-agent-session-outbox-settlement'
import { AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS } from './agent-session-host-authority'
import { DISPATCH_REJECTED_CANCELLED } from './structured-agent-session-dispatch-rejection'
import {
@@ -87,16 +87,16 @@ function refused(
}
}
const FIRST: StructuredAgentSessionSendSettlementContext = {
const FIRST: StructuredAgentSessionOutboxSettlementContext = {
firstAttempt: true,
answersProve: true,
journalHasRow: false
}
const RESEND_PROVING: StructuredAgentSessionSendSettlementContext = {
const RESEND_PROVING: StructuredAgentSessionOutboxSettlementContext = {
...FIRST,
firstAttempt: false
}
const RESEND_OLD_HOST: StructuredAgentSessionSendSettlementContext = {
const RESEND_OLD_HOST: StructuredAgentSessionOutboxSettlementContext = {
...RESEND_PROVING,
answersProve: false
}
@@ -588,18 +588,18 @@ describe('entries an older build saved are migrated, never sent again', () => {
describe('applying a settlement', () => {
it('records and returns leave the outbox; only a return hands the entry back, with its words', () => {
const outbox = [entry(), entry({ clientMessageId: 'next' })]
expect(applyStructuredAgentSessionSendSettlement(outbox, ID, { kind: 'recorded' })).toEqual({
expect(applyStructuredAgentSessionOutboxSettlement(outbox, ID, { kind: 'recorded' })).toEqual({
entries: [outbox[1]],
returned: null
})
expect(
applyStructuredAgentSessionSendSettlement(outbox, ID, {
applyStructuredAgentSessionOutboxSettlement(outbox, ID, {
kind: 'returned',
words: ['tryAgain']
})
).toEqual({ entries: [outbox[1]], returned: { entry: outbox[0], words: ['tryAgain'] } })
expect(
applyStructuredAgentSessionSendSettlement(outbox, ID, { kind: 'withdrawn' }).returned
applyStructuredAgentSessionOutboxSettlement(outbox, ID, { kind: 'withdrawn' }).returned
).toEqual({
entry: outbox[0],
words: null
@@ -609,10 +609,10 @@ describe('applying a settlement', () => {
it('no answer keeps it under the same id, in doubt; pending keeps it dispatching', () => {
const outbox = [entry({ state: 'dispatching' })]
expect(
applyStructuredAgentSessionSendSettlement(outbox, ID, { kind: 'unanswered' }).entries[0]
applyStructuredAgentSessionOutboxSettlement(outbox, ID, { kind: 'unanswered' }).entries[0]
).toMatchObject({ clientMessageId: ID, state: 'unconfirmed' })
expect(
applyStructuredAgentSessionSendSettlement(outbox, ID, { kind: 'pending' }).entries[0]
applyStructuredAgentSessionOutboxSettlement(outbox, ID, { kind: 'pending' }).entries[0]
).toMatchObject({ clientMessageId: ID, state: 'dispatching' })
})
})
@@ -27,7 +27,7 @@ import { structuredAgentSessionStillSendingWords } from './structured-agent-sess
import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox'
import { structuredAgentSessionEntryAwaitsSettlement } from './structured-agent-session-outbox-admission'
export type StructuredAgentSessionSendSettlement =
export type StructuredAgentSessionOutboxSettlement =
/** Case 1: the host's row (or card) holds the message from here. */
| { kind: 'recorded' }
/** Case 1, not final yet: the host wrote the row and has not handed it to the agent. The entry
@@ -48,7 +48,7 @@ export type StructuredAgentSessionSendAnswer =
* carried, if any, and the RPC error code. */
| { kind: 'thrown'; refusal: AgentSessionWriteRefusal | undefined; rpcCode: string | undefined }
export type StructuredAgentSessionSendSettlementContext = {
export type StructuredAgentSessionOutboxSettlementContext = {
/** No earlier attempt under this id went out from anywhere, so nothing can hold it but this
* attempt. Only an older host needs it: it may refuse a resent id before looking it up. */
firstAttempt: boolean
@@ -64,13 +64,15 @@ export type StructuredAgentSessionSendSettlementContext = {
export const STRUCTURED_AGENT_SESSION_SEND_UNCONFIRMED_WORDS: readonly AgentSessionWriteNoticePart[] =
['sendOutcomeLost']
function returnedFor(refusal: AgentSessionWriteRefusal): StructuredAgentSessionSendSettlement {
function returnedFor(refusal: AgentSessionWriteRefusal): StructuredAgentSessionOutboxSettlement {
return { kind: 'returned', words: agentSessionWriteNoticeParts(refusal, 'composer-send') }
}
/** Case 3 for a refusal that proves nothing here: why it is held, and that Orca keeps sending it.
* "Outcome unknown" is doubt like any lost answer, so it says nothing, thrown or returned. */
function stillSendingFor(refusal: AgentSessionWriteRefusal): StructuredAgentSessionSendSettlement {
function stillSendingFor(
refusal: AgentSessionWriteRefusal
): StructuredAgentSessionOutboxSettlement {
return refusal.code === 'agent_session_operation_unknown'
? { kind: 'unanswered' }
: { kind: 'unanswered', words: structuredAgentSessionStillSendingWords(refusal) }
@@ -78,8 +80,8 @@ function stillSendingFor(refusal: AgentSessionWriteRefusal): StructuredAgentSess
/** Words for an id no resend can settle: what an earlier attempt left, if anything, is in the chat. */
function settledByJournal(
context: StructuredAgentSessionSendSettlementContext
): StructuredAgentSessionSendSettlement {
context: StructuredAgentSessionOutboxSettlementContext
): StructuredAgentSessionOutboxSettlement {
return context.journalHasRow
? { kind: 'recorded' }
: { kind: 'returned', words: [...STRUCTURED_AGENT_SESSION_SEND_UNCONFIRMED_WORDS] }
@@ -87,8 +89,8 @@ function settledByJournal(
function refusalSettlement(
refusal: AgentSessionWriteRefusal,
context: StructuredAgentSessionSendSettlementContext
): StructuredAgentSessionSendSettlement {
context: StructuredAgentSessionOutboxSettlementContext
): StructuredAgentSessionOutboxSettlement {
const reason = refusal.details?.reason
if (refusal.code === 'agent_session_operation_unknown') {
// A rewind the host refused before writing anything is settled; every other unknown is doubt.
@@ -120,8 +122,8 @@ function refusalSettlement(
* and only on a first attempt: an earlier one may have landed before the host turned this away. */
function thrownSettlement(
answer: Extract<StructuredAgentSessionSendAnswer, { kind: 'thrown' }>,
context: StructuredAgentSessionSendSettlementContext
): StructuredAgentSessionSendSettlement {
context: StructuredAgentSessionOutboxSettlementContext
): StructuredAgentSessionOutboxSettlement {
if (answer.refusal) {
return stillSendingFor(answer.refusal)
}
@@ -136,8 +138,8 @@ function thrownSettlement(
export function settleStructuredAgentSessionSendAnswer(
answer: StructuredAgentSessionSendAnswer,
clientMessageId: string,
context: StructuredAgentSessionSendSettlementContext
): StructuredAgentSessionSendSettlement {
context: StructuredAgentSessionOutboxSettlementContext
): StructuredAgentSessionOutboxSettlement {
if (answer.kind === 'thrown') {
return thrownSettlement(answer, context)
}
@@ -188,7 +190,7 @@ export function structuredAgentSessionEntryOutlivedHostWindow(
/** A row the host holds for the send. */
function settleStructuredAgentSessionSendRow(
submission: AgentJournalSubmission
): StructuredAgentSessionSendSettlement {
): StructuredAgentSessionOutboxSettlement {
if (submission.dispatchState === 'pending') {
return { kind: 'pending' }
}
@@ -243,7 +245,7 @@ function readThrough(
export function settleStructuredAgentSessionEntryFromJournal(
entry: StructuredAgentSessionOutboxEntry,
reading: StructuredAgentSessionJournalReading
): StructuredAgentSessionSendSettlement | null {
): StructuredAgentSessionOutboxSettlement | null {
// The host handed it off as a queued draft, in whatever state: the card carries it.
if (
reading.submissions.some((candidate) => candidate.queuedMessageId === entry.clientMessageId)
@@ -298,10 +300,10 @@ export type StructuredAgentSessionSettledOutbox = {
} | null
}
export function applyStructuredAgentSessionSendSettlement(
export function applyStructuredAgentSessionOutboxSettlement(
entries: readonly StructuredAgentSessionOutboxEntry[],
clientMessageId: string,
settlement: StructuredAgentSessionSendSettlement
settlement: StructuredAgentSessionOutboxSettlement
): StructuredAgentSessionSettledOutbox {
const entry = entries.find((candidate) => candidate.clientMessageId === clientMessageId)
if (!entry) {
@@ -26,7 +26,7 @@ function goesOutOnItsOwn(
* - A message that never went out is withdrawn here; its text goes back to the composer.
* - One already on its way is stamped with the Stop's own id and never sent again: a resend onto the
* session the user stopped could start a turn. Its own answer, its journal row or the Stop's
* answer settles it (structured-agent-session-send-settlement).
* answer settles it (structured-agent-session-outbox-settlement).
* - One the host holds a row for is the journal's to settle.
*/
export function stopStructuredAgentSessionOutbox(
@@ -15,9 +15,9 @@ import {
import { admitStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-admission'
import { structuredAgentSessionEntryAttempt } from './structured-agent-session-outbox-delivery'
import {
applyStructuredAgentSessionSendSettlement,
applyStructuredAgentSessionOutboxSettlement,
settleStructuredAgentSessionSendAnswer
} from './structured-agent-session-send-settlement'
} from './structured-agent-session-outbox-settlement'
import { stopStructuredAgentSessionOutbox } from './structured-agent-session-outbox-stop-withdrawal'
function entry(sentDelivery?: 'queue-if-active') {
@@ -151,7 +151,7 @@ describe('outbox queue delivery', () => {
)
const stopped = stopStructuredAgentSessionOutbox([staged], [], 'client-1', 'stop-1').entries
for (const entries of [stopped, [staged]]) {
const settled = applyStructuredAgentSessionSendSettlement(entries, 'client-1', settlement)
const settled = applyStructuredAgentSessionOutboxSettlement(entries, 'client-1', settlement)
expect(settled.entries).toEqual([])
expect(settled.returned?.entry.clientMessageId).toBe('client-1')
}
@@ -14,7 +14,7 @@ import { parseStructuredAgentSessionOutboxQueueFields } from './structured-agent
/** `queued`: waits to go out. `dispatching`: out, or held by the host as a row it has not handed
* to the agent yet. `unconfirmed`: no answer yet, so the same id goes again
* (structured-agent-session-send-settlement). */
* (structured-agent-session-outbox-settlement). */
export type StructuredAgentSessionOutboxState = 'queued' | 'dispatching' | 'unconfirmed'
/** The Stop that outran a send already on its way: by its own id, and where the host's journal
@@ -147,7 +147,7 @@ export function stageStructuredAgentSessionOutboxEntryForSend(
/**
* The outbox as the journal reads it: an entry the host holds a row for leaves once that row has
* settled (the row shows it from there), and stays out while it is pending. A view's reading; the
* outbox hook settles the stored copy (structured-agent-session-send-settlement).
* outbox hook settles the stored copy (structured-agent-session-outbox-settlement).
*/
export function reconcileStructuredAgentSessionOutbox(
entries: readonly StructuredAgentSessionOutboxEntry[],