fix(native-chat): show "Sending…" on a message until the host confirms it (#24606)

* fix(native-chat): no "unconfirmed" line while Orca resends a send on its own

A send whose answer was lost (for example after reopening the chat mid-send)
showed "Message delivery is unconfirmed." with a Retry for the moment before
the automatic resend under the same message id confirmed it. One rule now
decides both: the resend runs, and the notice stays quiet, only while the
send's answer is lost, the user never retried it, it was not still going out
when the user pressed Stop, and the host has no record of it yet. Once the
host has any record of it, the line and Retry are exactly as before.

* fix(native-chat): say "Sending…" on a message until the host confirms it

A send whose answer was lost was resent quietly under the same id but looked
like any delivered message. Every message still in the outbox with no failure
to show now says "Sending…", muted, in place of its time, until the journal
holds a row for it. Rows that say a message did not go through keep only that
line and its Retry. The notices read the outbox through the same reconcile as
the transcript, so a row that lands clears the marker in one step.

* fix(native-chat): keep "Sending…" until the host has the message, in the time's slot

A message the user retried, a second message in doubt, or one requeued over a
rejected row had a journal row that did not hold it, so it showed nothing and
looked sent. "Sending…" now stays until the row is pending or accepted.

The marker took the place of the whole meta row, so the copy button went away
while sending and the row jumped when it cleared. The row now stays mounted:
copy keeps its hover reveal and "Sending…" sits where the time goes.

* test(native-chat): move the delivery probe tests into their own file

NativeChatStructuredSessionDelivery.test.tsx went over the 800-line lint
limit after main was merged. The eight tests for the automatic probe of an
unconfirmed message move unchanged to
NativeChatStructuredSessionDelivery.probe.test.tsx, which uses the shared
structured-session test harness for its mocks. The outbox seeding and probe
clock helpers move into that harness so both files share them.

The at-most-once reliability gate now lists the new file, with a fresh
evidence run.
This commit is contained in:
Brennan Benson
2026-10-04 16:08:40 -07:00
committed by GitHub
parent 52b3766f48
commit 3b01ba6d0d
17 changed files with 945 additions and 448 deletions
+6 -5
View File
@@ -948,7 +948,7 @@
"invariant": "One structured-send operation id causes at most one provider dispatch. A recorded or transport-ambiguous send reuses that id across retry, caller reconnect, client remount, and journal recovery; only a terminal rejection may rotate to a first delivery.",
"oracle": "Inject adapter acknowledgement loss, RPC response loss, caller replacement, logical-client close after response, auth recovery with a written request, missing journal submissions, legacy pending rows, stale fences, operation expiry, mobile remount, and durable-journal capacity. Assert one provider dispatch or one operation id for every ambiguous retry, fresh identity only after rejection, and no eviction of ambiguous mobile ids.",
"commands": [
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
"ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/mobile-structured-send-operation-journal.test.ts ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts"
],
"testFiles": [
@@ -963,6 +963,7 @@
"src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx",
"src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx",
"src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx",
"src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx",
"src/renderer/src/lib/launch-structured-agent-session.test.ts",
"mobile/src/session/mobile-native-chat-image-attachment.test.ts",
"mobile/src/session/use-mobile-native-chat-image-attachments.test.ts",
@@ -1033,13 +1034,13 @@
],
"evidenceRuns": [
{
"date": "2026-09-12",
"date": "2026-10-04",
"runner": "local",
"platform": "macos",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
"result": "passed",
"durationSeconds": 22.1,
"summary": "Thirteen focused host, shared, renderer, and orchestration files passed 148 tests."
"durationSeconds": 42.3,
"summary": "Thirteen focused host, shared, renderer, and orchestration files passed 197 tests after the delivery probe tests moved to their own file."
},
{
"date": "2026-09-12",
@@ -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 ? (
@@ -1,3 +1,4 @@
import { act } from '@testing-library/react'
import { forwardRef, useImperativeHandle, useRef } from 'react'
import { vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
@@ -27,7 +28,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 +39,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
@@ -49,6 +51,43 @@ export function DeliveryNoticesMock({
)
}
export function useProbeClock(): void {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
}
export async function advanceProbeClock(milliseconds: number): Promise<void> {
await act(async () => {
await vi.advanceTimersByTimeAsync(milliseconds)
})
}
export function seededEntry(
sessionId: string,
clientMessageId: string,
text: string,
state: 'queued' | 'unconfirmed'
) {
return {
clientMessageId,
sessionId,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] },
previewUris: [],
state,
queuedAt: clientMessageId === 'op-head' ? 1 : 2,
lastAttemptAt: null,
// Already force-retried once, so the automatic probe leaves the head alone
// and only the user's Retry moves it.
retryAfterUnknownSubmittedAt: -1
}
}
export function seedOutbox(sessionId: string, entries: unknown[]): void {
localStorage.setItem(
`orca:desktopStructuredAgentSessionOutbox:v1:${encodeURIComponent(sessionId)}`,
JSON.stringify(entries)
)
}
type StructuredSessionMessageListProps = {
allowFileUriLinks?: boolean
isVisible?: boolean
@@ -0,0 +1,362 @@
// The automatic probe of an unconfirmed outbox head: when it resends under the same
// operation id, and when it parks the entry for the user's Retry instead.
// @vitest-environment happy-dom
import { act, cleanup, render, screen } from '@testing-library/react'
import React from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () =>
(await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks()
)
vi.mock('@/runtime/structured-agent-session-client', () =>
moduleFactories.structuredAgentSessionClient()
)
vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession())
vi.mock('./use-native-chat-font-scale', () => moduleFactories.useNativeChatFontScale())
vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext())
vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick())
vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList())
vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer())
vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState())
vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard())
vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard())
import { NativeChatStructuredSession } from './NativeChatStructuredSession'
import {
advanceProbeClock,
seededEntry,
seedOutbox,
useProbeClock
} from './NativeChatStructuredSession.test-harness'
describe('NativeChatStructuredSession delivery probe', () => {
afterEach(() => {
cleanup()
vi.useRealTimers()
localStorage.clear()
resetStructuredSessionMocks()
})
it('resends a transport-unconfirmed head so later messages are not wedged', async () => {
useProbeClock()
mocks.mode = 'outbox'
mocks.submissions = []
mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({
ok: true,
value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } }
})
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-wedge"
sessionId="session-wedge"
target={{ kind: 'local' }}
agent="codex"
/>
)
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
await act(async () => {
expect(send?.('first', [])).toBe(true)
})
expect(mocks.call).toHaveBeenCalledOnce()
await act(async () => {
expect(send?.('second', [])).toBe(true)
})
await advanceProbeClock(999)
expect(mocks.call).toHaveBeenCalledOnce()
await advanceProbeClock(1)
// The head is probed automatically, clears, and the queue drains.
expect(mocks.call).toHaveBeenCalledTimes(3)
expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()
}, 20000)
it('probes the same operation without marking an explicit user retry', async () => {
useProbeClock()
mocks.mode = 'outbox'
mocks.submissions = []
mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({
ok: true,
value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } }
})
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-probe-flag"
sessionId="session-probe-flag"
target={{ kind: 'local' }}
agent="codex"
/>
)
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
await act(async () => {
expect(send?.('first', [])).toBe(true)
})
await advanceProbeClock(999)
expect(mocks.call).toHaveBeenCalledOnce()
await advanceProbeClock(1)
expect(mocks.call).toHaveBeenCalledTimes(2)
const first = mocks.call.mock.calls[0]?.[2] as Record<string, unknown>
const probe = mocks.call.mock.calls[1]?.[2] as Record<string, unknown>
expect(probe.retryUnknown).toBeUndefined()
// Same operation id: both dedupe layers key off it.
expect((probe.envelope as { clientOperationId: string }).clientOperationId).toBe(
(first.envelope as { clientOperationId: string }).clientOperationId
)
}, 20000)
it('parks a host-confirmed unknown instead of probing it', async () => {
useProbeClock()
mocks.mode = 'outbox'
mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({
ok: true,
value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } }
})
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-parked"
sessionId="session-parked"
target={{ kind: 'local' }}
agent="codex"
/>
)
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
await act(async () => {
expect(send?.('first', [])).toBe(true)
})
expect(mocks.call).toHaveBeenCalledOnce()
const sent = mocks.call.mock.calls[0]?.[2] as { envelope: { clientOperationId: string } }
// The host now reports an unresolved unknown: another replay is the user's call.
mocks.submissions = [
{
clientMessageId: sent.envelope.clientOperationId,
fence: 1,
payloadFingerprint: 'fp',
dispatchState: 'unknown',
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null
}
]
// Queue a second message purely to re-render so the effect observes the
// new submissions; it must stay wedged behind the parked head.
await act(async () => {
send?.('second', [])
})
await advanceProbeClock(3000)
// From the row on, only the user's Retry moves it, so it says so.
expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()
expect(mocks.call).toHaveBeenCalledOnce()
}, 20000)
it('still probes while streaming batches rebuild the submissions array', async () => {
useProbeClock()
mocks.mode = 'outbox'
mocks.submissions = []
mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({
ok: true,
value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } }
})
const makeView = (): React.ReactElement => (
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-churn"
sessionId="session-churn"
target={{ kind: 'local' }}
agent="codex"
/>
)
const { rerender } = render(makeView())
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
await act(async () => {
expect(send?.('first', [])).toBe(true)
})
expect(mocks.call).toHaveBeenCalledOnce()
// Each batch mints a fresh submissions array for an unrelated message. An
// array-identity dependency restarts the backoff on every one of these, so a
// stream that outlasts the delay would never let the probe fire.
for (let index = 0; index < 12; index += 1) {
mocks.submissions = [
{
clientMessageId: `other-${index}`,
fence: 1,
payloadFingerprint: 'fp',
dispatchState: 'accepted',
providerItemId: null,
reason: null,
submittedAt: index,
resolvedAt: index
}
]
await act(async () => {
rerender(makeView())
await vi.advanceTimersByTimeAsync(250)
})
}
// Asserted with no trailing grace period: the probe must have fired *during*
// the stream, not after it went quiet.
expect(mocks.call).toHaveBeenCalledTimes(2)
}, 20000)
it('restarts probe delay when the runtime target changes', async () => {
useProbeClock()
mocks.mode = 'outbox'
mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({
ok: true,
value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } }
})
const makeView = (
target: { kind: 'local' } | { kind: 'environment'; environmentId: string }
) => (
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-target-switch"
sessionId="session-target-switch"
target={target}
agent="codex"
/>
)
const { rerender } = render(makeView({ kind: 'local' }))
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
await act(async () => {
expect(send?.('first', [])).toBe(true)
})
expect(mocks.call).toHaveBeenCalledOnce()
await advanceProbeClock(300)
rerender(makeView({ kind: 'environment', environmentId: 'env-1' }))
await advanceProbeClock(600)
expect(mocks.call).toHaveBeenCalledOnce()
await advanceProbeClock(399)
expect(mocks.call).toHaveBeenCalledOnce()
await advanceProbeClock(1)
expect(mocks.call).toHaveBeenCalledTimes(2)
}, 10000)
it('never auto-probes an entry the user already force-retried', async () => {
useProbeClock()
mocks.mode = 'outbox'
mocks.submissions = []
mocks.call.mockRejectedValue(new Error('socket closed'))
seedOutbox('session-forced', [seededEntry('session-forced', 'op-head', 'first', 'unconfirmed')])
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-forced"
sessionId="session-forced"
target={{ kind: 'local' }}
agent="codex"
/>
)
await advanceProbeClock(3000)
// Only the user's Retry moves it, so it says so.
expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()
expect(mocks.call).not.toHaveBeenCalled()
}, 20000)
it('does not hot-loop when the host answers pending', async () => {
useProbeClock()
mocks.mode = 'outbox'
mocks.call.mockResolvedValue({
ok: true,
value: { submission: { clientMessageId: 'client-1', dispatchState: 'pending' } }
})
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-pending"
sessionId="session-pending"
target={{ kind: 'local' }}
agent="codex"
/>
)
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
await act(async () => {
expect(send?.('first', [])).toBe(true)
})
expect(mocks.call).toHaveBeenCalledOnce()
// A host-pending entry stays parked until the journal answers it.
await advanceProbeClock(999)
expect(mocks.call).toHaveBeenCalledOnce()
await advanceProbeClock(1)
expect(mocks.call).toHaveBeenCalledOnce()
await advanceProbeClock(1500)
expect(mocks.call).toHaveBeenCalledOnce()
}, 20000)
it('keeps probing past the old five-attempt budget', async () => {
mocks.mode = 'outbox'
mocks.call.mockRejectedValue(new Error('socket closed'))
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-budget"
sessionId="session-budget"
target={{ kind: 'local' }}
agent="codex"
/>
)
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
expect(send?.('first', [])).toBe(true)
// Backoff is 1+2+4+8+16 = 31s for five probes, which was the old hard budget.
// Step past it; a seventh call proves the probe re-arms instead of giving up.
for (let step = 0; step < 12; step += 1) {
await act(async () => {
await vi.advanceTimersByTimeAsync(8_000)
})
}
expect(mocks.call.mock.calls.length).toBeGreaterThanOrEqual(7)
} finally {
vi.useRealTimers()
}
}, 30000)
})
@@ -1,5 +1,5 @@
// The delivery notice and the outbox queue behind it: which entry a Retry acts
// on, when no notice is owed at all, and how a host-confirmed unknown is probed.
// on, and when no notice is owed at all. The automatic probe is in the .probe suite.
// @vitest-environment happy-dom
@@ -187,20 +187,14 @@ vi.mock('./NativeChatQuestionCard', () => ({
}))
import { NativeChatStructuredSession } from './NativeChatStructuredSession'
import { appendStructuredAgentSessionOutboxMessage } from './structured-agent-session-outbox-storage'
import { seededEntry, seedOutbox } from './NativeChatStructuredSession.test-harness'
import {
appendStructuredAgentSessionOutboxMessage,
getStructuredAgentSessionOutbox
} from './structured-agent-session-outbox-storage'
const REFUSED_RESTART = "The agent couldn't restart. Your message was not sent."
function useProbeClock(): void {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
}
async function advanceProbeClock(milliseconds: number): Promise<void> {
await act(async () => {
await vi.advanceTimersByTimeAsync(milliseconds)
})
}
describe('NativeChatStructuredSession delivery', () => {
afterEach(() => {
cleanup()
@@ -222,43 +216,12 @@ describe('NativeChatStructuredSession delivery', () => {
mocks.backgroundTasks = []
})
function seededEntry(
sessionId: string,
clientMessageId: string,
text: string,
state: 'queued' | 'unconfirmed'
) {
return {
clientMessageId,
sessionId,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] },
previewUris: [],
state,
queuedAt: clientMessageId === 'op-head' ? 1 : 2,
lastAttemptAt: null,
// Already force-retried once, so the automatic probe leaves the head alone
// and only the user's Retry moves it.
retryAfterUnknownSubmittedAt: -1
}
}
function seedOutbox(sessionId: string, entries: unknown[]): void {
localStorage.setItem(
`orca:desktopStructuredAgentSessionOutbox:v1:${encodeURIComponent(sessionId)}`,
JSON.stringify(entries)
)
}
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(
@@ -276,14 +239,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'
@@ -498,343 +573,6 @@ describe('NativeChatStructuredSession delivery', () => {
})
})
it('resends a transport-unconfirmed head so later messages are not wedged', async () => {
useProbeClock()
mocks.mode = 'outbox'
mocks.submissions = []
mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({
ok: true,
value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } }
})
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-wedge"
sessionId="session-wedge"
target={{ kind: 'local' }}
agent="codex"
/>
)
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
await act(async () => {
expect(send?.('first', [])).toBe(true)
})
expect(mocks.call).toHaveBeenCalledOnce()
expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()
await act(async () => {
expect(send?.('second', [])).toBe(true)
})
await advanceProbeClock(999)
expect(mocks.call).toHaveBeenCalledOnce()
await advanceProbeClock(1)
// The head is probed automatically, clears, and the queue drains.
expect(mocks.call).toHaveBeenCalledTimes(3)
expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()
}, 20000)
it('probes the same operation without marking an explicit user retry', async () => {
useProbeClock()
mocks.mode = 'outbox'
mocks.submissions = []
mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({
ok: true,
value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } }
})
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-probe-flag"
sessionId="session-probe-flag"
target={{ kind: 'local' }}
agent="codex"
/>
)
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
await act(async () => {
expect(send?.('first', [])).toBe(true)
})
await advanceProbeClock(999)
expect(mocks.call).toHaveBeenCalledOnce()
await advanceProbeClock(1)
expect(mocks.call).toHaveBeenCalledTimes(2)
const first = mocks.call.mock.calls[0]?.[2] as Record<string, unknown>
const probe = mocks.call.mock.calls[1]?.[2] as Record<string, unknown>
expect(probe.retryUnknown).toBeUndefined()
// Same operation id: both dedupe layers key off it.
expect((probe.envelope as { clientOperationId: string }).clientOperationId).toBe(
(first.envelope as { clientOperationId: string }).clientOperationId
)
}, 20000)
it('parks a host-confirmed unknown instead of probing it', async () => {
useProbeClock()
mocks.mode = 'outbox'
mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({
ok: true,
value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } }
})
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-parked"
sessionId="session-parked"
target={{ kind: 'local' }}
agent="codex"
/>
)
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
await act(async () => {
expect(send?.('first', [])).toBe(true)
})
expect(mocks.call).toHaveBeenCalledOnce()
const sent = mocks.call.mock.calls[0]?.[2] as { envelope: { clientOperationId: string } }
// The host now reports an unresolved unknown: another replay is the user's call.
mocks.submissions = [
{
clientMessageId: sent.envelope.clientOperationId,
fence: 1,
payloadFingerprint: 'fp',
dispatchState: 'unknown',
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null
}
]
// Queue a second message purely to re-render so the effect observes the
// new submissions; it must stay wedged behind the parked head.
await act(async () => {
send?.('second', [])
})
await advanceProbeClock(3000)
expect(mocks.call).toHaveBeenCalledOnce()
}, 20000)
it('still probes while streaming batches rebuild the submissions array', async () => {
useProbeClock()
mocks.mode = 'outbox'
mocks.submissions = []
mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({
ok: true,
value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } }
})
const makeView = (): React.ReactElement => (
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-churn"
sessionId="session-churn"
target={{ kind: 'local' }}
agent="codex"
/>
)
const { rerender } = render(makeView())
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
await act(async () => {
expect(send?.('first', [])).toBe(true)
})
expect(mocks.call).toHaveBeenCalledOnce()
// Each batch mints a fresh submissions array for an unrelated message. An
// array-identity dependency restarts the backoff on every one of these, so a
// stream that outlasts the delay would never let the probe fire.
for (let index = 0; index < 12; index += 1) {
mocks.submissions = [
{
clientMessageId: `other-${index}`,
fence: 1,
payloadFingerprint: 'fp',
dispatchState: 'accepted',
providerItemId: null,
reason: null,
submittedAt: index,
resolvedAt: index
}
]
await act(async () => {
rerender(makeView())
await vi.advanceTimersByTimeAsync(250)
})
}
// Asserted with no trailing grace period: the probe must have fired *during*
// the stream, not after it went quiet.
expect(mocks.call).toHaveBeenCalledTimes(2)
}, 20000)
it('restarts probe delay when the runtime target changes', async () => {
useProbeClock()
mocks.mode = 'outbox'
mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({
ok: true,
value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } }
})
const makeView = (
target: { kind: 'local' } | { kind: 'environment'; environmentId: string }
) => (
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-target-switch"
sessionId="session-target-switch"
target={target}
agent="codex"
/>
)
const { rerender } = render(makeView({ kind: 'local' }))
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
await act(async () => {
expect(send?.('first', [])).toBe(true)
})
expect(mocks.call).toHaveBeenCalledOnce()
expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()
await advanceProbeClock(300)
rerender(makeView({ kind: 'environment', environmentId: 'env-1' }))
await advanceProbeClock(600)
expect(mocks.call).toHaveBeenCalledOnce()
await advanceProbeClock(399)
expect(mocks.call).toHaveBeenCalledOnce()
await advanceProbeClock(1)
expect(mocks.call).toHaveBeenCalledTimes(2)
}, 10000)
it('never auto-probes an entry the user already force-retried', async () => {
useProbeClock()
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'))
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-forced"
sessionId="session-forced"
target={{ kind: 'local' }}
agent="codex"
/>
)
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
await act(async () => {
expect(send?.('first', [])).toBe(true)
})
expect(mocks.call).toHaveBeenCalledOnce()
expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()
// User retries with the same envelope and no legacy redelivery signal.
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Retry/ }))
})
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 advanceProbeClock(3000)
expect(mocks.call).toHaveBeenCalledTimes(2)
}, 20000)
it('does not hot-loop when the host answers pending', async () => {
useProbeClock()
mocks.mode = 'outbox'
mocks.call.mockResolvedValue({
ok: true,
value: { submission: { clientMessageId: 'client-1', dispatchState: 'pending' } }
})
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-pending"
sessionId="session-pending"
target={{ kind: 'local' }}
agent="codex"
/>
)
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
await act(async () => {
expect(send?.('first', [])).toBe(true)
})
expect(mocks.call).toHaveBeenCalledOnce()
// A host-pending entry stays parked until the journal answers it.
await advanceProbeClock(999)
expect(mocks.call).toHaveBeenCalledOnce()
await advanceProbeClock(1)
expect(mocks.call).toHaveBeenCalledOnce()
await advanceProbeClock(1500)
expect(mocks.call).toHaveBeenCalledOnce()
}, 20000)
it('keeps probing past the old five-attempt budget', async () => {
mocks.mode = 'outbox'
mocks.call.mockRejectedValue(new Error('socket closed'))
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-budget"
sessionId="session-budget"
target={{ kind: 'local' }}
agent="codex"
/>
)
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
expect(send?.('first', [])).toBe(true)
// Backoff is 1+2+4+8+16 = 31s for five probes, which was the old hard budget.
// Step past it; a seventh call proves the probe re-arms instead of giving up.
for (let step = 0; step < 12; step += 1) {
await act(async () => {
await vi.advanceTimersByTimeAsync(8_000)
})
}
expect(mocks.call.mock.calls.length).toBeGreaterThanOrEqual(7)
} finally {
vi.useRealTimers()
}
}, 30000)
// A cause seen while the chat was open is worded in full; one read back after the chat is
// reopened may have cleared, until a Retry it still stops brings it back.
it('words a refusal seen here in full, and after a reopen only once its Retry is refused', 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', () => {
@@ -173,7 +177,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({
@@ -193,21 +199,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
})
})
@@ -215,7 +224,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(
@@ -234,7 +246,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()
@@ -473,8 +485,231 @@ 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 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
})
})
// The rejection makes it the host's: not sent, in the host's words, whether its outbox copy or
// its loaded row draws it.
it('says a requeued message whose row was rejected was not sent, never sending', () => {
const requeued = entry('q', { state: 'queued', lastAttemptAt: 1 })
const rejected = row('q', 'rejected', { reason: 'provider said no', resolvedAt: 8 })
const notSent = { [agentJournalSubmissionKey('q')]: 'provider said no' }
expect(texts([requeued], [rejected])).toEqual(notSent)
expect(texts([stuck, requeued], [rejected])).toEqual({
[agentJournalSubmissionKey('stuck')]: 'Message delivery is unconfirmed.',
...notSent
})
const loadedRow: AgentJournalRenderItem = {
itemId: agentJournalSubmissionKey('q'),
revision: 1,
sequence: 1,
observedAt: 8,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'q' }] }
}
const loaded = structuredAgentSessionDeliveryNotices(
[requeued],
'Claude',
vi.fn(),
[rejected],
[],
NOT_FAILED_HERE,
[],
new Set(),
[loadedRow]
)
expect([...loaded]).toEqual([[agentJournalSubmissionKey('q'), { text: 'provider said no' }]])
})
})
// 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,14 @@
// 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 does not, so a message resent over one still reads
// as sending; a rejected row makes it the host's, shown as not sent.
//
// 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 drawn from the host's history, worded from the
// journal's own fact, with no Retry: sending it again is a new message. A rejection that is a
@@ -34,6 +37,8 @@ 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,
@@ -44,7 +49,10 @@ 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 }
const NO_COMMANDS: ReadonlySet<string> = new Set()
const NO_ITEMS: readonly AgentJournalRenderItem[] = []
/** The facts the chat's loaded start-failure rows state. */
export function structuredAgentSessionStartFailureFacts(
@@ -149,7 +157,8 @@ 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[],
@@ -158,22 +167,28 @@ export function structuredAgentSessionDeliveryNotices(
/** The queue's live cards, which the transcript leaves a rejected message to. */
queuedMessageIds: readonly string[] = [],
/** The loaded commands, from `structuredAgentSessionCommandItemIds`: they report their own. */
commandItemIds: ReadonlySet<string> = NO_COMMANDS
commandItemIds: ReadonlySet<string> = NO_COMMANDS,
/** The loaded rows: a rejected message's outbox copy leaves once its row is here. */
journalItems: readonly AgentJournalRenderItem[] = NO_ITEMS
): 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, journalItems)
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. One the host recorded is
// the host's: sending it again is a new message, so it has no Retry.
@@ -185,6 +200,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
)
}
}
// After the outbox's: in the host's words, whether its row or the outbox's copy draws it.
@@ -11,6 +11,7 @@ import { useStructuredAgentSessionStartFailureFacts } from './use-structured-age
import type { NativeChatDeliveryNotice } from './NativeChatMessageRow'
const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = []
const NO_ITEMS: readonly AgentJournalRenderItem[] = []
/** The structured chat's delivery notices, by the message id each row renders under. */
export function useStructuredAgentSessionDeliveryNotices(args: {
@@ -31,14 +32,17 @@ export function useStructuredAgentSessionDeliveryNotices(args: {
const retry = useCallback((clientMessageId: string) => {
retryRef.current(clientMessageId)
}, [])
// Only a message shown as not sent reads the journal's rows (a withdrawn one draws nothing), so
// in a chat without one a new batch of them re-renders no row.
// Only a message shown as not sent (a withdrawn one draws nothing) or still in the outbox reads
// the journal's rows, so in a chat with neither a new batch of them re-renders no row.
const hasRejected =
outbox.some((entry) => entry.state === 'rejected') ||
submissions.some(
(submission) => submission.dispatchState === 'rejected' && !dispatchWasWithdrawn(submission)
)
const rejectionRows = hasRejected ? submissions : NO_SUBMISSIONS
const journalRows = hasRejected || outbox.length > 0 ? submissions : NO_SUBMISSIONS
// Only an outbox copy of a rejected message reads the loaded rows, so a streaming turn rebuilds
// no notice otherwise.
const loadedItems = hasRejected && outbox.length > 0 ? args.journalItems : NO_ITEMS
const startFailures = useStructuredAgentSessionStartFailureFacts(args.journalItems, hasRejected)
const commandItemIds = useCommandItemIds(args.journalItems, hasRejected)
const notices = useMemo(
@@ -47,21 +51,23 @@ export function useStructuredAgentSessionDeliveryNotices(args: {
outbox,
agentName,
retry,
rejectionRows,
journalRows,
startFailures,
failedHere,
queuedMessageIds,
commandItemIds
commandItemIds,
loadedItems
),
[
outbox,
agentName,
retry,
rejectionRows,
journalRows,
startFailures,
failedHere,
queuedMessageIds,
commandItemIds
commandItemIds,
loadedItems
]
)
// A submission batch rebuilds the map; one that says the same keeps the old, so no row re-renders.
@@ -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
@@ -18067,6 +18067,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
@@ -15192,6 +15192,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
@@ -17880,6 +17880,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
@@ -17817,6 +17817,7 @@
"dismissDeliveryNotice": "閉じる",
"messageNotSent": "メッセージは送信されませんでした",
"deliveryUnconfirmed": "配信を確認できません — 再試行する前にチャットを確認してください",
"messageSending": "送信中…",
"queuedMessages": {
"alreadySent": "このメッセージはすでに送信されています。",
"editAlreadySent": "すでに送信されています — テキストは入力欄に残っています。",
+1
View File
@@ -17817,6 +17817,7 @@
"dismissDeliveryNotice": "닫기",
"messageNotSent": "메시지가 전송되지 않음",
"deliveryUnconfirmed": "전달 확인 안 됨 — 다시 시도하기 전에 채팅을 확인하세요",
"messageSending": "전송 중…",
"queuedMessages": {
"alreadySent": "이 메시지는 이미 전송되었습니다.",
"editAlreadySent": "이미 전송되었습니다 — 텍스트는 입력창에 남아 있습니다.",
+1
View File
@@ -17834,6 +17834,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)
)
}