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
@@ -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[],