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