Files
orca/src/shared/structured-agent-session-send-disposition.test.ts
T
Brennan Benson 87f2c0f92a fix(native-chat): read an older host's restart answer as recovered when sending, too
Hosts from before the recovered marker publish only the restart reason. The
working-state rule already read that reason as recovered; the send answer now
uses the same predicate, so both agree on those hosts.
2026-10-03 22:46:54 -07:00

469 lines
16 KiB
TypeScript

// What a rejection puts on the user's screen.
//
// The module is pure so this can be asserted directly instead of through the hook,
// which is the whole reason it was split out.
import { describe, expect, it } from 'vitest'
import type { AgentSessionFailureFact } from './agent-session-failure'
import type { AgentJournalSubmission } from './agent-session-journal-types'
import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire'
import {
DISPATCH_REJECTED_CANCELLED,
DISPATCH_REJECTED_QUEUE_FULL
} from './structured-agent-session-dispatch-rejection'
import { agentSessionWriteNoticeEnglish } from './agent-session-refusal-notice'
import {
disposeStructuredAgentSessionSendFailure,
disposeStructuredAgentSessionSendResult,
structuredAgentSessionAttemptFailureParts
} from './structured-agent-session-send-disposition'
import {
createStructuredAgentSessionOutboxEntry,
reconcileStructuredAgentSessionOutbox,
type StructuredAgentSessionOutboxEntry
} from './structured-agent-session-outbox'
import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-session-outbox-admission'
const entry: StructuredAgentSessionOutboxEntry = createStructuredAgentSessionOutboxEntry({
clientMessageId: 'client-1',
sessionId: 'session-1',
text: 'hello',
attachments: [],
queuedAt: 1
})
function rejectedWith(
reason: string | null,
extra: { rejection?: AgentSessionFailureFact; replayed?: boolean } = {}
): AgentSessionMutationResult<AgentSessionSendResult> {
const submission: AgentJournalSubmission = {
clientMessageId: 'client-1',
fence: 1,
payloadFingerprint: 'fingerprint',
dispatchState: 'rejected',
providerItemId: null,
reason,
submittedAt: 10,
resolvedAt: 10,
...(extra.rejection ? { rejection: extra.rejection } : {})
}
return {
ok: true,
replayed: extra.replayed ?? false,
fence: 1,
cursor: { epoch: 'epoch-1', sequence: 10 },
value: { clientMessageId: 'client-1', submission }
}
}
function notice(reason: string | null, rejection?: AgentSessionFailureFact): string | undefined {
const disposition = disposeStructuredAgentSessionSendResult({
entries: [entry],
entry,
result: rejectedWith(reason, rejection ? { rejection } : {}),
createOperationId: () => 'unused'
})
// The reason travels with the message it explains, never as a separate error.
expect(disposition.error).toBeNull()
const failure = disposition.entries[0]?.lastFailure
return (
failure && agentSessionWriteNoticeEnglish(structuredAgentSessionAttemptFailureParts(failure))
)
}
describe('a queued draft answer', () => {
it('retires the outbox entry: the host-held draft carries any later refusal', () => {
const disposition = disposeStructuredAgentSessionSendResult({
entries: [entry],
entry,
result: {
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-1', sequence: 10 },
value: {
clientMessageId: 'client-1',
queued: { messageId: 'client-1', position: 1, state: 'waiting' }
}
},
createOperationId: () => 'unused'
})
expect(disposition.entries).toEqual([])
expect(disposition.error).toBeNull()
})
it('a withdrawn replay is spent, not unknown', () => {
const disposition = disposeStructuredAgentSessionSendResult({
entries: [entry],
entry,
result: {
ok: true,
replayed: true,
fence: 1,
cursor: { epoch: 'epoch-1', sequence: 10 },
value: {
clientMessageId: 'client-1',
queued: { messageId: 'client-1', position: 1, state: 'withdrawn' }
}
},
createOperationId: () => 'unused'
})
expect(disposition.entries).toEqual([])
expect(disposition.error).toBeNull()
})
})
describe('what a rejection shows the user', () => {
it('removes a queued message the provider confirms Stop cancelled', () => {
const result = rejectedWith(DISPATCH_REJECTED_CANCELLED)
if (!result.ok || !('submission' in result.value)) {
throw new Error('expected rejected submission fixture')
}
expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission])).toEqual([])
})
it('never puts the transport marker on screen', () => {
const shown = notice('provider_write_failed: broken pipe')
// `provider_write_failed: broken pipe` names nothing a person can act on.
expect(shown).not.toContain('provider_write_failed')
expect(shown).not.toContain('broken pipe')
expect(shown).toBe("Orca couldn't reach the agent. Your message was not sent.")
})
it('shows a content rejection in the provider own words', () => {
// The provider explaining itself IS the answer; a generic string throws it away.
expect(notice('Claude messages support at most 20 images')).toBe(
'Claude messages support at most 20 images'
)
})
it('shows the sentence a host wrote for the person reading it', () => {
const reason = 'The provider stopped before it finished starting.'
expect(notice(reason)).toBe(reason)
})
it('never puts the legacy not_delivered marker on screen', () => {
// Released clients printed it as it was; it is a marker, not a sentence.
expect(notice('not_delivered')).toBe('Your message was not sent.')
})
it.each([
['the legacy marker alone', undefined],
['the marker and its typed fact', { kind: 'cancelled' as const }]
])('fails nothing for a Stop-withdrawn send, first reply or replay: %s', (_label, rejection) => {
for (const replayed of [false, true]) {
const disposition = disposeStructuredAgentSessionSendResult({
entries: [entry],
entry,
result: rejectedWith(DISPATCH_REJECTED_CANCELLED, { rejection, replayed }),
createOperationId: () => 'unused'
})
expect(disposition).toEqual({ entries: [], error: null })
}
})
it('reads a withdrawal off the typed fact whatever the reason says', () => {
const result = rejectedWith('Withdrawn.', { rejection: { kind: 'cancelled' } })
if (!result.ok || !('submission' in result.value)) {
throw new Error('expected rejected submission fixture')
}
expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission])).toEqual([])
expect(
disposeStructuredAgentSessionSendResult({
entries: [entry],
entry,
result,
createOperationId: () => 'unused'
}).error
).toBeNull()
})
it('claims no cause when the rejection names none', () => {
expect(notice(null)).toBe('Your message was not sent.')
})
it('never puts a local-capacity marker on screen either', () => {
// Neither the provider's words nor a transport failure: a refusal we minted
// ourselves. It has no user-facing meaning, so it gets copy rather than the token.
const shown = notice(DISPATCH_REJECTED_QUEUE_FULL)
expect(shown).not.toContain('queue is full')
expect(shown).toBe('Your message was not sent.')
})
})
// A row that carries the host's fact is worded from it; the reason is not read.
describe('what a rejection with a typed fact shows the user', () => {
it('says Orca could not hand the message over, whatever the reason holds', () => {
expect(notice('provider_write_failed', { kind: 'writeFailed' })).toBe(
"Orca couldn't reach the agent. Your message was not sent."
)
expect(notice('Something unrelated.', { kind: 'writeFailed' })).toBe(
"Orca couldn't reach the agent. Your message was not sent."
)
})
it("rebuilds the fact's sentence where a marker stands in for it", () => {
expect(notice(DISPATCH_REJECTED_QUEUE_FULL, { kind: 'queueFull' })).toBe(
'Too many messages were waiting for the agent, so this one was not sent.'
)
})
// The surface names the agent; the host's own sentence is never compared or shown.
it('words the fact itself, never the sentence the host wrote beside it', () => {
expect(
notice('Claude never finished starting, so Orca stopped it.', { kind: 'hostStopped' })
).toBe('The agent never finished starting, so Orca stopped it.')
})
// The message keeps no fact it cannot place, so the host's sentence stands, as on an older host.
it("shows a newer host's sentence when its fact cannot be placed", () => {
expect(notice('A sentence a newer host wrote.', JSON.parse('{"kind":"fromTheFuture"}'))).toBe(
'A sentence a newer host wrote.'
)
})
// The message's copy drops the detail and the refusal these kinds are worded from, so the
// sentence the host wrote for the person stands in for them; with none, the table's words.
it("shows the host's sentence for a kind whose words its copy cannot rebuild", () => {
expect(
notice('The provider did not accept this message: Image type .bmp.', {
kind: 'providerRejected',
detail: { text: 'Image type .bmp', audience: 'person' }
})
).toBe('The provider did not accept this message: Image type .bmp.')
expect(
notice("Claude couldn't start. Start a new chat to continue.", {
kind: 'startFailed',
refusal: { code: 'agent_session_identity_required' }
})
).toBe("Claude couldn't start. Start a new chat to continue.")
expect(notice(null, { kind: 'providerRejected' })).toBe(
'The provider did not accept this message.'
)
})
it('says only that the message was not sent for a fact no message can carry', () => {
expect(notice('Compaction failed.', { kind: 'compactionFailed' })).toBe(
'Your message was not sent.'
)
})
})
describe('what a refusal shows the user', () => {
it('keeps the refusal as a fact on the message, without the host diagnostic', () => {
const disposition = disposeStructuredAgentSessionSendResult({
entries: [entry],
entry,
result: {
ok: false,
refusal: {
code: 'agent_session_checkpoint_stale',
message: 'Expected runtime fence 1; the session is at 3.'
}
},
createOperationId: () => 'unused'
})
expect(disposition.error).toBeNull()
expect(structuredAgentSessionEntryHeldForRetry(disposition.entries[0]!)).toBe(true)
expect(disposition.entries).toMatchObject([
{
clientMessageId: entry.clientMessageId,
state: 'queued',
lastFailure: { kind: 'refused', code: 'agent_session_checkpoint_stale' }
}
])
expect(
agentSessionWriteNoticeEnglish(
structuredAgentSessionAttemptFailureParts(disposition.entries[0]!.lastFailure!)
)
).toBe('Your message was not sent.')
})
it("keeps the reason the host named, and says it on the message's Retry row", () => {
const disposition = disposeStructuredAgentSessionSendResult({
entries: [entry],
entry,
result: {
ok: false,
refusal: {
code: 'agent_session_conflict',
message: 'The chat is still starting.',
details: { reason: 'chatStarting' }
}
},
createOperationId: () => 'unused'
})
const lastFailure = disposition.entries[0]?.lastFailure
expect(lastFailure).toEqual({
kind: 'refused',
code: 'agent_session_conflict',
details: { reason: 'chatStarting' }
})
expect(
agentSessionWriteNoticeEnglish(structuredAgentSessionAttemptFailureParts(lastFailure!))
).toBe(
'The agent is still starting. Your message was not sent. Wait for the agent to finish starting.'
)
})
it("keeps a rejected message's typed fact without the provider's detail", () => {
const disposition = disposeStructuredAgentSessionSendResult({
entries: [entry],
entry,
result: rejectedWith('An image on this message is empty, so the message was not sent.', {
rejection: {
kind: 'attachmentInvalid',
attachment: { reason: 'empty' },
detail: { text: 'image block 2: zero bytes', audience: 'log' }
}
}),
createOperationId: () => 'unused'
})
expect(disposition.entries[0]?.lastFailure).toEqual({
kind: 'rejected',
reason: 'An image on this message is empty, so the message was not sent.',
rejection: { kind: 'attachmentInvalid', attachment: { reason: 'empty' } }
})
})
it('keeps a failed request as a fact, not a transport error string', () => {
const disposition = disposeStructuredAgentSessionSendFailure({
entries: [entry],
entry,
cause: new Error('socket hang up: ECONNRESET 10.0.0.2:443'),
isDeliveryUnknown: () => false
})
expect(disposition.error).toBeNull()
expect(disposition.entries[0]?.lastFailure).toEqual({ kind: 'failed' })
})
it('drops the reason once the same message is accepted', () => {
const refused: StructuredAgentSessionOutboxEntry = {
...entry,
lastFailure: { kind: 'refused', code: 'agent_session_checkpoint_stale' }
}
const result = rejectedWith(null)
if (!result.ok || !('submission' in result.value)) {
throw new Error('expected a send result')
}
result.value.submission = { ...result.value.submission, dispatchState: 'accepted' }
const disposition = disposeStructuredAgentSessionSendResult({
entries: [refused],
entry: refused,
result,
createOperationId: () => 'unused'
})
expect(disposition.entries).toEqual([])
expect(disposition.error).toBeNull()
})
})
describe('ambiguous operation refusals', () => {
it.each([
{ ...entry, state: 'unconfirmed' as const, lastAttemptAt: 10 },
{ ...entry, state: 'queued' as const, lastAttemptAt: 10, retryAfterUnknownSubmittedAt: 10 }
])(
'never rotates $state operation after its host tombstone expires; holds it for its Retry',
(ambiguous) => {
const disposition = disposeStructuredAgentSessionSendResult({
entries: [ambiguous],
entry: ambiguous,
result: {
ok: false,
refusal: {
code: 'agent_session_operation_expired',
message: 'Operation expired.'
}
},
createOperationId: () => 'fresh-id'
})
// An earlier attempt under the kept id may have landed, so no new id goes out on its own.
expect(disposition.entries).toMatchObject([
{
clientMessageId: entry.clientMessageId,
state: 'queued',
lastFailure: { kind: 'refused', code: 'agent_session_operation_expired' }
}
])
expect(structuredAgentSessionEntryHeldForRetry(disposition.entries[0]!)).toBe(true)
expect(disposition.error).toBeNull()
}
)
it('rotates a first attempt the host calls expired, which nothing can have delivered', () => {
const disposition = disposeStructuredAgentSessionSendResult({
entries: [entry],
entry,
result: {
ok: false,
refusal: { code: 'agent_session_operation_expired', message: 'Operation expired.' }
},
createOperationId: () => 'fresh-id'
})
expect(disposition.entries).toMatchObject([{ clientMessageId: 'fresh-id', state: 'rejected' }])
})
it("parks an older host's restart answer that carries the reason but not the recovered marker", () => {
const result = rejectedWith(null)
if (!result.ok || !('submission' in result.value)) {
throw new Error('expected a send result')
}
result.value.submission = {
...result.value.submission,
dispatchState: 'unknown',
reason: 'host_restarted_before_acknowledgement'
}
const disposition = disposeStructuredAgentSessionSendResult({
entries: [entry],
entry,
result,
createOperationId: () => 'unused'
})
expect(disposition.entries).toMatchObject([
{
clientMessageId: entry.clientMessageId,
state: 'unconfirmed',
retryAfterUnknownSubmittedAt: -1
}
])
})
it('parks a recovered missing submission without polling forever', () => {
const result = rejectedWith(null)
if (!result.ok || !('submission' in result.value)) {
throw new Error('expected a send result')
}
result.value.submission = {
...result.value.submission,
dispatchState: 'unknown',
reason: 'durable_send_submission_missing',
recovered: true
}
const disposition = disposeStructuredAgentSessionSendResult({
entries: [entry],
entry,
result,
createOperationId: () => 'unused'
})
expect(disposition.entries).toMatchObject([
{
clientMessageId: entry.clientMessageId,
state: 'unconfirmed',
retryAfterUnknownSubmittedAt: -1
}
])
})
})