Merge remote-tracking branch 'origin/brennanb2025/chat-failed-send-returns-to-composer' into brennanb2025/chat-send-settles-once

This commit is contained in:
Brennan Benson
2026-10-04 02:00:08 -07:00
15 changed files with 508 additions and 82 deletions
@@ -3,7 +3,7 @@ import '@testing-library/jest-dom/vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { MessageRow } from './NativeChatMessageRow'
import { MessageRow, type NativeChatDeliveryNotice } from './NativeChatMessageRow'
afterEach(cleanup)
@@ -163,8 +163,8 @@ describe('MessageRow send mode', () => {
})
})
describe('a user message that did not go through', () => {
function renderUser(deliveryNotice?: { text: string; onRetry?: () => void }) {
describe('what a user message says about its delivery', () => {
function renderUser(deliveryNotice?: NativeChatDeliveryNotice) {
return render(
<MessageRow
message={{
@@ -203,4 +203,41 @@ describe('a user message that did not go through', () => {
renderUser()
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
})
// Muted, in the time's place, and shown without hover: a message nothing confirmed yet never
// looks like one that went through. Copy keeps its hover reveal, and the row its height.
it('says quietly that it is still sending in place of its time, with no Retry', () => {
renderUser({ sending: true })
const sending = screen.getByText('Sending…')
const copy = screen.getByRole('button', { name: 'Copy message' })
expect(sending).toHaveClass('text-xs', 'text-muted-foreground')
expect(Array.from(sending.parentElement!.children)).toEqual([copy, sending])
expect(sending.parentElement).not.toHaveClass('can-hover:opacity-0')
expect(sending.parentElement!.parentElement).toHaveClass('group')
expect(copy).toHaveClass('can-hover:opacity-0', 'group-hover:opacity-100')
expect(screen.queryByRole('time')).toBeNull()
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
})
it('keeps the same row when the message is confirmed, with the time back in its place', () => {
const { rerender } = renderUser({ sending: true })
const meta = screen.getByText('Sending…').parentElement
rerender(
<MessageRow
message={{
id: 'message',
role: 'user',
timestamp: 0,
source: 'transcript',
blocks: [{ type: 'text', text: 'Message text' }]
}}
expandSignal={false}
onScrollMessageToTop={vi.fn()}
/>
)
expect(screen.queryByText('Sending…')).toBeNull()
expect(screen.getByRole('time').parentElement).toBe(meta)
expect(meta).toHaveClass('can-hover:opacity-0')
})
})
@@ -28,12 +28,45 @@ import type {
} from './native-chat-subagent-sections'
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
/** What a user message says under it when it did not go through, with its own Retry when the
* surface can send it again. */
export type NativeChatDeliveryNotice = {
text: string
onRetry?: () => void
onDismiss?: () => void
/** What a user message says about its delivery: that nothing has confirmed it yet, quietly in
* place of its time, or that it did not go through, with its own Retry when the surface can send
* it again. */
export type NativeChatDeliveryNotice =
| { sending: true; text?: never; onRetry?: never; onDismiss?: never }
| { sending?: never; text: string; onRetry?: () => void; onDismiss?: () => void }
const USER_META_REVEAL =
'transition-opacity can-hover:pointer-events-none can-hover:opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [.group:has(:focus-visible)_&]:pointer-events-auto [.group:has(:focus-visible)_&]:opacity-100'
/** Under a user message: copy + timestamp, revealed together like the agent controls row. Until
* confirmed, a quiet "Sending…" stays visible in the time's place and copy keeps its own reveal,
* so the row keeps its height when it clears. Image-only prompts have no text to copy. */
function UserMessageMeta({
markdown,
timestamp,
sending
}: {
markdown: string
timestamp: number | null
sending: boolean
}): React.JSX.Element | null {
if (!markdown && timestamp === null && !sending) {
return null
}
return (
<div className={cn('flex select-none items-center gap-1', !sending && USER_META_REVEAL)}>
{markdown ? (
<NativeChatCopyButton text={markdown} className={sending ? USER_META_REVEAL : undefined} />
) : null}
{sending ? (
<span className="text-xs whitespace-nowrap text-muted-foreground">
{translate('components.native-chat.messageSending', 'Sending…')}
</span>
) : (
<NativeChatMessageTimestamp timestamp={timestamp} focusable />
)}
</div>
)
}
/** One message: its prose first, then a collapsible run folding all of the
@@ -178,15 +211,12 @@ export const MessageRow = memo(function MessageRow({
<span>{translate('components.native-chat.goal.sentAsGoal', 'Sent as goal')}</span>
</div>
) : null}
{/* Copy + timestamp reveal together, mirroring the agent controls row.
Image-only prompts have no text to copy, so the button is omitted. */}
{markdown || message.timestamp !== null ? (
<div className="flex select-none items-center gap-1 transition-opacity can-hover:pointer-events-none can-hover:opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [.group:has(:focus-visible)_&]:pointer-events-auto [.group:has(:focus-visible)_&]:opacity-100">
{markdown ? <NativeChatCopyButton text={markdown} /> : null}
<NativeChatMessageTimestamp timestamp={message.timestamp} focusable />
</div>
) : null}
{deliveryNotice ? (
<UserMessageMeta
markdown={markdown}
timestamp={message.timestamp}
sending={deliveryNotice?.sending === true}
/>
{deliveryNotice?.text !== undefined ? (
<div className="flex max-w-[85%] items-center gap-2 text-[11px] text-destructive/80">
<span className="min-w-0 break-words">{deliveryNotice.text}</span>
{deliveryNotice.onDismiss ? (
@@ -27,7 +27,8 @@ function absent<T>(): T | undefined {
return undefined
}
/** Stands in for the transcript: renders only each message's delivery notice and its Retry. */
/** Stands in for the transcript: renders only each message's delivery notice and its Retry, or the
* row's quiet "Sending…" while nothing has confirmed it. */
export function DeliveryNoticesMock({
notices
}: {
@@ -37,7 +38,7 @@ export function DeliveryNoticesMock({
<div data-testid="message-list">
{[...(notices ?? [])].map(([id, notice]) => (
<div key={id} data-message-id={id}>
<span>{notice.text}</span>
<span>{notice.sending ? 'Sending…' : notice.text}</span>
{notice.onRetry ? (
<button type="button" onClick={notice.onRetry}>
Retry
@@ -124,9 +124,10 @@ export function NativeChatStructuredSession(
retryRef.current(clientMessageId)
}, [])
const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude')
// Only a rejected message reads the journal's rows, so a new batch of them re-renders no row else.
// Only a message still in the outbox reads the journal's rows, so a new batch of them re-renders
// no row else.
const hasRejected = controller.outbox.some((entry) => entry.state === 'rejected')
const rejectionRows = hasRejected ? controller.submissions : NO_SUBMISSIONS
const journalRows = controller.outbox.length > 0 ? controller.submissions : NO_SUBMISSIONS
const startFailures = useStructuredAgentSessionStartFailureFacts(
controller.journalItems,
hasRejected
@@ -137,7 +138,7 @@ export function NativeChatStructuredSession(
controller.outbox,
agentLabel,
retryDelivery,
rejectionRows,
journalRows,
startFailures,
controller.failedHere
),
@@ -145,7 +146,7 @@ export function NativeChatStructuredSession(
controller.outbox,
agentLabel,
retryDelivery,
rejectionRows,
journalRows,
startFailures,
controller.failedHere
]
@@ -183,7 +183,10 @@ vi.mock('./NativeChatQuestionCard', () => ({
}))
import { NativeChatStructuredSession } from './NativeChatStructuredSession'
import { appendStructuredAgentSessionOutboxMessage } from './structured-agent-session-outbox-storage'
import {
appendStructuredAgentSessionOutboxMessage,
getStructuredAgentSessionOutbox
} from './structured-agent-session-outbox-storage'
describe('NativeChatStructuredSession delivery', () => {
afterEach(() => {
@@ -232,16 +235,12 @@ describe('NativeChatStructuredSession delivery', () => {
)
}
it('retries an unconfirmed transport send and clears the delivery notice', async () => {
// Resent under its own id until the host answers, so its row says only that it is still sending.
it('says a send whose answer was lost is sending until it confirms on its own, with no Retry', async () => {
mocks.mode = 'outbox'
mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValueOnce({
ok: true,
value: {
submission: {
clientMessageId: 'client-1',
dispatchState: 'accepted'
}
}
value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } }
})
render(
@@ -259,14 +258,126 @@ describe('NativeChatStructuredSession delivery', () => {
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
expect(send?.('hello', [])).toBe(true)
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy())
// From the moment it is sent, through the lost answer, until the host confirms it.
await waitFor(() => expect(screen.getByText('Sending…')).toBeTruthy())
await waitFor(() =>
expect(getStructuredAgentSessionOutbox('session-1')).toMatchObject([{ state: 'unconfirmed' }])
)
expect(screen.getByText('Sending…')).toBeTruthy()
expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()
expect(screen.queryByRole('button', { name: /Retry/ })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Retry/ }))
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2), { timeout: 5000 })
expect(mocks.call.mock.calls[1]?.[2]).toEqual(mocks.call.mock.calls[0]?.[2])
await waitFor(() => expect(screen.queryByText('Sending…')).toBeNull())
expect(getStructuredAgentSessionOutbox('session-1')).toEqual([])
expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()
}, 10000)
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
await waitFor(() => expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull())
})
// Reopened mid-send, the send is read back in doubt; the probe resends it under its own id.
function seedMidSend(sessionId: string, patch: Record<string, unknown> = {}): void {
seedOutbox(sessionId, [
{
...seededEntry(sessionId, 'op-sent', 'first', 'queued'),
state: 'dispatching',
lastAttemptAt: 1,
retryAfterUnknownSubmittedAt: null,
...patch
}
])
}
function renderSession(sessionId: string): void {
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId={`structured-tab-${sessionId}`}
sessionId={sessionId}
target={{ kind: 'local' }}
agent="codex"
/>
)
}
it('says a send reopened mid-send is sending while it is resent, until it settles', async () => {
mocks.mode = 'outbox'
mocks.submissions = []
mocks.call.mockResolvedValue({
ok: true,
value: { submission: { clientMessageId: 'op-sent', dispatchState: 'accepted' } }
})
seedMidSend('session-reopened')
renderSession('session-reopened')
expect(getStructuredAgentSessionOutbox('session-reopened')).toMatchObject([
{ clientMessageId: 'op-sent', state: 'unconfirmed' }
])
expect(screen.getByText('Sending…')).toBeTruthy()
expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()
expect(screen.queryByRole('button', { name: /Retry/ })).toBeNull()
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce(), { timeout: 3000 })
expect(mocks.call.mock.calls[0]?.[2]).toMatchObject({
envelope: { clientOperationId: 'op-sent' }
})
await waitFor(() => expect(getStructuredAgentSessionOutbox('session-reopened')).toEqual([]))
expect(screen.queryByText('Sending…')).toBeNull()
expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()
}, 10000)
it.each([
['a live unknown', {}],
['a recovered unknown', { recovered: true }],
["an older host's recovered unknown", { reason: 'host_restarted_before_acknowledgement' }]
])(
'says a send reopened mid-send is unconfirmed, with its Retry, once the journal holds %s',
async (label, patch) => {
mocks.mode = 'outbox'
const sessionId = `session-reopened-${label.replace(/\W+/g, '-')}`
mocks.submissions = [
{
clientMessageId: 'op-sent',
fence: 1,
payloadFingerprint: 'fp',
dispatchState: 'unknown',
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null,
...patch
}
]
seedMidSend(sessionId)
renderSession(sessionId)
await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy())
expect(screen.getByRole('button', { name: /Retry/ })).toBeTruthy()
expect(screen.queryByText('Sending…')).toBeNull()
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1500))
})
expect(mocks.call).not.toHaveBeenCalled()
},
10000
)
it('says a send a Stop outlived is unconfirmed when reopened, as nothing resends it', async () => {
mocks.mode = 'outbox'
mocks.submissions = []
seedMidSend('session-reopened-stopped', { outlivedStop: true })
renderSession('session-reopened-stopped')
expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()
expect(screen.getByRole('button', { name: /Retry/ })).toBeTruthy()
expect(screen.queryByText('Sending…')).toBeNull()
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1500))
})
expect(mocks.call).not.toHaveBeenCalled()
}, 10000)
it('retries the head, not a later stuck message', async () => {
mocks.mode = 'outbox'
@@ -484,7 +595,6 @@ describe('NativeChatStructuredSession delivery', () => {
| undefined
expect(send?.('first', [])).toBe(true)
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy())
expect(send?.('second', [])).toBe(true)
// The head is probed automatically, clears, and the queue drains.
@@ -569,6 +679,8 @@ describe('NativeChatStructuredSession delivery', () => {
await act(async () => {
send?.('second', [])
})
// From the row on, only the user's Retry moves it, so it says so.
await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy())
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 3000))
})
@@ -653,7 +765,6 @@ describe('NativeChatStructuredSession delivery', () => {
| undefined
expect(send?.('first', [])).toBe(true)
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy())
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300))
@@ -669,8 +780,8 @@ describe('NativeChatStructuredSession delivery', () => {
it('never auto-probes an entry the user already force-retried', async () => {
mocks.mode = 'outbox'
mocks.submissions = []
// Both the original send and the user's explicit Retry fail at the transport.
mocks.call.mockRejectedValue(new Error('socket closed'))
seedOutbox('session-forced', [seededEntry('session-forced', 'op-head', 'first', 'unconfirmed')])
render(
<NativeChatStructuredSession
@@ -683,25 +794,12 @@ describe('NativeChatStructuredSession delivery', () => {
/>
)
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
expect(send?.('first', [])).toBe(true)
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
// Only the user's Retry moves it, so it says so.
await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy())
// User retries with the same envelope and no legacy redelivery signal.
fireEvent.click(screen.getByRole('button', { name: /Retry/ }))
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
const forcedRequest = mocks.call.mock.calls[1]?.[2] as Record<string, unknown> | undefined
expect(forcedRequest?.retryUnknown).toBeUndefined()
// That retry also failed at the transport. The probe must not repeat an
// explicit retry automatically.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 3000))
})
expect(mocks.call).toHaveBeenCalledTimes(2)
expect(mocks.call).not.toHaveBeenCalled()
}, 20000)
it('does not hot-loop when the host answers pending', async () => {
@@ -37,6 +37,8 @@ function entry(
}
const NOT_FAILED_HERE: ReadonlySet<string> = new Set()
// What the row shows, quietly in place of its time, while nothing has confirmed the message.
const SENDING = 'Sending…'
function texts(
outbox: StructuredAgentSessionOutboxEntry[],
@@ -52,7 +54,9 @@ function texts(
startFailures,
new Set(outbox.map((candidate) => candidate.clientMessageId))
)
return Object.fromEntries([...notices].map(([id, notice]) => [id, notice.text]))
return Object.fromEntries(
[...notices].map(([id, notice]) => [id, notice.sending ? SENDING : notice.text])
)
}
describe('the notice on each message that did not go through', () => {
@@ -133,7 +137,9 @@ describe('the notice on each message that did not go through', () => {
})
it('says a message is unconfirmed, and only that it was not sent when nothing more is known', () => {
expect(texts([entry('doubt', { state: 'unconfirmed' })])).toEqual({
expect(
texts([entry('doubt', { state: 'unconfirmed', retryAfterUnknownSubmittedAt: -1 })])
).toEqual({
[agentJournalSubmissionKey('doubt')]: 'Message delivery is unconfirmed.'
})
expect(texts([entry('bare', { state: 'rejected' })])).toEqual({
@@ -153,21 +159,24 @@ describe('the notice on each message that did not go through', () => {
})
// The drain's own rule: a message behind the one the queue stopped on is only waiting, so it says
// nothing. A rejected or refused message holds nothing up and keeps its words.
// only that it is still sending. A rejected or refused message holds nothing up and keeps its words.
it('says why on the message the queue stopped on and on every rejected or refused one', () => {
expect(
texts([
entry('sent', { state: 'dispatching' }),
entry('rejected', { state: 'rejected' }),
entry('failed', { lastFailure: { kind: 'failed' } }),
entry('stuck', { state: 'unconfirmed' }),
entry('stuck', { state: 'unconfirmed', retryAfterUnknownSubmittedAt: -1 }),
entry('behind', { state: 'unconfirmed' }),
entry('queued')
])
).toEqual({
[agentJournalSubmissionKey('sent')]: SENDING,
[agentJournalSubmissionKey('rejected')]: 'Message was not sent.',
[agentJournalSubmissionKey('failed')]: 'Your message was not sent.',
[agentJournalSubmissionKey('stuck')]: 'Message delivery is unconfirmed.'
[agentJournalSubmissionKey('stuck')]: 'Message delivery is unconfirmed.',
[agentJournalSubmissionKey('behind')]: SENDING,
[agentJournalSubmissionKey('queued')]: SENDING
})
})
@@ -175,7 +184,10 @@ describe('the notice on each message that did not go through', () => {
it('keeps a rejected message behind the stopped one its words but not its Retry', () => {
const retry = vi.fn()
for (const outbox of [
[entry('stuck', { state: 'unconfirmed' }), entry('rejected', { state: 'rejected' })],
[
entry('stuck', { state: 'unconfirmed', retryAfterUnknownSubmittedAt: -1 }),
entry('rejected', { state: 'rejected' })
],
[entry('held', { outlivedStop: true }), entry('rejected', { state: 'rejected' })]
]) {
const notices = structuredAgentSessionDeliveryNotices(
@@ -194,7 +206,7 @@ describe('the notice on each message that did not go through', () => {
// Ahead of the stopped message, its Retry sends it at once, so the row offers it.
it.each([
['in doubt', { state: 'unconfirmed' as const }],
['in doubt', { state: 'unconfirmed' as const, retryAfterUnknownSubmittedAt: -1 }],
['outlived by a Stop', { outlivedStop: true as const }]
])('gives a failed message ahead of one %s its Retry', (_label, patch) => {
const retry = vi.fn()
@@ -456,8 +468,209 @@ describe('the notice on each message that did not go through', () => {
)
})
it('says nothing on a message that is only waiting its turn or on its way', () => {
expect(texts([entry('queued'), entry('sending', { state: 'dispatching' })])).toEqual({})
it('says only that a message waiting its turn or on its way is still sending', () => {
const notices = structuredAgentSessionDeliveryNotices(
[entry('queued'), entry('sending', { state: 'dispatching' })],
'Claude',
vi.fn(),
[],
[],
NOT_FAILED_HERE
)
expect([...notices.values()]).toEqual([{ sending: true }, { sending: true }])
})
// Any row the host holds for it is the answer, whatever its state; the row itself then shows it.
it.each(['pending', 'accepted'] as const)(
'stops saying a message is sending once the journal holds a %s row for it',
(dispatchState) => {
const answer = (clientMessageId: string): AgentJournalSubmission => ({
clientMessageId,
fence: 1,
payloadFingerprint: 'fp',
dispatchState,
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null
})
const resent = entry('resent', { state: 'unconfirmed', lastAttemptAt: 1 })
const outbox = [entry('queued'), entry('sending', { state: 'dispatching' }), resent]
expect(texts(outbox)).toEqual({
[agentJournalSubmissionKey('queued')]: SENDING,
[agentJournalSubmissionKey('sending')]: SENDING,
[agentJournalSubmissionKey('resent')]: SENDING
})
expect(texts(outbox, [answer('queued'), answer('sending'), answer('resent')])).toEqual({})
// Read as the outbox will commit it, so the resent one never says it failed on the way.
expect(texts([resent], [answer('resent')])).toEqual({})
}
)
// Only a row that has the message ends it; one in doubt or rejected leaves it looking sent.
describe('a message whose journal row does not have it yet', () => {
const row = (
clientMessageId: string,
dispatchState: AgentJournalSubmission['dispatchState'],
patch: Partial<AgentJournalSubmission> = {}
): AgentJournalSubmission => ({
clientMessageId,
fence: 1,
payloadFingerprint: 'fp',
dispatchState,
providerItemId: null,
reason: null,
submittedAt: 7,
resolvedAt: null,
...patch
})
const stuck = entry('stuck', { state: 'unconfirmed', retryAfterUnknownSubmittedAt: -1 })
it.each([
['queued', { state: 'queued' as const }, {}],
['in flight', { state: 'dispatching' as const, lastAttemptAt: 2 }, {}],
[
'in flight, after a host restart',
{ state: 'dispatching' as const, lastAttemptAt: 2 },
{ recovered: true as const }
]
])('says it is sending while your Retry is %s', (_label, patch, rowPatch) => {
const doubt = row('m', 'unknown', rowPatch)
expect(texts([entry('m', { state: 'unconfirmed', lastAttemptAt: 1 })], [doubt])).toEqual({
[agentJournalSubmissionKey('m')]: 'Message delivery is unconfirmed.'
})
const retried = entry('m', { lastAttemptAt: 1, retryAfterUnknownSubmittedAt: 7, ...patch })
expect(texts([retried], [doubt])).toEqual({ [agentJournalSubmissionKey('m')]: SENDING })
})
it.each([
['a live unknown', {}],
['a recovered unknown', { recovered: true as const }]
])('says the second of two in doubt is sending when each row holds %s', (_label, patch) => {
const a = entry('a', { state: 'unconfirmed' })
const b = entry('b', { state: 'unconfirmed' })
expect(texts([a, b], [row('a', 'unknown', patch), row('b', 'unknown', patch)])).toEqual({
[agentJournalSubmissionKey('a')]: 'Message delivery is unconfirmed.',
[agentJournalSubmissionKey('b')]: SENDING
})
})
it('says a requeued message whose row was rejected is sending until its replay answers', () => {
const requeued = entry('q', { state: 'queued', lastAttemptAt: 1 })
const rejected = row('q', 'rejected', { reason: 'provider said no', resolvedAt: 8 })
expect(texts([requeued], [rejected])).toEqual({ [agentJournalSubmissionKey('q')]: SENDING })
expect(texts([stuck, requeued], [rejected])).toEqual({
[agentJournalSubmissionKey('stuck')]: 'Message delivery is unconfirmed.',
[agentJournalSubmissionKey('q')]: SENDING
})
})
})
// One state, one surface: a row that says it did not go through never also says it is sending.
it.each([
['rejected', entry('m', { state: 'rejected' }), []],
['held for its Retry', entry('m', { lastFailure: { kind: 'failed' } }), []],
[
'in doubt with a row the host holds',
entry('m', { state: 'unconfirmed', lastAttemptAt: 1 }),
[
{
clientMessageId: 'm',
fence: 1,
payloadFingerprint: 'fp',
dispatchState: 'unknown',
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null
} satisfies AgentJournalSubmission
]
],
[
'retried by the user',
entry('m', { state: 'unconfirmed', retryAfterUnknownSubmittedAt: 1 }),
[]
],
['outlived by a Stop', entry('m', { state: 'unconfirmed', outlivedStop: true }), []]
])('says only why on a message %s, never that it is sending', (_label, failed, rows) => {
const notice = structuredAgentSessionDeliveryNotices(
[failed],
'Claude',
vi.fn(),
rows,
[],
NOT_FAILED_HERE
).get(agentJournalSubmissionKey('m'))
expect(notice?.sending).toBeUndefined()
expect(notice?.text).toMatch(/not sent|unconfirmed/)
expect(notice?.onRetry).toBeDefined()
})
// The unconfirmed probe resends it under its own id until the journal answers, so the row says
// it is still sending, with no Retry, until a row lands.
describe('a message in doubt that Orca resends on its own', () => {
const doubt = (patch: Partial<StructuredAgentSessionOutboxEntry> = {}) =>
entry('doubt', { state: 'unconfirmed', lastAttemptAt: 1, ...patch })
const row = (patch: Partial<AgentJournalSubmission>): AgentJournalSubmission => ({
clientMessageId: 'doubt',
fence: 1,
payloadFingerprint: 'fp',
dispatchState: 'unknown',
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null,
...patch
})
const UNCONFIRMED = { [agentJournalSubmissionKey('doubt')]: 'Message delivery is unconfirmed.' }
it('says only that it is still sending while the journal holds no row for it', () => {
const notices = structuredAgentSessionDeliveryNotices(
[doubt(), entry('behind')],
'Claude',
vi.fn(),
[],
[],
NOT_FAILED_HERE
)
expect(notices.get(agentJournalSubmissionKey('doubt'))).toEqual({ sending: true })
expect(notices.get(agentJournalSubmissionKey('behind'))).toEqual({ sending: true })
// Another message's row is not its answer.
expect(
texts([doubt()], [row({ clientMessageId: 'other', dispatchState: 'accepted' })])
).toEqual({ [agentJournalSubmissionKey('doubt')]: SENDING })
})
it.each([
['a live unknown', row({})],
['a recovered unknown', row({ recovered: true })],
[
"an older host's recovered unknown",
row({ reason: 'host_restarted_before_acknowledgement' })
]
])('says it is unconfirmed, with its Retry, once the journal holds %s', (_label, answer) => {
const retry = vi.fn()
const notices = structuredAgentSessionDeliveryNotices(
[doubt()],
'Claude',
retry,
[answer],
[],
NOT_FAILED_HERE
)
expect(Object.fromEntries([...notices].map(([id, notice]) => [id, notice.text]))).toEqual(
UNCONFIRMED
)
notices.get(agentJournalSubmissionKey('doubt'))?.onRetry?.()
expect(retry).toHaveBeenCalledExactlyOnceWith('doubt')
})
it.each([
['the user already retried', { retryAfterUnknownSubmittedAt: 1 }],
['a Stop outlived', { outlivedStop: true as const }]
])('says it is unconfirmed when %s it, as nothing resends it', (_label, patch) => {
expect(texts([doubt(patch)])).toEqual(UNCONFIRMED)
})
})
// Matched on the typed fact of a row found by its identity, never on either sentence.
@@ -1,11 +1,13 @@
// Which of the structured chat's own messages say, on their row, that they did not go through.
// Which of the structured chat's own messages say, on their row, that they did not go through, and
// which say quietly that they are still sending: every other one, until the host holds a row that
// has it (pending or accepted). A row in doubt or rejected does not, so it still reads as sending.
//
// Derived from the outbox on every render and never stored: each failed or held message carries
// its own typed failure, so each row words its own reason. Read through the drain's own rule: while
// the queue is stopped, the message it stopped on and any failed one ahead of it have a Retry, as
// each would go out at once; one behind it would wait unseen. One waiting behind says nothing; a
// rejected or refused message holds nothing up, so it keeps its words and gets its Retry once the
// queue moves.
// each would go out at once; one behind it would wait unseen. One waiting behind has no failure of
// its own, so it reads as sending; a rejected or refused message holds nothing up, so it keeps its
// words and gets its Retry once the queue moves.
//
// A message the host recorded and then rejected is worded from the journal's own fact, found by id;
// the message keeps only a smaller copy, read when its submission is not loaded. A rejection that
@@ -32,12 +34,17 @@ import {
admitStructuredAgentSessionOutboxEntry,
structuredAgentSessionEntryHeldForRetry
} from '../../../../shared/structured-agent-session-outbox-admission'
import { reconcileStructuredAgentSessionOutboxWithQueue } from '../../../../shared/structured-agent-session-draft-hand-off'
import { structuredAgentSessionEntryResendsUnconfirmed } from '../../../../shared/structured-agent-session-outbox-unconfirmed-resend'
import type { AgentSessionFailureWordsContext } from '../../../../shared/agent-session-failure-words'
import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition'
import { translate } from '@/i18n/i18n'
import { agentSessionWriteNoticeText } from './agent-session-write-notice-text'
import type { NativeChatDeliveryNotice } from './NativeChatMessageRow'
/** One shared value, so a rebuilt map re-renders no row still sending. */
const STRUCTURED_AGENT_SESSION_DELIVERY_SENDING: NativeChatDeliveryNotice = { sending: true }
/** The facts the chat's loaded start-failure rows state. */
export function structuredAgentSessionStartFailureFacts(
items: readonly AgentJournalRenderItem[]
@@ -135,27 +142,32 @@ export function structuredAgentSessionDeliveryNotices(
outbox: readonly StructuredAgentSessionOutboxEntry[],
agentName: string,
retry: (clientMessageId: string) => void,
/** The journal's rows, whose rejected ones carry more of a rejection than the message keeps. */
/** The journal's rows: rejected ones carry more of a rejection than the message keeps, and a
* message with no pending or accepted one is still sending. */
submissions: readonly AgentJournalSubmission[],
/** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */
startFailures: readonly AgentSessionFailureFact[],
/** Ids whose send failed or was refused while this chat was open: only they word their cause. */
failedHere: ReadonlySet<string>
): ReadonlyMap<string, NativeChatDeliveryNotice> {
const admission = admitStructuredAgentSessionOutboxEntry(outbox)
// As the transcript reads it, so a row that lands is answered here before the outbox commits it.
const entries = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions)
const admission = admitStructuredAgentSessionOutboxEntry(entries)
const held = admission.state === 'blocked' ? admission.entry.clientMessageId : null
const stalledFrom = admission.state === 'blocked' ? outbox.indexOf(admission.entry) : -1
const stalledFrom = admission.state === 'blocked' ? entries.indexOf(admission.entry) : -1
const rejected = new Map(
submissions
.filter((submission) => submission.dispatchState === 'rejected')
.map((submission) => [submission.clientMessageId, submission])
)
const notices = new Map<string, NativeChatDeliveryNotice>()
for (const [index, entry] of outbox.entries()) {
for (const [index, entry] of entries.entries()) {
// Resent under its own id until the journal answers, so still sending, not failed.
if (
entry.state === 'rejected' ||
structuredAgentSessionEntryHeldForRetry(entry) ||
entry.clientMessageId === held
!structuredAgentSessionEntryResendsUnconfirmed(entry, submissions) &&
(entry.state === 'rejected' ||
structuredAgentSessionEntryHeldForRetry(entry) ||
entry.clientMessageId === held)
) {
// Its own Retry is the step, so the words leave out sending again.
const retryControl = stalledFrom === -1 || index <= stalledFrom
@@ -170,6 +182,17 @@ export function structuredAgentSessionDeliveryNotices(
agentJournalSubmissionKey(entry.clientMessageId),
retryControl ? { text, onRetry: () => retry(entry.clientMessageId) } : { text }
)
} else if (
!submissions.some(
(submission) =>
submission.clientMessageId === entry.clientMessageId &&
(submission.dispatchState === 'pending' || submission.dispatchState === 'accepted')
)
) {
notices.set(
agentJournalSubmissionKey(entry.clientMessageId),
STRUCTURED_AGENT_SESSION_DELIVERY_SENDING
)
}
}
return notices
@@ -1,6 +1,7 @@
import { useEffect, useLayoutEffect, useRef } from 'react'
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
import { structuredAgentSessionEntryResendsUnconfirmed } from '../../../../shared/structured-agent-session-outbox-unconfirmed-resend'
import {
commitStructuredAgentSessionOutbox,
getStructuredAgentSessionOutbox
@@ -40,17 +41,15 @@ export function useStructuredAgentSessionOutboxUnconfirmedProbe(args: {
// A non-null `retryAfterUnknownSubmittedAt` means the user already retried, so
// another request would repeat that explicit action. Only entries that have
// never been retried, and that no Stop outlived, are safe to probe automatically.
// The delivery notices read the same rule: while it is resent here, its row says it is sending.
const probeId =
blocker &&
blocker.sessionId === sessionId &&
blocker.retryAfterUnknownSubmittedAt === null &&
blocker.outlivedStop !== true
structuredAgentSessionEntryResendsUnconfirmed(blocker, submissions)
? blocker.clientMessageId
: null
const probeSettled =
probeId !== null && submissions.some((submission) => submission.clientMessageId === probeId)
useEffect(() => {
if (probeId === null || probeSettled || !owner.attached) {
if (probeId === null || !owner.attached) {
return
}
const attempts = probeAttemptsRef.current.id === probeId ? probeAttemptsRef.current.attempts : 0
@@ -70,5 +69,5 @@ export function useStructuredAgentSessionOutboxUnconfirmedProbe(args: {
Math.min(UNCONFIRMED_PROBE_BASE_DELAY_MS * 2 ** attempts, UNCONFIRMED_PROBE_MAX_DELAY_MS)
)
return () => clearTimeout(timer)
}, [owner.attached, owner.ownerChange, owner.targetKey, probeId, probeSettled, sessionId])
}, [owner.attached, owner.ownerChange, owner.targetKey, probeId, sessionId])
}
+1
View File
@@ -18055,6 +18055,7 @@
"dismissDeliveryNotice": "Dismiss",
"messageNotSent": "Message not sent",
"deliveryUnconfirmed": "Delivery unconfirmed — check chat before retrying",
"messageSending": "Sending…",
"queuedMessages": {
"behindReturnedHold": "Waiting — a message ahead needs attention",
"awaitingAnswerHold": "Waiting for your answer",
+1
View File
@@ -15200,6 +15200,7 @@
"dismissDeliveryNotice": "Descartar",
"messageNotSent": "Mensaje no enviado",
"deliveryUnconfirmed": "Entrega sin confirmar — revisa el chat antes de reintentar",
"messageSending": "Enviando…",
"subagents": {
"unnamed": "Subagente"
},
+1
View File
@@ -17888,6 +17888,7 @@
"dismissDeliveryNotice": "Ignorer",
"messageNotSent": "Message non envoyé",
"deliveryUnconfirmed": "Livraison non confirmée — vérifiez le chat avant de réessayer",
"messageSending": "Envoi…",
"queuedMessages": {
"alreadySent": "Ce message a déjà été envoyé.",
"editAlreadySent": "Déjà envoyé — votre texte reste dans la zone de saisie.",
+1
View File
@@ -17825,6 +17825,7 @@
"dismissDeliveryNotice": "閉じる",
"messageNotSent": "メッセージは送信されませんでした",
"deliveryUnconfirmed": "配信を確認できません — 再試行する前にチャットを確認してください",
"messageSending": "送信中…",
"queuedMessages": {
"alreadySent": "このメッセージはすでに送信されています。",
"editAlreadySent": "すでに送信されています — テキストは入力欄に残っています。",
+1
View File
@@ -17825,6 +17825,7 @@
"dismissDeliveryNotice": "닫기",
"messageNotSent": "메시지가 전송되지 않음",
"deliveryUnconfirmed": "전달 확인 안 됨 — 다시 시도하기 전에 채팅을 확인하세요",
"messageSending": "전송 중…",
"queuedMessages": {
"alreadySent": "이 메시지는 이미 전송되었습니다.",
"editAlreadySent": "이미 전송되었습니다 — 텍스트는 입력창에 남아 있습니다.",
+1
View File
@@ -17842,6 +17842,7 @@
"dismissDeliveryNotice": "关闭",
"messageNotSent": "消息未发送",
"deliveryUnconfirmed": "无法确认送达 — 重试前请检查聊天",
"messageSending": "正在发送…",
"queuedMessages": {
"alreadySent": "此消息已发送。",
"editAlreadySent": "已发送 — 你的文本仍在输入框中。",
@@ -0,0 +1,18 @@
// Which send in doubt Orca resends on its own under the same id, without the user's Retry.
import type { AgentJournalSubmission } from './agent-session-journal-types'
import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox'
/** Whether the unconfirmed probe resends this entry: in doubt, never retried by the user, not
* outlived by a Stop, and with no journal row yet. Any row ends it: the journal answers from there. */
export function structuredAgentSessionEntryResendsUnconfirmed(
entry: StructuredAgentSessionOutboxEntry,
submissions: readonly AgentJournalSubmission[]
): boolean {
return (
entry.state === 'unconfirmed' &&
entry.retryAfterUnknownSubmittedAt === null &&
entry.outlivedStop !== true &&
!submissions.some((submission) => submission.clientMessageId === entry.clientMessageId)
)
}