diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index faf844b5137..32185cabb56 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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", diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 9ce0a000634..6ca6604e6e8 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -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' @@ -50,6 +51,43 @@ export function DeliveryNoticesMock({ ) } +export function useProbeClock(): void { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) +} + +export async function advanceProbeClock(milliseconds: number): Promise { + 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 diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx new file mode 100644 index 00000000000..4a89074d23f --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx @@ -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( + + ) + + 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( + + ) + + 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 + const probe = mocks.call.mock.calls[1]?.[2] as Record + 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( + + ) + + 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 => ( + + ) + 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 } + ) => ( + + ) + 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( + + ) + + 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( + + ) + + 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( + + ) + + 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) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 6a119bd64ba..48de7c5b161 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -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 @@ -183,21 +183,12 @@ vi.mock('./NativeChatQuestionCard', () => ({ })) import { NativeChatStructuredSession } from './NativeChatStructuredSession' +import { seededEntry, seedOutbox } from './NativeChatStructuredSession.test-harness' import { appendStructuredAgentSessionOutboxMessage, getStructuredAgentSessionOutbox } from './structured-agent-session-outbox-storage' -function useProbeClock(): void { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) -} - -async function advanceProbeClock(milliseconds: number): Promise { - await act(async () => { - await vi.advanceTimersByTimeAsync(milliseconds) - }) -} - describe('NativeChatStructuredSession delivery', () => { afterEach(() => { cleanup() @@ -219,33 +210,6 @@ 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) - ) - } - // 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' @@ -582,326 +546,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( - - ) - - 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( - - ) - - 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 - const probe = mocks.call.mock.calls[1]?.[2] as Record - 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( - - ) - - 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 => ( - - ) - 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 } - ) => ( - - ) - 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( - - ) - - 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( - - ) - - 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( - - ) - - 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 () => {