mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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<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
|
||||
|
||||
+362
@@ -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)
|
||||
})
|
||||
+2
-358
@@ -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<void> {
|
||||
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(
|
||||
<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)
|
||||
|
||||
// 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 () => {
|
||||
|
||||
Reference in New Issue
Block a user