fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer

The error strip for a message the host accepted and then did not deliver matched the entry before
the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not
send your message" with nothing to retry. It now reads the reconciled entry.

A rejection the journal records before the send's own pending answer lands is final as well:
that answer no longer puts the entry back to dispatching with no Retry.
This commit is contained in:
Brennan Benson
2026-09-25 04:37:04 -07:00
parent c7a652fe29
commit 34d0bb0d34
3 changed files with 115 additions and 21 deletions
@@ -3,6 +3,7 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection'
const mocks = vi.hoisted(() => ({
call: vi.fn()
@@ -174,6 +175,80 @@ describe('a send the host rejected because the agent never started', () => {
expect(retried.envelope.clientOperationId).not.toBe(id)
expect(retried.body.blocks[0]?.text).toBe('hello')
})
it('says nothing when a Stop withdrew the message', async () => {
mocks.call.mockImplementation(
async (
_target: unknown,
_method: unknown,
params: { envelope: { clientOperationId: string } }
) => pendingResultFor(params.envelope.clientOperationId)
)
const target = { kind: 'local' } as const
const { result, rerender } = renderHook(
(props: { submissions: AgentJournalSubmission[] }) =>
useStructuredAgentSessionOutbox({
sessionId: 'session-1',
target,
fence: 1,
submissions: props.submissions
}),
{ initialProps: { submissions: NO_SUBMISSIONS } }
)
act(() => expect(result.current.send('hello')).toBe(true))
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching'))
const id = result.current.outbox[0]!.clientMessageId
rerender({
submissions: [
{
...pendingResultFor(id).value.submission,
dispatchState: 'rejected',
reason: DISPATCH_REJECTED_CANCELLED
}
]
})
await waitFor(() => expect(result.current.outbox).toEqual([]))
expect(result.current.error).toBeNull()
})
it('keeps the rejection when the journal settles the message before the send answers', async () => {
const reason = "Codex couldn't restart: spawn codex ENOENT."
let answer: (value: unknown) => void = () => undefined
mocks.call.mockImplementationOnce(
() =>
new Promise((resolve) => {
answer = resolve
})
)
const target = { kind: 'local' } as const
const { result, rerender } = renderHook(
(props: { submissions: AgentJournalSubmission[] }) =>
useStructuredAgentSessionOutbox({
sessionId: 'session-1',
target,
fence: 1,
submissions: props.submissions
}),
{ initialProps: { submissions: NO_SUBMISSIONS } }
)
act(() => expect(result.current.send('hello')).toBe(true))
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching'))
const id = result.current.outbox[0]!.clientMessageId
// A start refused at once: the rejection frame lands before the send's own `pending` answer.
rerender({
submissions: [{ ...pendingResultFor(id).value.submission, dispatchState: 'rejected', reason }]
})
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected'))
await act(async () => answer(pendingResultFor(id)))
expect(result.current.outbox[0]?.state).toBe('rejected')
expect(result.current.error).toBe(reason)
})
})
const NO_SUBMISSIONS: AgentJournalSubmission[] = []
@@ -8,8 +8,8 @@ import {
type StructuredAgentSessionOutboxEntry
} from '../../../../shared/structured-agent-session-outbox'
import {
rejectedDispatchingSubmission,
structuredAgentSessionRejectionNotice,
journalAnswersInFlightSend,
reconciledRejectionNotice,
type StructuredAgentSessionSendDisposition
} from '../../../../shared/structured-agent-session-send-disposition'
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
@@ -106,7 +106,7 @@ export function useStructuredAgentSessionOutbox(args: {
.map((submission) => submission.clientMessageId)
)
const next = reconcileStructuredAgentSessionOutbox(current, submissions)
const admittedInFlight = inFlightIdRef.current !== null && hostOwns.has(inFlightIdRef.current)
const admittedInFlight = journalAnswersInFlightSend(submissions, inFlightIdRef.current)
if (
admittedInFlight ||
next.some((entry, index) => entry !== current[index]) ||
@@ -124,9 +124,9 @@ export function useStructuredAgentSessionOutbox(args: {
dispatchGenerationRef.current += 1
inFlightIdRef.current = null
}
const rejected = rejectedDispatchingSubmission(current, submissions)
if (rejected) {
setError(structuredAgentSessionRejectionNotice(rejected.reason))
const rejection = reconciledRejectionNotice(current, next, submissions)
if (rejection) {
setError(rejection)
} else if (blockedIdRef.current !== null && hostOwns.has(blockedIdRef.current)) {
blockedIdRef.current = null
setError(null)
@@ -73,6 +73,40 @@ function refusedRedelivery(
)
}
/** Whether the journal already answers a send still in flight, so its own reply adds nothing: the
* host holds the message, or rejected it — a later `pending` reply must not undo that. */
export function journalAnswersInFlightSend(
submissions: readonly AgentJournalSubmission[],
clientMessageId: string | null
): boolean {
return submissions.some(
(submission) =>
submission.clientMessageId === clientMessageId && submission.dispatchState !== 'unknown'
)
}
/** What to say for a message the reconcile just settled as accepted and then not delivered, which
* keeps its Retry; null when there is none. A Stop's withdrawal is dropped there, so says nothing. */
export function reconciledRejectionNotice(
previous: readonly StructuredAgentSessionOutboxEntry[],
reconciled: readonly StructuredAgentSessionOutboxEntry[],
submissions: readonly AgentJournalSubmission[]
): string | null {
const was = (
entries: readonly StructuredAgentSessionOutboxEntry[],
clientMessageId: string,
state: StructuredAgentSessionOutboxEntry['state']
): boolean =>
entries.some((entry) => entry.clientMessageId === clientMessageId && entry.state === state)
const rejected = submissions.find(
(submission) =>
submission.dispatchState === 'rejected' &&
was(previous, submission.clientMessageId, 'dispatching') &&
was(reconciled, submission.clientMessageId, 'rejected')
)
return rejected ? structuredAgentSessionRejectionNotice(rejected.reason) : null
}
/**
* What to put on screen for a rejection.
*
@@ -90,21 +124,6 @@ function refusedRedelivery(
* 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.
*/
/** A message the host accepted and then did not deliver: the chat says why and offers Retry. */
export function rejectedDispatchingSubmission(
entries: readonly StructuredAgentSessionOutboxEntry[],
submissions: readonly AgentJournalSubmission[]
): AgentJournalSubmission | undefined {
return submissions.find(
(submission) =>
submission.dispatchState === 'rejected' &&
entries.some(
(entry) =>
entry.clientMessageId === submission.clientMessageId && entry.state === 'dispatching'
)
)
}
export function structuredAgentSessionRejectionNotice(reason: string | null): string {
if (reason === null) {
return 'Message was not sent.'