mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
fix(native-chat): every chat send ends one of three ways, decided by the host's answer
A send now ends only by what the host said: it holds a record (the host's row shows the message from then on), it proved there is no record (the text goes back into the conversation's draft and the reason is said once), or nothing answered yet (the same id goes again, quietly, as "Sending…"). One shared settlement decides it for the open chat and a launch prompt alike. Removed: the Retry control and its id rotation, the rejected and held-for-Retry outbox states, the Stop latch, the per-window failure memory, the parked "unknown" entry, the launch prompt's own send path, and the error-text regex (errors are read by code). A Stop stamps a send already on its way with the Stop's own id; the Stop's answer settles it. Drafts of a structured chat are keyed by the conversation and survive their tab closing. Entries older builds left waiting for a Retry are settled once the journal loads, never sent again.
This commit is contained in:
@@ -6,7 +6,7 @@ import { readWholeAgentSessionFailureFact } from '../../../src/shared/agent-sess
|
||||
import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types'
|
||||
import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice'
|
||||
import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection'
|
||||
import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/structured-agent-session-send-disposition'
|
||||
import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/structured-agent-session-send-failure-words'
|
||||
import {
|
||||
QUEUED_MESSAGE_PAUSED_SEND_FAILED,
|
||||
type AgentSessionQueuedMessage,
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types'
|
||||
import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire'
|
||||
import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry'
|
||||
import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-send-disposition'
|
||||
import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-send-failure-words'
|
||||
import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc'
|
||||
|
||||
@@ -55,6 +55,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
{
|
||||
terminalTabId,
|
||||
paneKey,
|
||||
draftScopeKey = paneKey,
|
||||
targetPtyId,
|
||||
agent,
|
||||
canSend = true,
|
||||
@@ -75,10 +76,10 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
// Scope key shared with image attachments so an unsent draft + its attached
|
||||
// images survive both TUI/GUI toggles and PTY replacement on reconnect.
|
||||
// Why: local, SSH, and runtime reconnects can replace or temporarily clear
|
||||
// the PTY id. Pane identity is the stable ownership key for unsent input.
|
||||
// the PTY id. Pane (or conversation) identity is the stable owner of unsent input.
|
||||
const imeEnterGesture = useImeEnterGestureOwnership()
|
||||
const { draft, setDraft, flushDraftAppends } = useNativeChatDraft(
|
||||
paneKey,
|
||||
draftScopeKey,
|
||||
imeEnterGesture.isComposing
|
||||
)
|
||||
const [caret, setCaret] = useState(draft.length)
|
||||
@@ -155,7 +156,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
}, [])
|
||||
|
||||
const attachments = useNativeChatComposerAttachments({
|
||||
attachmentScopeKey: paneKey,
|
||||
attachmentScopeKey: draftScopeKey,
|
||||
allowWithoutTarget: Boolean(structuredTransport),
|
||||
caret,
|
||||
disabled,
|
||||
@@ -249,10 +250,10 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
|
||||
const sendStructured = useNativeChatStructuredComposerSend({
|
||||
agent,
|
||||
draftScopeKey: paneKey,
|
||||
draftScopeKey,
|
||||
imageAttachments,
|
||||
structuredTransport,
|
||||
clearImageAttachments,
|
||||
isComposing: imeEnterGesture.isComposing,
|
||||
clearSkillOrigin,
|
||||
setHistory,
|
||||
setDraft,
|
||||
@@ -285,7 +286,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
})
|
||||
const { send, goalMode } = useNativeChatComposerSubmit({
|
||||
structuredTransport,
|
||||
draftScopeKey: paneKey,
|
||||
draftScopeKey,
|
||||
draft,
|
||||
caret,
|
||||
imageAttachments,
|
||||
@@ -360,7 +361,8 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
|
||||
return (
|
||||
<NativeChatComposerField
|
||||
composerScopeKey={paneKey}
|
||||
dropScopeKey={paneKey}
|
||||
draftScopeKey={draftScopeKey}
|
||||
textareaRef={textareaRef}
|
||||
draft={draft}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -23,7 +23,9 @@ import { translate } from '@/i18n/i18n'
|
||||
export type NativeChatComposerFieldProps = {
|
||||
/** Pane identity published to the drop pipeline so a native file drop lands
|
||||
* only in the composer it was dropped on. */
|
||||
composerScopeKey: string
|
||||
dropScopeKey: string
|
||||
/** Owner of the draft the editor's document is saved with. */
|
||||
draftScopeKey: string
|
||||
textareaRef: RefObject<NativeChatComposerInput | null>
|
||||
draft: string
|
||||
disabled: boolean
|
||||
@@ -101,7 +103,8 @@ function imeComposedSegment(base: string, settled: string): string {
|
||||
}
|
||||
|
||||
export function NativeChatComposerField({
|
||||
composerScopeKey,
|
||||
dropScopeKey,
|
||||
draftScopeKey,
|
||||
textareaRef,
|
||||
draft,
|
||||
disabled,
|
||||
@@ -198,7 +201,7 @@ export function NativeChatComposerField({
|
||||
) : null}
|
||||
<div
|
||||
data-native-file-drop-target={NATIVE_FILE_DROP_TARGET.composer}
|
||||
data-composer-scope-key={composerScopeKey}
|
||||
data-composer-scope-key={dropScopeKey}
|
||||
className={cn(
|
||||
// Why: always-on hairline (token-level border, not focus ring) —
|
||||
// no focus/click border flash. The box is a container, not a
|
||||
@@ -227,8 +230,8 @@ export function NativeChatComposerField({
|
||||
</div>
|
||||
) : null}
|
||||
<NativeChatPromptEditor
|
||||
key={composerScopeKey}
|
||||
scopeKey={composerScopeKey}
|
||||
key={draftScopeKey}
|
||||
scopeKey={draftScopeKey}
|
||||
inputRef={textareaRef}
|
||||
initialValue={draft}
|
||||
disabled={disabled}
|
||||
|
||||
-216
@@ -1,216 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
// A message shown as not sent stays in the outbox and draws below newer turns, after their live
|
||||
// activity. It is in no turn: the newer turn's "Working for" clock and "Worked for" bar stay with
|
||||
// that turn, never under it.
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSubmission,
|
||||
AgentJournalTurnScope
|
||||
} from '../../../../shared/agent-session-journal-types'
|
||||
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
|
||||
import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status'
|
||||
import {
|
||||
createStructuredAgentSessionOutboxEntry,
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from '../../../../shared/structured-agent-session-outbox'
|
||||
import { NativeChatMessageList } from './NativeChatMessageList'
|
||||
import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport'
|
||||
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
|
||||
|
||||
let restoreViewport = (): void => {}
|
||||
beforeAll(() => {
|
||||
restoreViewport = installNativeChatMessageListTestViewport()
|
||||
})
|
||||
afterAll(() => restoreViewport())
|
||||
afterEach(cleanup)
|
||||
|
||||
type Phase = 'in flight' | 'running' | 'done'
|
||||
|
||||
const SEED = agentJournalSubmissionKey('seed')
|
||||
const NEW = agentJournalSubmissionKey('new')
|
||||
|
||||
function said(role: 'user' | 'assistant', text: string): AgentJournalItemBody {
|
||||
return { kind: 'message', role, blocks: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
function journal(phase: Phase, scoped: boolean): AgentJournalRenderItem[] {
|
||||
const thread: AgentJournalTurnScope = { kind: 'thread' }
|
||||
const row = (
|
||||
itemId: string,
|
||||
body: AgentJournalItemBody,
|
||||
turnScope: AgentJournalTurnScope
|
||||
): AgentJournalRenderItem => ({
|
||||
itemId,
|
||||
revision: 0,
|
||||
sequence: rows.length + 1,
|
||||
observedAt: 1000 + rows.length,
|
||||
body,
|
||||
...(scoped ? { turnScope } : {})
|
||||
})
|
||||
const rows: AgentJournalRenderItem[] = []
|
||||
rows.push(row(SEED, said('user', 'SEED PROMPT'), thread))
|
||||
rows.push(
|
||||
row(
|
||||
'turn-seed',
|
||||
{ kind: 'turn', turnId: 'turn-seed', state: 'completed', userItemId: SEED },
|
||||
thread
|
||||
)
|
||||
)
|
||||
rows.push(
|
||||
row('seed-answer', said('assistant', 'SEED OK'), { kind: 'turn', turnItemId: 'turn-seed' })
|
||||
)
|
||||
rows.push(row(NEW, said('user', 'NEW PROMPT'), thread))
|
||||
if (phase !== 'in flight') {
|
||||
rows.push(
|
||||
row(
|
||||
'turn-new',
|
||||
{
|
||||
kind: 'turn',
|
||||
turnId: 'turn-new',
|
||||
state: phase === 'done' ? 'completed' : 'running',
|
||||
userItemId: NEW
|
||||
},
|
||||
thread
|
||||
)
|
||||
)
|
||||
}
|
||||
if (phase === 'done') {
|
||||
rows.push(
|
||||
row('new-answer', said('assistant', 'NEW OK'), { kind: 'turn', turnItemId: 'turn-new' })
|
||||
)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function submission(
|
||||
id: string,
|
||||
dispatchState: AgentJournalSubmission['dispatchState']
|
||||
): AgentJournalSubmission {
|
||||
return {
|
||||
clientMessageId: id,
|
||||
fence: 1,
|
||||
payloadFingerprint: id,
|
||||
dispatchState,
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
submittedAt: 1,
|
||||
resolvedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
function unsentEntry(kind: 'held' | 'rejected'): StructuredAgentSessionOutboxEntry {
|
||||
const entry = createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: 'held',
|
||||
sessionId: 'session-1',
|
||||
text: 'HELD PROMPT',
|
||||
attachments: [],
|
||||
queuedAt: 500
|
||||
})
|
||||
return kind === 'held'
|
||||
? {
|
||||
...entry,
|
||||
lastAttemptAt: 500,
|
||||
lastFailure: { kind: 'refused', code: 'agent_session_journal_unreadable' }
|
||||
}
|
||||
: { ...entry, state: 'rejected', lastFailure: { kind: 'rejected', reason: null } }
|
||||
}
|
||||
|
||||
function list(phase: Phase, scoped: boolean, outbox: StructuredAgentSessionOutboxEntry[]) {
|
||||
const items = journal(phase, scoped)
|
||||
const submissions = [
|
||||
submission('seed', 'accepted'),
|
||||
submission('new', phase === 'done' ? 'accepted' : 'pending')
|
||||
]
|
||||
const settledTurns: NativeChatSettledTurns = new Map([
|
||||
[SEED, { startedAt: 1, workedSeconds: 3 }],
|
||||
...(phase === 'done' ? [[NEW, { startedAt: 2, workedSeconds: 5 }] as const] : [])
|
||||
])
|
||||
return (
|
||||
<NativeChatMessageList
|
||||
session={{
|
||||
messages: projectStructuredAgentSessionMessages(items, outbox, submissions),
|
||||
status: phase === 'done' ? 'ready' : 'working',
|
||||
sessionId: 'session-1',
|
||||
agent: 'claude',
|
||||
hasMore: false,
|
||||
loadingEarlier: false,
|
||||
olderHistoryGeneration: 0,
|
||||
loadEarlier: vi.fn(),
|
||||
readPhase: 'ready'
|
||||
}}
|
||||
journalItems={items}
|
||||
journalSubmissions={submissions}
|
||||
isWorking={phase !== 'done'}
|
||||
workingStartedAt={phase === 'done' ? null : Date.now() - 1500}
|
||||
settledTurns={settledTurns}
|
||||
expandSignal={false}
|
||||
fontScale={1}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** The drawn sequence of the prompts, bars and live activity line, top to bottom. */
|
||||
function drawn(container: HTMLElement): string[] {
|
||||
const out: string[] = []
|
||||
for (const element of container.querySelectorAll('*')) {
|
||||
if (element.hasAttribute('data-native-chat-turn-activity')) {
|
||||
out.push('ACTIVITY')
|
||||
} else if (element.hasAttribute('data-native-chat-turn-status')) {
|
||||
out.push(
|
||||
element.getAttribute('data-native-chat-turn-status') === 'active' ? 'WORKING' : 'WORKED'
|
||||
)
|
||||
} else if (element.childElementCount === 0 && (element.textContent ?? '').endsWith('PROMPT')) {
|
||||
out.push(element.textContent ?? '')
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
describe.each([
|
||||
['held for its Retry', 'held'],
|
||||
['rejected', 'rejected']
|
||||
] as const)('a message %s, below a newer turn', (_label, kind) => {
|
||||
it.each([
|
||||
['states each row turn', true],
|
||||
['states no turn scope', false]
|
||||
])(
|
||||
'keeps the newer turn bar with that turn while it runs and once done (host %s)',
|
||||
(_host, scoped) => {
|
||||
const outbox = [unsentEntry(kind)]
|
||||
const { container, rerender } = render(list('in flight', scoped, outbox))
|
||||
expect(drawn(container)).toEqual([
|
||||
'SEED PROMPT',
|
||||
'WORKED',
|
||||
'NEW PROMPT',
|
||||
'WORKING',
|
||||
'ACTIVITY',
|
||||
'HELD PROMPT'
|
||||
])
|
||||
rerender(list('running', scoped, outbox))
|
||||
expect(drawn(container)).toEqual([
|
||||
'SEED PROMPT',
|
||||
'WORKED',
|
||||
'NEW PROMPT',
|
||||
'WORKING',
|
||||
'ACTIVITY',
|
||||
'HELD PROMPT'
|
||||
])
|
||||
rerender(list('done', scoped, outbox))
|
||||
expect(drawn(container)).toEqual([
|
||||
'SEED PROMPT',
|
||||
'WORKED',
|
||||
'NEW PROMPT',
|
||||
'WORKED',
|
||||
'HELD PROMPT'
|
||||
])
|
||||
expect(container.textContent).toContain('Worked for 5s')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -181,15 +181,13 @@ describe('what a user message says about its delivery', () => {
|
||||
)
|
||||
}
|
||||
|
||||
it('says why under the message, with a Retry that sends this one', () => {
|
||||
const onRetry = vi.fn()
|
||||
renderUser({ text: "The agent couldn't restart. Your message was not sent.", onRetry })
|
||||
it('says why under the message, with no Retry: sending again is a new message', () => {
|
||||
renderUser({ text: "The agent couldn't restart. Your message was not sent." })
|
||||
|
||||
expect(
|
||||
screen.getByText("The agent couldn't restart. Your message was not sent.")
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(onRetry).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
|
||||
})
|
||||
|
||||
it('offers no Retry where the surface cannot send it again', () => {
|
||||
@@ -199,9 +197,9 @@ describe('what a user message says about its delivery', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
|
||||
})
|
||||
|
||||
// A plain "not sent" is a label, not an error; a doubt to check keeps the error color.
|
||||
// A plain "not sent" is a label, not an error; anything else keeps the error color.
|
||||
it('reads muted when it says only that the message was not sent', () => {
|
||||
renderUser({ text: 'Your message was not sent.', notSent: true })
|
||||
renderUser({ text: 'Your message was not sent.', muted: true })
|
||||
const notSent = screen.getByText('Your message was not sent.').parentElement
|
||||
expect(notSent).toHaveClass('text-muted-foreground')
|
||||
expect(notSent).not.toHaveClass('text-destructive/80')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useRef } from 'react'
|
||||
import { Goal, RotateCcw } from 'lucide-react'
|
||||
import { Goal } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import CommentMarkdown, {
|
||||
type CommentMarkdownLinkClickHandler
|
||||
@@ -29,16 +29,14 @@ import type {
|
||||
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
|
||||
|
||||
/** 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. */
|
||||
* place of its time, or how it ended when it did not go through. */
|
||||
export type NativeChatDeliveryNotice =
|
||||
| { sending: true; text?: never; notSent?: never; onRetry?: never; onDismiss?: never }
|
||||
| { sending: true; text?: never; muted?: never; onDismiss?: never }
|
||||
| {
|
||||
sending?: never
|
||||
text: string
|
||||
/** Says only that the message did not go out, so it reads muted, not as an error. */
|
||||
notSent?: true
|
||||
onRetry?: () => void
|
||||
/** Says only that the message did not go out, or is not confirmed: muted, not an error. */
|
||||
muted?: true
|
||||
onDismiss?: () => void
|
||||
}
|
||||
|
||||
@@ -227,7 +225,7 @@ export const MessageRow = memo(function MessageRow({
|
||||
<div
|
||||
className={cn(
|
||||
'flex max-w-[85%] items-center gap-2 text-[11px]',
|
||||
deliveryNotice.notSent ? 'text-muted-foreground' : 'text-destructive/80'
|
||||
deliveryNotice.muted ? 'text-muted-foreground' : 'text-destructive/80'
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 break-words">{deliveryNotice.text}</span>
|
||||
@@ -236,15 +234,6 @@ export const MessageRow = memo(function MessageRow({
|
||||
{translate('components.native-chat.dismissDeliveryNotice', 'Dismiss')}
|
||||
</Button>
|
||||
) : null}
|
||||
{deliveryNotice.onRetry ? (
|
||||
<Button type="button" variant="ghost" size="xs" onClick={deliveryNotice.onRetry}>
|
||||
<RotateCcw className="size-3" />
|
||||
{translate(
|
||||
'auto.components.native.chat.NativeChatStructuredSession.a5e7f14068',
|
||||
'Retry'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition'
|
||||
import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-failure-words'
|
||||
import { classifyDispatchRejection } from '../../../../shared/structured-agent-session-dispatch-rejection'
|
||||
import { readWholeAgentSessionFailureFact } from '../../../../shared/agent-session-failure'
|
||||
import { agentSessionWriteNoticeText } from './agent-session-write-notice-text'
|
||||
|
||||
+23
-20
@@ -61,17 +61,6 @@ function startFailureRow(fact: AgentSessionFailureFact): AgentJournalRenderItem
|
||||
|
||||
function rejected(clientMessageId: string, reason: string, rejection: AgentSessionFailureFact) {
|
||||
return {
|
||||
outbox: {
|
||||
clientMessageId,
|
||||
sessionId: SESSION_ID,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] },
|
||||
previewUris: [],
|
||||
state: 'rejected',
|
||||
queuedAt: 1,
|
||||
lastAttemptAt: null,
|
||||
retryAfterUnknownSubmittedAt: null,
|
||||
lastFailure: { kind: 'rejected', reason, rejection: { kind: rejection.kind } }
|
||||
},
|
||||
submission: {
|
||||
clientMessageId,
|
||||
fence: 1,
|
||||
@@ -86,13 +75,26 @@ function rejected(clientMessageId: string, reason: string, rejection: AgentSessi
|
||||
}
|
||||
}
|
||||
|
||||
function userRow(clientMessageId: string, sequence: number): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId: agentJournalSubmissionKey(clientMessageId),
|
||||
revision: 1,
|
||||
sequence,
|
||||
observedAt: sequence,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }
|
||||
}
|
||||
}
|
||||
|
||||
// The sender's own message: its entry left the outbox when the host answered, so the host's
|
||||
// record alone shows it, as in every other window.
|
||||
function renderPane(messages: ReturnType<typeof rejected>[]): void {
|
||||
mocks.mode = 'outbox'
|
||||
mocks.submissions = messages.map((message) => message.submission)
|
||||
localStorage.setItem(
|
||||
`orca:desktopStructuredAgentSessionOutbox:v1:${encodeURIComponent(SESSION_ID)}`,
|
||||
JSON.stringify(messages.map((message) => message.outbox))
|
||||
)
|
||||
mocks.journalItems = [
|
||||
...mocks.journalItems,
|
||||
...messages.map((message, index) => userRow(message.submission.clientMessageId, index + 2))
|
||||
]
|
||||
const submissions = messages.map((message) => message.submission)
|
||||
mocks.submissions = submissions
|
||||
mocks.messages = projectStructuredAgentSessionMessages(mocks.journalItems, [], submissions)
|
||||
render(
|
||||
<NativeChatStructuredSession
|
||||
isVisible
|
||||
@@ -118,7 +120,7 @@ async function notice(clientMessageId: string): Promise<HTMLElement> {
|
||||
}
|
||||
|
||||
// The start's own row says why, so its rejected messages say only that they were not sent.
|
||||
it("says only 'not sent', with its Retry, on each message the failed start's row explains", async () => {
|
||||
it("says only 'not sent', with no Retry, on each message the failed start's row explains", async () => {
|
||||
mocks.journalItems = [startFailureRow(START_FAILED)]
|
||||
|
||||
renderPane([
|
||||
@@ -129,7 +131,7 @@ it("says only 'not sent', with its Retry, on each message the failed start's row
|
||||
for (const id of ['first', 'second']) {
|
||||
const row = await notice(id)
|
||||
expect(within(row).getByText('Your message was not sent.')).toBeTruthy()
|
||||
expect(within(row).getByRole('button', { name: 'Retry' })).toBeTruthy()
|
||||
expect(within(row).queryByRole('button', { name: 'Retry' })).toBeNull()
|
||||
}
|
||||
expect(screen.queryByText(/stopped before it finished starting/)).toBeNull()
|
||||
})
|
||||
@@ -162,7 +164,8 @@ it("keeps the start failure's own words when its row is not loaded", async () =>
|
||||
renderPane([rejected('first', START_FAILED_REASON, START_FAILED)])
|
||||
|
||||
expect(
|
||||
within(await notice('first')).getByText('Claude stopped before it finished starting.')
|
||||
// No Retry here, so the words carry the step: sending again is the user's new message.
|
||||
within(await notice('first')).getByText(START_FAILED_REASON)
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
|
||||
+2
-9
@@ -27,8 +27,8 @@ function absent<T>(): T | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
/** Stands in for the transcript: renders only each message's delivery notice, or the row's quiet
|
||||
* "Sending…" while nothing has confirmed it. */
|
||||
export function DeliveryNoticesMock({
|
||||
notices
|
||||
}: {
|
||||
@@ -39,11 +39,6 @@ export function DeliveryNoticesMock({
|
||||
{[...(notices ?? [])].map(([id, notice]) => (
|
||||
<div key={id} data-message-id={id}>
|
||||
<span>{notice.sending ? 'Sending…' : notice.text}</span>
|
||||
{notice.onRetry ? (
|
||||
<button type="button" onClick={notice.onRetry}>
|
||||
Retry
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -175,10 +170,8 @@ export function createStructuredSessionMocks() {
|
||||
loadOlder: mocks.loadOlder,
|
||||
prompts: mocks.promptItems,
|
||||
outbox: outbox.outbox,
|
||||
failedHere: outbox.failedHere,
|
||||
submissions: mocks.submissions,
|
||||
send: outbox.send,
|
||||
retry: outbox.retry,
|
||||
isWorking: mocks.isWorking,
|
||||
backgroundTasks: {
|
||||
show: mocks.showBackgroundTasks || mocks.monitoringBackgroundTasks,
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard
|
||||
|
||||
import { NativeChatStructuredSession } from './NativeChatStructuredSession'
|
||||
import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection'
|
||||
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
|
||||
|
||||
describe('NativeChatStructuredSession', () => {
|
||||
afterEach(() => {
|
||||
@@ -35,7 +36,7 @@ describe('NativeChatStructuredSession', () => {
|
||||
resetStructuredSessionMocks()
|
||||
})
|
||||
|
||||
it('gives what a Stop withdrew back to the composer this pane shows', () => {
|
||||
it("gives the composer this pane shows the conversation's own draft", () => {
|
||||
render(
|
||||
<NativeChatStructuredSession
|
||||
isVisible
|
||||
@@ -47,8 +48,12 @@ describe('NativeChatStructuredSession', () => {
|
||||
/>
|
||||
)
|
||||
const paneKey = structuredAgentSessionPaneKey('structured-tab-1', 'session-1')
|
||||
expect(mocks.composerProps).toMatchObject({ paneKey })
|
||||
expect(mocks.controllerProps).toMatchObject({ composerScopeKey: paneKey })
|
||||
// The pane routes drops and pickers; the draft belongs to the conversation, whatever pane shows it.
|
||||
expect(mocks.composerProps).toMatchObject({
|
||||
paneKey,
|
||||
draftScopeKey: structuredAgentSessionDraftScopeKey('session-1')
|
||||
})
|
||||
expect(mocks.controllerProps).not.toHaveProperty('composerScopeKey')
|
||||
})
|
||||
|
||||
it('routes the launch draft and app-menu paste to the structured composer', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { structuredAgentSessionPaneKey } from '../../../../shared/structured-age
|
||||
import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
import { NativeChatApprovalCard } from './NativeChatApprovalCard'
|
||||
import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer'
|
||||
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
|
||||
import { NativeChatEmptyState } from './NativeChatEmptyState'
|
||||
import { NativeChatLoadingCue } from './NativeChatLoadingCue'
|
||||
import { NativeChatMessageList } from './NativeChatMessageList'
|
||||
@@ -50,7 +51,6 @@ export function NativeChatStructuredSession(
|
||||
const queueFollowUps = useAppStore((store) => store.settings?.nativeChatQueueFollowUps !== false)
|
||||
const controller = useStructuredAgentSession({
|
||||
...props,
|
||||
composerScopeKey: paneKey,
|
||||
queueFollowUps,
|
||||
providerStarting: startupPhase === 'starting',
|
||||
transportEnabled: provisionalLaunch.transportEnabled,
|
||||
@@ -117,9 +117,10 @@ export function NativeChatStructuredSession(
|
||||
journalItems: controller.journalItems,
|
||||
submissions: controller.submissions,
|
||||
outbox: controller.outbox,
|
||||
failedHere: controller.failedHere,
|
||||
retry: controller.retry,
|
||||
agentLabel
|
||||
agentLabel,
|
||||
// A lost outcome may still resolve while the agent works or starts.
|
||||
agentActive:
|
||||
controller.isWorking || startupPhase === 'starting' || !provisionalLaunch.transportEnabled
|
||||
})
|
||||
const viewState = selectNativeChatViewState(session, { readRetries: true })
|
||||
// Nothing reads an unread history, so its pane stays blank beside the Retry line.
|
||||
@@ -361,6 +362,7 @@ export function NativeChatStructuredSession(
|
||||
ref={composerRef}
|
||||
terminalTabId={props.tabId}
|
||||
paneKey={paneKey}
|
||||
draftScopeKey={structuredAgentSessionDraftScopeKey(props.sessionId)}
|
||||
targetPtyId={null}
|
||||
agent={props.agent}
|
||||
canSend={!prompt}
|
||||
|
||||
+25
-289
@@ -1,9 +1,9 @@
|
||||
// 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.
|
||||
// The delivery notice and the outbox queue behind it: a send with no answer reads as sending and
|
||||
// goes again under its id, and a send the host holds a row for leaves, so nothing waits on it.
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import React, { forwardRef, useImperativeHandle, useRef } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
|
||||
@@ -79,10 +79,8 @@ vi.mock('./use-structured-agent-session', async () => {
|
||||
loadOlder: vi.fn(),
|
||||
prompts: mocks.promptItems,
|
||||
outbox: outbox.outbox,
|
||||
failedHere: outbox.failedHere,
|
||||
submissions: mocks.submissions,
|
||||
send: outbox.send,
|
||||
retry: outbox.retry,
|
||||
isWorking: false,
|
||||
isMonitoringBackgroundTasks: mocks.monitoringBackgroundTasks,
|
||||
supportsBackgroundTaskStop: mocks.supportsBackgroundTaskStop,
|
||||
@@ -183,10 +181,7 @@ vi.mock('./NativeChatQuestionCard', () => ({
|
||||
}))
|
||||
|
||||
import { NativeChatStructuredSession } from './NativeChatStructuredSession'
|
||||
import {
|
||||
appendStructuredAgentSessionOutboxMessage,
|
||||
getStructuredAgentSessionOutbox
|
||||
} from './structured-agent-session-outbox-storage'
|
||||
import { getStructuredAgentSessionOutbox } from './structured-agent-session-outbox-storage'
|
||||
|
||||
describe('NativeChatStructuredSession delivery', () => {
|
||||
afterEach(() => {
|
||||
@@ -221,10 +216,7 @@ describe('NativeChatStructuredSession delivery', () => {
|
||||
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
|
||||
lastAttemptAt: null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,7 +273,6 @@ describe('NativeChatStructuredSession delivery', () => {
|
||||
...seededEntry(sessionId, 'op-sent', 'first', 'queued'),
|
||||
state: 'dispatching',
|
||||
lastAttemptAt: 1,
|
||||
retryAfterUnknownSubmittedAt: null,
|
||||
...patch
|
||||
}
|
||||
])
|
||||
@@ -326,12 +317,14 @@ describe('NativeChatStructuredSession delivery', () => {
|
||||
expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()
|
||||
}, 10000)
|
||||
|
||||
// The host has a record of it, so the row shows it from there: the entry leaves, and nothing
|
||||
// waits on a Retry that no longer exists.
|
||||
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',
|
||||
'lets a send reopened mid-send leave once the journal holds %s, with no Retry and no resend',
|
||||
async (label, patch) => {
|
||||
mocks.mode = 'outbox'
|
||||
const sessionId = `session-reopened-${label.replace(/\W+/g, '-')}`
|
||||
@@ -352,9 +345,9 @@ describe('NativeChatStructuredSession delivery', () => {
|
||||
|
||||
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 waitFor(() => expect(getStructuredAgentSessionOutbox(sessionId)).toEqual([]))
|
||||
expect(screen.queryByRole('button', { name: /Retry/ })).toBeNull()
|
||||
expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
})
|
||||
@@ -363,214 +356,22 @@ describe('NativeChatStructuredSession delivery', () => {
|
||||
10000
|
||||
)
|
||||
|
||||
it('says a send a Stop outlived is unconfirmed when reopened, as nothing resends it', async () => {
|
||||
it('never sends a send an older build said a Stop outlived, and offers no Retry', 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()
|
||||
// Its settlement waits for the journal; until then it reads as not confirmed yet.
|
||||
expect(screen.getByText('Sending…')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: /Retry/ })).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'
|
||||
mocks.submissions = []
|
||||
mocks.call.mockResolvedValue({
|
||||
ok: true,
|
||||
value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } }
|
||||
})
|
||||
seedOutbox('session-retry-head', [
|
||||
seededEntry('session-retry-head', 'op-head', 'first', 'unconfirmed'),
|
||||
seededEntry('session-retry-head', 'op-later', 'second', 'unconfirmed')
|
||||
])
|
||||
|
||||
render(
|
||||
<NativeChatStructuredSession
|
||||
isVisible
|
||||
isFocusedGroup
|
||||
tabId="structured-tab-retry-head"
|
||||
sessionId="session-retry-head"
|
||||
target={{ kind: 'local' }}
|
||||
agent="codex"
|
||||
/>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy())
|
||||
fireEvent.click(screen.getByRole('button', { name: /Retry/ }))
|
||||
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
|
||||
const request = mocks.call.mock.calls[0]?.[2] as { envelope: { clientOperationId: string } }
|
||||
expect(request.envelope.clientOperationId).toBe('op-head')
|
||||
})
|
||||
|
||||
it('offers a rejected message no Retry while the queue is stopped, and gives it back once it moves', async () => {
|
||||
mocks.mode = 'outbox'
|
||||
mocks.submissions = []
|
||||
mocks.call.mockResolvedValue({
|
||||
ok: true,
|
||||
value: { submission: { clientMessageId: 'op-head', dispatchState: 'accepted' } }
|
||||
})
|
||||
seedOutbox('session-held-rejected', [
|
||||
seededEntry('session-held-rejected', 'op-head', 'first', 'unconfirmed'),
|
||||
{
|
||||
...seededEntry('session-held-rejected', 'op-rejected', 'second', 'queued'),
|
||||
state: 'rejected',
|
||||
lastFailure: { kind: 'rejected', reason: 'Claude messages support at most 20 images' }
|
||||
}
|
||||
])
|
||||
|
||||
render(
|
||||
<NativeChatStructuredSession
|
||||
isVisible
|
||||
isFocusedGroup
|
||||
tabId="structured-tab-held-rejected"
|
||||
sessionId="session-held-rejected"
|
||||
target={{ kind: 'local' }}
|
||||
agent="codex"
|
||||
/>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy())
|
||||
expect(screen.getByText('Claude messages support at most 20 images')).toBeTruthy()
|
||||
// One Retry, the stopped message's: it sends only that one.
|
||||
fireEvent.click(screen.getByRole('button', { name: /Retry/ }))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
|
||||
expect(mocks.call.mock.calls[0]?.[2]).toMatchObject({
|
||||
envelope: { clientOperationId: 'op-head' }
|
||||
})
|
||||
|
||||
// The queue moved, so the rejected message offers its own Retry again.
|
||||
await waitFor(() => expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull())
|
||||
expect(screen.getByText('Claude messages support at most 20 images')).toBeTruthy()
|
||||
expect(screen.getAllByRole('button', { name: /Retry/ })).toHaveLength(1)
|
||||
expect(mocks.call).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("words a failed start on each message by the chat's agent, leaving the resend to its Retry", async () => {
|
||||
mocks.mode = 'outbox'
|
||||
mocks.submissions = []
|
||||
const startFailed = (clientMessageId: string, text: string) => ({
|
||||
...seededEntry('session-start-failed', clientMessageId, text, 'queued'),
|
||||
state: 'rejected' as const,
|
||||
lastFailure: {
|
||||
kind: 'rejected' as const,
|
||||
reason: 'Codex stopped before it finished starting. Send your message to try again.',
|
||||
rejection: { kind: 'providerStartFailed' as const }
|
||||
}
|
||||
})
|
||||
seedOutbox('session-start-failed', [
|
||||
startFailed('op-first', 'first'),
|
||||
startFailed('op-second', 'second')
|
||||
])
|
||||
|
||||
render(
|
||||
<NativeChatStructuredSession
|
||||
isVisible
|
||||
isFocusedGroup
|
||||
tabId="structured-tab-start-failed"
|
||||
sessionId="session-start-failed"
|
||||
target={{ kind: 'local' }}
|
||||
agent="codex"
|
||||
/>
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText('Codex stopped before it finished starting.')).toHaveLength(2)
|
||||
)
|
||||
expect(screen.getAllByRole('button', { name: /Retry/ })).toHaveLength(2)
|
||||
expect(screen.queryByText(/Send your message to try again/)).toBeNull()
|
||||
})
|
||||
|
||||
it("words a rejected message from its loaded journal row, not the message's own copy", async () => {
|
||||
mocks.mode = 'outbox'
|
||||
const reason = "Claude couldn't start. Send your message to try again."
|
||||
mocks.submissions = [
|
||||
{
|
||||
clientMessageId: 'op-recorded',
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'rejected',
|
||||
providerItemId: null,
|
||||
reason,
|
||||
rejection: {
|
||||
kind: 'startFailed',
|
||||
refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } }
|
||||
},
|
||||
submittedAt: 1,
|
||||
resolvedAt: 1
|
||||
}
|
||||
]
|
||||
seedOutbox('session-recorded', [
|
||||
{
|
||||
...seededEntry('session-recorded', 'op-recorded', 'first', 'queued'),
|
||||
state: 'rejected',
|
||||
lastFailure: { kind: 'rejected', reason, rejection: { kind: 'startFailed' } }
|
||||
}
|
||||
])
|
||||
|
||||
render(
|
||||
<NativeChatStructuredSession
|
||||
isVisible
|
||||
isFocusedGroup
|
||||
tabId="structured-tab-recorded"
|
||||
sessionId="session-recorded"
|
||||
target={{ kind: 'local' }}
|
||||
agent="codex"
|
||||
/>
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("Codex couldn't start. Start a new chat to continue.")).toBeTruthy()
|
||||
)
|
||||
expect(screen.queryByText(reason)).toBeNull()
|
||||
})
|
||||
|
||||
it('names the stuck message behind an admitted head, and its Retry sends that one', async () => {
|
||||
mocks.mode = 'outbox'
|
||||
mocks.submissions = []
|
||||
// The head is admitted -- written and awaiting the provider -- so the entry behind it is the
|
||||
// one holding the queue, and Retry acts on it instead of waiting for the head to clear.
|
||||
mocks.call.mockResolvedValue({
|
||||
ok: true,
|
||||
value: { submission: { clientMessageId: 'op-head', dispatchState: 'pending' } }
|
||||
})
|
||||
seedOutbox('session-quiet-head', [
|
||||
seededEntry('session-quiet-head', 'op-head', 'first', 'queued'),
|
||||
seededEntry('session-quiet-head', 'op-later', 'second', 'unconfirmed')
|
||||
])
|
||||
|
||||
render(
|
||||
<NativeChatStructuredSession
|
||||
isVisible
|
||||
isFocusedGroup
|
||||
tabId="structured-tab-quiet-head"
|
||||
sessionId="session-quiet-head"
|
||||
target={{ kind: 'local' }}
|
||||
agent="codex"
|
||||
/>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
|
||||
expect(mocks.call.mock.calls[0]?.[2]).toMatchObject({
|
||||
envelope: { clientOperationId: 'op-head' }
|
||||
})
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy())
|
||||
fireEvent.click(screen.getByRole('button', { name: /Retry/ }))
|
||||
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
expect(mocks.call.mock.calls[1]?.[2]).toMatchObject({
|
||||
envelope: { clientOperationId: 'op-later' }
|
||||
})
|
||||
})
|
||||
|
||||
it('resends a transport-unconfirmed head so later messages are not wedged', async () => {
|
||||
mocks.mode = 'outbox'
|
||||
mocks.submissions = []
|
||||
@@ -636,7 +437,7 @@ describe('NativeChatStructuredSession delivery', () => {
|
||||
)
|
||||
}, 20000)
|
||||
|
||||
it('parks a host-confirmed unknown instead of probing it', async () => {
|
||||
it('lets a head the host answers in doubt leave, so the message behind it goes out (no parked head)', async () => {
|
||||
mocks.mode = 'outbox'
|
||||
mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -661,7 +462,7 @@ describe('NativeChatStructuredSession delivery', () => {
|
||||
await waitFor(() => 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.
|
||||
// The host now reports the row in doubt: it has a record, so the row shows it from here.
|
||||
mocks.submissions = [
|
||||
{
|
||||
clientMessageId: sent.envelope.clientOperationId,
|
||||
@@ -674,17 +475,17 @@ describe('NativeChatStructuredSession delivery', () => {
|
||||
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.
|
||||
// A later send, with no user action: it goes out instead of waiting behind the head.
|
||||
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))
|
||||
})
|
||||
expect(mocks.call).toHaveBeenCalledOnce()
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2), { timeout: 5000 })
|
||||
const texts = mocks.call.mock.calls.map(
|
||||
(call) => (call[2] as { body: { blocks: { text: string }[] } }).body.blocks[0]?.text
|
||||
)
|
||||
expect(texts).toEqual(['first', 'second'])
|
||||
expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Retry/ })).toBeNull()
|
||||
}, 20000)
|
||||
|
||||
it('still probes while streaming batches rebuild the submissions array', async () => {
|
||||
@@ -777,31 +578,6 @@ describe('NativeChatStructuredSession delivery', () => {
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2), { timeout: 1500 })
|
||||
}, 10000)
|
||||
|
||||
it('never auto-probes an entry the user already force-retried', async () => {
|
||||
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"
|
||||
/>
|
||||
)
|
||||
|
||||
// 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))
|
||||
})
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
}, 20000)
|
||||
|
||||
it('does not hot-loop when the host answers pending', async () => {
|
||||
mocks.mode = 'outbox'
|
||||
mocks.call.mockResolvedValue({
|
||||
@@ -867,44 +643,4 @@ describe('NativeChatStructuredSession delivery', () => {
|
||||
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 () => {
|
||||
const newerOrca =
|
||||
'Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them.'
|
||||
mocks.mode = 'outbox'
|
||||
mocks.call.mockResolvedValue({
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'agent_session_journal_unreadable',
|
||||
message: 'newer',
|
||||
details: { reason: 'journalWrittenByNewerOrca' }
|
||||
}
|
||||
})
|
||||
const view = (): React.JSX.Element => (
|
||||
<NativeChatStructuredSession
|
||||
isVisible
|
||||
isFocusedGroup
|
||||
tabId="tab-held"
|
||||
sessionId="session-held"
|
||||
target={{ kind: 'local' }}
|
||||
agent="codex"
|
||||
/>
|
||||
)
|
||||
const first = render(view())
|
||||
// The composer's own enqueue.
|
||||
act(() => {
|
||||
expect(appendStructuredAgentSessionOutboxMessage('session-held', 'hello')).not.toBeNull()
|
||||
})
|
||||
await waitFor(() => expect(screen.getByText(newerOrca)).toBeTruthy())
|
||||
first.unmount()
|
||||
|
||||
render(view())
|
||||
await waitFor(() => expect(screen.getByText('Your message was not sent.')).toBeTruthy())
|
||||
expect(mocks.call).toHaveBeenCalledOnce()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Retry/ }))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
await waitFor(() => expect(screen.getByText(newerOrca)).toBeTruthy())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../../shared/agent-session-write-notice-copy'
|
||||
import { agentSessionWriteNoticeParts } from '../../../../shared/agent-session-refusal-notice'
|
||||
import { agentSessionRefusalFailure } from '../../../../shared/agent-session-write-failure'
|
||||
import { structuredAgentSessionRejectionParts } from '../../../../shared/structured-agent-session-send-disposition'
|
||||
import { structuredAgentSessionRejectionParts } from '../../../../shared/structured-agent-session-send-failure-words'
|
||||
import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text'
|
||||
import { agentSessionWriteNoticeText } from './agent-session-write-notice-text'
|
||||
|
||||
|
||||
@@ -46,7 +46,8 @@ function TestField({
|
||||
const imeEnterGesture = useImeEnterGestureOwnership()
|
||||
return (
|
||||
<NativeChatComposerField
|
||||
composerScopeKey="pane-test"
|
||||
dropScopeKey="pane-test"
|
||||
draftScopeKey="pane-test"
|
||||
textareaRef={createRef<HTMLTextAreaElement>()}
|
||||
draft={draft}
|
||||
disabled={false}
|
||||
|
||||
@@ -35,7 +35,8 @@ function TestField(props: TestFieldProps): React.JSX.Element {
|
||||
|
||||
function fieldProps(overrides: Partial<TestFieldProps> = {}): TestFieldProps {
|
||||
return {
|
||||
composerScopeKey: 'pane-test',
|
||||
dropScopeKey: 'pane-test',
|
||||
draftScopeKey: 'pane-test',
|
||||
textareaRef: createRef<HTMLTextAreaElement>(),
|
||||
draft: '',
|
||||
disabled: false,
|
||||
@@ -79,8 +80,12 @@ function textarea(): HTMLTextAreaElement {
|
||||
describe('native chat composer drop-scope marker', () => {
|
||||
// The drop pipeline stops walking at the drop-target marker, so a scope key on
|
||||
// any other element would never reach the payload.
|
||||
it('publishes the scope key on the same element as the drop-target marker', () => {
|
||||
const view = render(<TestField {...fieldProps({ composerScopeKey: 'tab-7:pane-9' })} />)
|
||||
it('publishes the pane key, not the draft key, on the same element as the drop-target marker', () => {
|
||||
const view = render(
|
||||
<TestField
|
||||
{...fieldProps({ dropScopeKey: 'tab-7:pane-9', draftScopeKey: 'agent-session:session-9' })}
|
||||
/>
|
||||
)
|
||||
const marker = view.container.querySelector('[data-native-file-drop-target="composer"]')
|
||||
expect(marker).not.toBeNull()
|
||||
expect(marker?.getAttribute('data-composer-scope-key')).toBe('tab-7:pane-9')
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nativeChatComposerDraftLeftAfterSend } from './native-chat-composer-draft-comparison'
|
||||
|
||||
const SHOT = { id: 'a-1', path: '/repo/shot.png' }
|
||||
const DIAGRAM = { id: 'a-2', path: '/repo/diagram.png' }
|
||||
const SENT = { text: 'abcd', images: [SHOT] }
|
||||
|
||||
describe('nativeChatComposerDraftLeftAfterSend', () => {
|
||||
it('leaves nothing of a draft still as sent', () => {
|
||||
expect(nativeChatComposerDraftLeftAfterSend(SENT, SENT)).toEqual({ text: '', images: [] })
|
||||
})
|
||||
|
||||
it('keeps text typed after, and images attached, since the send', () => {
|
||||
expect(
|
||||
nativeChatComposerDraftLeftAfterSend({ text: 'abcd more', images: [SHOT, DIAGRAM] }, SENT)
|
||||
).toEqual({ text: ' more', images: [DIAGRAM] })
|
||||
})
|
||||
|
||||
it('keeps text composed inside the sent text, away from its end', () => {
|
||||
expect(nativeChatComposerDraftLeftAfterSend({ text: 'ab가cd', images: [] }, SENT)).toEqual({
|
||||
text: '가',
|
||||
images: []
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a draft replaced or edited inside the sent text alone', () => {
|
||||
expect(nativeChatComposerDraftLeftAfterSend({ text: 'new', images: [SHOT] }, SENT)).toBeNull()
|
||||
expect(nativeChatComposerDraftLeftAfterSend({ text: 'abXd', images: [] }, SENT)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the whole text when only images were sent', () => {
|
||||
expect(
|
||||
nativeChatComposerDraftLeftAfterSend(
|
||||
{ text: 'typed', images: [SHOT] },
|
||||
{ text: '', images: [SHOT] }
|
||||
)
|
||||
).toEqual({ text: 'typed', images: [] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
// How two copies of a composer draft compare, such as the draft now against the one that was sent.
|
||||
|
||||
import type {
|
||||
NativeChatComposerDraft,
|
||||
NativeChatComposerDraftImage
|
||||
} from './native-chat-composer-draft-storage'
|
||||
|
||||
export function sameNativeChatComposerDraftImages(
|
||||
left: readonly NativeChatComposerDraftImage[],
|
||||
right: readonly NativeChatComposerDraftImage[]
|
||||
): boolean {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every(
|
||||
(image, index) =>
|
||||
image.id === right[index].id &&
|
||||
image.path === right[index].path &&
|
||||
image.connectionId === right[index].connectionId &&
|
||||
image.unavailableName === right[index].unavailableName
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a draft keeps once its send is accepted: anything added since, meaning text inserted into
|
||||
* the sent text (typing or a composition begun before the send settled) and images attached
|
||||
* meanwhile. Null when the draft was otherwise changed since (replaced, or edited inside the sent
|
||||
* text), which is then left as it is.
|
||||
*/
|
||||
export function nativeChatComposerDraftLeftAfterSend(
|
||||
current: NativeChatComposerDraft,
|
||||
sent: NativeChatComposerDraft
|
||||
): NativeChatComposerDraft | null {
|
||||
let prefix = 0
|
||||
while (prefix < sent.text.length && sent.text[prefix] === current.text[prefix]) {
|
||||
prefix += 1
|
||||
}
|
||||
const rest = sent.text.slice(prefix)
|
||||
if (current.text.length < sent.text.length || !current.text.endsWith(rest)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
text: current.text.slice(prefix, current.text.length - rest.length),
|
||||
images: current.images.filter((image) => !sent.images.some((held) => held.id === image.id))
|
||||
}
|
||||
}
|
||||
+78
-2
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, createElement, useState } from 'react'
|
||||
import { act, createElement, Fragment, useState } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt'
|
||||
import type * as DraftHook from './use-native-chat-draft'
|
||||
@@ -149,7 +149,7 @@ function composer(
|
||||
draftScopeKey: scopeKey,
|
||||
imageAttachments: attachments.imageAttachments,
|
||||
structuredTransport: transport,
|
||||
clearImageAttachments: attachments.clearImageAttachments,
|
||||
isComposing: () => false,
|
||||
clearSkillOrigin: () => {},
|
||||
setHistory: () => {},
|
||||
setDraft,
|
||||
@@ -299,6 +299,82 @@ describe('native-chat composer draft lifecycle', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('clears an accepted send’s text and images from the shown composer', async () => {
|
||||
const hooks = await loadHooks()
|
||||
const held = heldTransport()
|
||||
const seen: { api?: ComposerApi } = {}
|
||||
await mount(
|
||||
createElement(
|
||||
composer(hooks, (next) => (seen.api = next), held.transport),
|
||||
{ scopeKey: 'agent-session:s1' }
|
||||
)
|
||||
)
|
||||
await act(async () => {
|
||||
seen.api?.setDraft('sent text')
|
||||
seen.api?.attachments.attachResolvedPaths(['/repo/a.png'])
|
||||
})
|
||||
await act(async () => seen.api?.send('sent text'))
|
||||
|
||||
await held.settle()
|
||||
|
||||
expect(seen.api?.draft).toBe('')
|
||||
expect(seen.api?.attachments.imageAttachments).toEqual([])
|
||||
expect(storedDraft('agent-session:s1')).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a draft replaced before the send was accepted as it is', async () => {
|
||||
const hooks = await loadHooks()
|
||||
const held = heldTransport()
|
||||
const seen: { api?: ComposerApi } = {}
|
||||
await mount(
|
||||
createElement(
|
||||
composer(hooks, (next) => (seen.api = next), held.transport),
|
||||
{ scopeKey: 'agent-session:s1' }
|
||||
)
|
||||
)
|
||||
await act(async () => seen.api?.setDraft('sent text'))
|
||||
await act(async () => seen.api?.send('sent text'))
|
||||
await act(async () => seen.api?.setDraft('typed while it was on its way'))
|
||||
|
||||
await held.settle()
|
||||
|
||||
expect(seen.api?.draft).toBe('typed while it was on its way')
|
||||
})
|
||||
|
||||
it('shares one conversation’s draft between its composers, and keeps what the other added', async () => {
|
||||
const hooks = await loadHooks()
|
||||
const held = heldTransport()
|
||||
const sender: { api?: ComposerApi } = {}
|
||||
const other: { api?: ComposerApi } = {}
|
||||
await mount(
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(
|
||||
composer(hooks, (next) => (sender.api = next), held.transport),
|
||||
{
|
||||
scopeKey: 'agent-session:s1'
|
||||
}
|
||||
),
|
||||
createElement(
|
||||
composer(hooks, (next) => (other.api = next)),
|
||||
{
|
||||
scopeKey: 'agent-session:s1'
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
await act(async () => sender.api?.setDraft('sent text'))
|
||||
expect(other.api?.draft).toBe('sent text')
|
||||
await act(async () => sender.api?.send('sent text'))
|
||||
await act(async () => other.api?.setDraft((previous) => `${previous} and more`))
|
||||
|
||||
await held.settle()
|
||||
|
||||
expect(sender.api?.draft).toBe(' and more')
|
||||
expect(other.api?.draft).toBe(' and more')
|
||||
})
|
||||
|
||||
it('keeps a shown composer’s pasted image when many other drafts are written', async () => {
|
||||
const hooks = await loadHooks()
|
||||
const drafts = await import('./native-chat-draft-cache')
|
||||
|
||||
@@ -484,6 +484,50 @@ describe('native-chat composer draft store', () => {
|
||||
expect(reloaded.drafts.readNativeChatDraftCache('tab-10:pane')).toBe('other tab')
|
||||
})
|
||||
|
||||
it('keeps a conversation’s draft when a tab is closed, and drops it when deleted by its key', async () => {
|
||||
const conversation = modules.store.structuredAgentSessionDraftScopeKey('session-1')
|
||||
expect(conversation).toBe('agent-session:session-1')
|
||||
modules.drafts.writeNativeChatDraftCache(conversation, 'unsent')
|
||||
modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'pane draft')
|
||||
modules.store.flushNativeChatComposerDrafts()
|
||||
// The prefix before the conversation key's ':' must never read as a tab id.
|
||||
modules.store.deleteNativeChatComposerDraftsForTab('agent-session')
|
||||
modules.store.deleteNativeChatComposerDraftsForTab('tab-1')
|
||||
expect(modules.drafts.readNativeChatDraftCache(conversation)).toBe('unsent')
|
||||
|
||||
const listener = vi.fn()
|
||||
modules.store.subscribeToNativeChatComposerDraft(conversation, listener)
|
||||
modules.drafts.writeNativeChatDraftCache(conversation, 'unsent, still deferred')
|
||||
modules.store.deleteNativeChatComposerDraft(conversation)
|
||||
expect(listener).toHaveBeenCalled()
|
||||
expect(modules.drafts.readNativeChatDraftCache(conversation)).toBe('')
|
||||
modules.store.flushNativeChatComposerDrafts()
|
||||
const reloaded = await reload()
|
||||
expect(reloaded.drafts.readNativeChatDraftCache(conversation)).toBe('')
|
||||
})
|
||||
|
||||
it('gives text back once, even when the hand-back repeats, with no composer shown', async () => {
|
||||
const conversation = modules.store.structuredAgentSessionDraftScopeKey('session-1')
|
||||
modules.drafts.returnNativeChatDraftText(conversation, 'withdrawn message')
|
||||
modules.drafts.returnNativeChatDraftText(conversation, ' withdrawn message\n')
|
||||
expect(modules.drafts.readNativeChatDraftCache(conversation)).toBe('withdrawn message')
|
||||
// Saved at once: the copy it came from goes right after.
|
||||
const reloaded = await reload()
|
||||
expect(reloaded.drafts.readNativeChatDraftCache(conversation)).toBe('withdrawn message')
|
||||
})
|
||||
|
||||
it('adds an image given back again only once', () => {
|
||||
const image = { id: 'withdrawn-m1-0', path: '/repo/shot.png' }
|
||||
modules.attachments.appendNativeChatAttachmentCache('agent-session:s1', [image])
|
||||
modules.attachments.appendNativeChatAttachmentCache('agent-session:s1', [
|
||||
image,
|
||||
{ id: 'withdrawn-m1-1', path: '/repo/other.png' }
|
||||
])
|
||||
expect(
|
||||
modules.attachments.readNativeChatAttachmentCache('agent-session:s1').map(({ id }) => id)
|
||||
).toEqual(['withdrawn-m1-0', 'withdrawn-m1-1'])
|
||||
})
|
||||
|
||||
it('keeps the drafts of a tab whose id extends the closed one', async () => {
|
||||
// A second chat for one session gets `<tab id>:history-1`, so a prefix match would reach it.
|
||||
const closed = 'structured-agent-session-claude_1'
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import type { JSONContent } from '@tiptap/react'
|
||||
import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache'
|
||||
import { sameNativeChatComposerDraftImages } from './native-chat-composer-draft-comparison'
|
||||
import { basename } from '@/lib/path'
|
||||
import { isNativeChatKeptPastePath, isNativeChatPastedImagePath } from './native-chat-image-paste'
|
||||
import {
|
||||
@@ -107,22 +108,6 @@ function isEmptyDraft(draft: NativeChatComposerDraft): boolean {
|
||||
return draft.text === '' && draft.images.length === 0
|
||||
}
|
||||
|
||||
function sameImages(
|
||||
left: readonly NativeChatComposerDraftImage[],
|
||||
right: readonly NativeChatComposerDraftImage[]
|
||||
): boolean {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every(
|
||||
(image, index) =>
|
||||
image.id === right[index].id &&
|
||||
image.path === right[index].path &&
|
||||
image.connectionId === right[index].connectionId &&
|
||||
image.unavailableName === right[index].unavailableName
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Monotonic within a run, so drafts changed in the same millisecond still age in order. */
|
||||
function nextSavedAt(): number {
|
||||
lastSavedAt = Math.max(Date.now(), lastSavedAt + 1)
|
||||
@@ -274,7 +259,7 @@ export function updateNativeChatComposerDraft(
|
||||
text === current.text &&
|
||||
document === current.document &&
|
||||
unsavedText === current.unsavedText &&
|
||||
sameImages(images, current.images)
|
||||
sameNativeChatComposerDraftImages(images, current.images)
|
||||
) {
|
||||
if (persist === 'immediate' && dirtyScopes.has(scopeKey)) {
|
||||
flushNativeChatComposerDrafts()
|
||||
@@ -318,36 +303,55 @@ export function clearNativeChatComposerDraftIfUnchanged(
|
||||
sent: NativeChatComposerDraft
|
||||
): boolean {
|
||||
const current = readNativeChatComposerDraft(scopeKey)
|
||||
if (current.text !== sent.text || !sameImages(current.images, sent.images)) {
|
||||
if (
|
||||
current.text !== sent.text ||
|
||||
!sameNativeChatComposerDraftImages(current.images, sent.images)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
updateNativeChatComposerDraft(scopeKey, { text: '', images: [] }, 'immediate')
|
||||
return true
|
||||
}
|
||||
|
||||
const STRUCTURED_AGENT_SESSION_DRAFT_SCOPE_PREFIX = 'agent-session:'
|
||||
|
||||
/** A structured chat's draft belongs to its conversation, so it outlives the tab showing it. */
|
||||
export function structuredAgentSessionDraftScopeKey(sessionId: string): string {
|
||||
return `${STRUCTURED_AGENT_SESSION_DRAFT_SCOPE_PREFIX}${sessionId}`
|
||||
}
|
||||
|
||||
function deleteDraftsWhere(owned: (scopeKey: string) => boolean): void {
|
||||
const storage = nativeChatComposerDraftStorage()
|
||||
if (storage) {
|
||||
removeStoredNativeChatComposerDraftsWhere(storage, owned)
|
||||
}
|
||||
for (const scopeKey of new Set([...records.keys(), ...scopeListeners.keys()])) {
|
||||
if (owned(scopeKey)) {
|
||||
records.delete(scopeKey)
|
||||
dirtyScopes.delete(scopeKey)
|
||||
notifyScope(scopeKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops one scope's draft, for an owner that is gone for good. */
|
||||
export function deleteNativeChatComposerDraft(scopeKey: string): void {
|
||||
deleteDraftsWhere((key) => key === scopeKey)
|
||||
}
|
||||
|
||||
/** A pane key is `<tabId>:<leaf>`; the leaf is a UUID, while a tab id may hold ':' itself. */
|
||||
function scopeTabId(scopeKey: string): string {
|
||||
return scopeKey.slice(0, scopeKey.lastIndexOf(':'))
|
||||
}
|
||||
|
||||
/** Drops the drafts of every pane in a tab the user closed; its pane keys never come back. */
|
||||
/** Drops the drafts of every pane in a tab the user closed; its pane keys never come back. A
|
||||
* conversation's draft is never a tab's. */
|
||||
export function deleteNativeChatComposerDraftsForTab(tabId: string): void {
|
||||
const inTab = (scopeKey: string): boolean => scopeTabId(scopeKey) === tabId
|
||||
for (const scopeKey of records.keys()) {
|
||||
if (inTab(scopeKey)) {
|
||||
records.delete(scopeKey)
|
||||
dirtyScopes.delete(scopeKey)
|
||||
}
|
||||
}
|
||||
const storage = nativeChatComposerDraftStorage()
|
||||
if (storage) {
|
||||
removeStoredNativeChatComposerDraftsWhere(storage, inTab)
|
||||
}
|
||||
for (const scopeKey of scopeListeners.keys()) {
|
||||
if (inTab(scopeKey)) {
|
||||
notifyScope(scopeKey)
|
||||
}
|
||||
}
|
||||
deleteDraftsWhere(
|
||||
(scopeKey) =>
|
||||
!scopeKey.startsWith(STRUCTURED_AGENT_SESSION_DRAFT_SCOPE_PREFIX) &&
|
||||
scopeTabId(scopeKey) === tabId
|
||||
)
|
||||
}
|
||||
|
||||
export function clearNativeChatComposerDraftsForTests(): void {
|
||||
|
||||
@@ -53,11 +53,20 @@ import {
|
||||
} from '../../../../preload/preload-runtime-support'
|
||||
|
||||
// Uses the production drop listener, subscriber fan-out, attachment hook, and scope cache.
|
||||
function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolean }) {
|
||||
function ComposerProbe({
|
||||
pane,
|
||||
draft = pane,
|
||||
hidden = false
|
||||
}: {
|
||||
pane: string
|
||||
/** The draft's owner; a structured chat's composers share their conversation's. */
|
||||
draft?: string
|
||||
hidden?: boolean
|
||||
}) {
|
||||
const textareaRef = useRef<NativeChatComposerInput>(null)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const attachments = useNativeChatComposerAttachments({
|
||||
attachmentScopeKey: pane,
|
||||
attachmentScopeKey: draft,
|
||||
allowWithoutTarget: true,
|
||||
caret: 0,
|
||||
disabled: false,
|
||||
@@ -79,7 +88,7 @@ function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolea
|
||||
<div data-pane={pane} style={{ display: hidden ? 'none' : 'block' }}>
|
||||
<div data-native-file-drop-target="composer" data-composer-scope-key={pane}>
|
||||
<NativeChatPromptEditor
|
||||
scopeKey={pane}
|
||||
scopeKey={draft}
|
||||
inputRef={textareaRef}
|
||||
initialValue="untouched draft"
|
||||
disabled={false}
|
||||
@@ -242,6 +251,36 @@ describe('native chat composer drop scoping', () => {
|
||||
expect(returned.container.querySelector('output')?.textContent).toBe('[]')
|
||||
})
|
||||
|
||||
it('attaches a drop once, from the pane it landed on, when two panes share a conversation’s draft', async () => {
|
||||
const view = render(
|
||||
<>
|
||||
<ComposerProbe pane="chat-a" draft="agent-session:s1" />
|
||||
<ComposerProbe pane="chat-b" draft="agent-session:s1" />
|
||||
</>
|
||||
)
|
||||
|
||||
await dropTwoImages(view.container.querySelector('[data-pane="chat-a"] .ProseMirror')!)
|
||||
await settleAttachments()
|
||||
|
||||
expect(electron.send).toHaveBeenCalledExactlyOnceWith('terminal:file-dropped-from-preload', {
|
||||
target: 'composer',
|
||||
scopeKey: 'chat-a',
|
||||
paths: ['/repro/first.png', '/repro/second.png']
|
||||
})
|
||||
expect(readNativeChatAttachmentCache('agent-session:s1').map(({ path }) => path)).toEqual([
|
||||
'/repro/first.png',
|
||||
'/repro/second.png'
|
||||
])
|
||||
// Both panes show the one shared draft.
|
||||
const shown = [...view.container.querySelectorAll('output:not([data-notice])')].map(
|
||||
(output) => output.textContent
|
||||
)
|
||||
expect(shown).toEqual([
|
||||
JSON.stringify(['/repro/first.png', '/repro/second.png']),
|
||||
JSON.stringify(['/repro/first.png', '/repro/second.png'])
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a drop into an unscoped composer out of every chat pane', async () => {
|
||||
const view = render(
|
||||
<>
|
||||
|
||||
@@ -50,6 +50,9 @@ export type NativeChatComposerProps = {
|
||||
terminalTabId: string
|
||||
/** Stable split-leaf identity; unlike a PTY id, this survives reconnects. */
|
||||
paneKey: string
|
||||
/** Owner of the unsent draft; defaults to `paneKey`. A structured chat's is its conversation,
|
||||
* shared by every composer showing it. */
|
||||
draftScopeKey?: string
|
||||
/** Specific split-pane PTY this chat view owns. */
|
||||
targetPtyId: string | null
|
||||
agent: AgentType
|
||||
|
||||
+2
-1
@@ -165,7 +165,8 @@ function ComposerProbe({
|
||||
on a bare wrapper so the drop logic is exercised on its own. */}
|
||||
<div {...workspaceFileDropHandlers}>
|
||||
<NativeChatComposerField
|
||||
composerScopeKey={`pane:${workspaceId}`}
|
||||
dropScopeKey={`pane:${workspaceId}`}
|
||||
draftScopeKey={`pane:${workspaceId}`}
|
||||
textareaRef={inputRef}
|
||||
draft={draft}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
appendNativeChatDraftCache,
|
||||
appendNativeChatDraftText,
|
||||
clearNativeChatDraftCacheForTests,
|
||||
readNativeChatDraftCache,
|
||||
returnNativeChatDraftText,
|
||||
subscribeToNativeChatDraftAppend,
|
||||
writeNativeChatDraftCache
|
||||
} from './native-chat-draft-cache'
|
||||
import { NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX } from './native-chat-composer-scope-cache'
|
||||
@@ -44,4 +48,49 @@ describe('native-chat draft cache', () => {
|
||||
expect(readNativeChatDraftCache('keep')).toBe('hot')
|
||||
expect(readNativeChatDraftCache(`scope-${total - 1}`)).toBe(`draft-${total - 1}`)
|
||||
})
|
||||
|
||||
it('appends after a blank line, treating a whitespace-only draft as empty', () => {
|
||||
expect(appendNativeChatDraftText('typed \n', 'given back')).toBe('typed\n\ngiven back')
|
||||
expect(appendNativeChatDraftText('', 'given back')).toBe('given back')
|
||||
expect(appendNativeChatDraftText(' \n\t', 'given back')).toBe('given back')
|
||||
writeNativeChatDraftCache('pane', ' ')
|
||||
appendNativeChatDraftCache('pane', 'given back')
|
||||
expect(readNativeChatDraftCache('pane')).toBe('given back')
|
||||
})
|
||||
})
|
||||
|
||||
describe('returnNativeChatDraftText', () => {
|
||||
it('appends returned text after a blank line, with no composer mounted', () => {
|
||||
writeNativeChatDraftCache('agent-session:s1', 'typed meanwhile')
|
||||
returnNativeChatDraftText('agent-session:s1', 'unsent message')
|
||||
expect(readNativeChatDraftCache('agent-session:s1')).toBe('typed meanwhile\n\nunsent message')
|
||||
})
|
||||
|
||||
it('is idempotent: text the draft already holds is not added again', () => {
|
||||
returnNativeChatDraftText('agent-session:s1', 'unsent message')
|
||||
returnNativeChatDraftText('agent-session:s1', 'unsent message')
|
||||
writeNativeChatDraftCache('agent-session:s1', 'before\n\nunsent message\n\nafter')
|
||||
returnNativeChatDraftText('agent-session:s1', '\nunsent message ')
|
||||
expect(readNativeChatDraftCache('agent-session:s1')).toBe('before\n\nunsent message\n\nafter')
|
||||
})
|
||||
|
||||
it('puts no blank lines before text returned to a whitespace-only draft', () => {
|
||||
writeNativeChatDraftCache('agent-session:s1', ' \n ')
|
||||
returnNativeChatDraftText('agent-session:s1', 'unsent message')
|
||||
expect(readNativeChatDraftCache('agent-session:s1')).toBe('unsent message')
|
||||
})
|
||||
|
||||
it('ignores blank text', () => {
|
||||
returnNativeChatDraftText('agent-session:s1', ' \n')
|
||||
expect(readNativeChatDraftCache('agent-session:s1')).toBe('')
|
||||
})
|
||||
|
||||
it('tells a composer mid-composition only when it actually appends', () => {
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = subscribeToNativeChatDraftAppend('agent-session:s1', listener)
|
||||
returnNativeChatDraftText('agent-session:s1', 'unsent message')
|
||||
returnNativeChatDraftText('agent-session:s1', 'unsent message')
|
||||
unsubscribe()
|
||||
expect(listener).toHaveBeenCalledExactlyOnceWith('unsent message', '')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,19 +32,15 @@ export function writeNativeChatDraftCache(
|
||||
)
|
||||
}
|
||||
|
||||
/** A whitespace-only draft counts as empty, so the text never lands after blank lines. */
|
||||
export function appendNativeChatDraftText(draft: string, text: string): string {
|
||||
return draft === '' ? text : `${draft.trimEnd()}\n\n${text}`
|
||||
return draft.trim() === '' ? text : `${draft.trimEnd()}\n\n${text}`
|
||||
}
|
||||
|
||||
// Why: a composer mid-IME-composition keeps showing what it had, so it is told what was appended.
|
||||
const appendListeners = new Map<string, Set<(text: string, previous: string) => void>>()
|
||||
|
||||
/** Puts text back after whatever is typed, and tells a mounted composer to show it. */
|
||||
export function appendNativeChatDraftCache(scopeKey: string, text: string): void {
|
||||
if (text === '') {
|
||||
return
|
||||
}
|
||||
const previous = readNativeChatDraftCache(scopeKey)
|
||||
function appendToDraft(scopeKey: string, text: string, previous: string): void {
|
||||
// Saved now: the copy it came from (an outbox entry, a queued card) goes right after this.
|
||||
updateNativeChatComposerDraft(
|
||||
scopeKey,
|
||||
@@ -54,6 +50,31 @@ export function appendNativeChatDraftCache(scopeKey: string, text: string): void
|
||||
appendListeners.get(scopeKey)?.forEach((listener) => listener(text, previous))
|
||||
}
|
||||
|
||||
/** Puts text back after whatever is typed, and tells a mounted composer to show it. */
|
||||
export function appendNativeChatDraftCache(scopeKey: string, text: string): void {
|
||||
if (text === '') {
|
||||
return
|
||||
}
|
||||
appendToDraft(scopeKey, text, readNativeChatDraftCache(scopeKey))
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands text Orca could not deliver back to the person, with or without a composer showing it.
|
||||
* Why skipped when already there: a hand-back can repeat (a crash before its copy was removed,
|
||||
* two windows settling one message), and the person must see it once.
|
||||
*/
|
||||
export function returnNativeChatDraftText(scopeKey: string, text: string): void {
|
||||
const returned = text.trim()
|
||||
if (returned === '') {
|
||||
return
|
||||
}
|
||||
const previous = readNativeChatDraftCache(scopeKey)
|
||||
if (previous.includes(returned)) {
|
||||
return
|
||||
}
|
||||
appendToDraft(scopeKey, returned, previous)
|
||||
}
|
||||
|
||||
export function subscribeToNativeChatDraftAppend(
|
||||
scopeKey: string,
|
||||
listener: (text: string, previous: string) => void
|
||||
|
||||
+119
-751
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure'
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
} from '../../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
createStructuredAgentSessionOutboxEntry,
|
||||
structuredAgentSessionRejectedFailure,
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from '../../../../shared/structured-agent-session-outbox'
|
||||
import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words'
|
||||
@@ -34,272 +33,121 @@ function entry(
|
||||
}
|
||||
}
|
||||
|
||||
const NOT_FAILED_HERE: ReadonlySet<string> = new Set()
|
||||
function row(
|
||||
clientMessageId: string,
|
||||
dispatchState: AgentJournalSubmission['dispatchState'],
|
||||
patch: Partial<AgentJournalSubmission> = {}
|
||||
): AgentJournalSubmission {
|
||||
return {
|
||||
clientMessageId,
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fp',
|
||||
dispatchState,
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
submittedAt: 1,
|
||||
resolvedAt: null,
|
||||
...patch
|
||||
}
|
||||
}
|
||||
|
||||
// What the row shows, quietly in place of its time, while nothing has confirmed the message.
|
||||
const SENDING = 'Sending…'
|
||||
const NOT_CONFIRMED = "Not confirmed. Send it again if the agent didn't answer it."
|
||||
|
||||
function texts(
|
||||
outbox: StructuredAgentSessionOutboxEntry[],
|
||||
submissions: readonly AgentJournalSubmission[] = [],
|
||||
startFailures: readonly AgentSessionFailureFact[] = []
|
||||
startFailures: readonly AgentSessionFailureFact[] = [],
|
||||
agentActive = false
|
||||
): Record<string, string> {
|
||||
// Every failure seen while the chat was open, so each words its whole cause.
|
||||
const notices = structuredAgentSessionDeliveryNotices(
|
||||
outbox,
|
||||
'Claude',
|
||||
() => {},
|
||||
submissions,
|
||||
startFailures,
|
||||
new Set(outbox.map((candidate) => candidate.clientMessageId))
|
||||
undefined,
|
||||
agentActive
|
||||
)
|
||||
return Object.fromEntries(
|
||||
[...notices].map(([id, notice]) => [id, notice.sending ? SENDING : notice.text])
|
||||
)
|
||||
}
|
||||
|
||||
describe('the notice on each message that did not go through', () => {
|
||||
it('gives two failed messages each their own reason and their own Retry', () => {
|
||||
const retry = vi.fn()
|
||||
const notices = structuredAgentSessionDeliveryNotices(
|
||||
[
|
||||
entry('first', {
|
||||
state: 'rejected',
|
||||
lastFailure: { kind: 'rejected', reason: 'Claude messages support at most 20 images' }
|
||||
}),
|
||||
entry('second', {
|
||||
state: 'rejected',
|
||||
lastFailure: {
|
||||
kind: 'rejected',
|
||||
reason: 'Claude never finished starting, so Orca stopped it.',
|
||||
rejection: { kind: 'hostStopped' }
|
||||
}
|
||||
})
|
||||
],
|
||||
'Claude',
|
||||
retry,
|
||||
[],
|
||||
[],
|
||||
NOT_FAILED_HERE
|
||||
)
|
||||
|
||||
expect([...notices.keys()]).toEqual([
|
||||
agentJournalSubmissionKey('first'),
|
||||
agentJournalSubmissionKey('second')
|
||||
])
|
||||
expect(notices.get(agentJournalSubmissionKey('first'))?.text).toBe(
|
||||
'Claude messages support at most 20 images'
|
||||
)
|
||||
expect(notices.get(agentJournalSubmissionKey('second'))?.text).toBe(
|
||||
'Claude never finished starting, so Orca stopped it.'
|
||||
)
|
||||
notices.get(agentJournalSubmissionKey('second'))?.onRetry?.()
|
||||
expect(retry).toHaveBeenCalledExactlyOnceWith('second')
|
||||
})
|
||||
|
||||
it('chooses the words from the saved refusal on a refused message', () => {
|
||||
expect(
|
||||
texts([
|
||||
entry('held', {
|
||||
lastFailure: { kind: 'refused', code: 'agent_session_owner_restart_failed' }
|
||||
})
|
||||
])
|
||||
).toEqual({
|
||||
[agentJournalSubmissionKey('held')]: "The agent couldn't restart. Your message was not sent."
|
||||
})
|
||||
})
|
||||
|
||||
// The same rule as a rejected row's: its own Retry is the resend step, and any other step stays.
|
||||
it('leaves a retry step to the Retry beside a refused message', () => {
|
||||
const held = (lastFailure: StructuredAgentSessionOutboxEntry['lastFailure']) =>
|
||||
texts([entry('held', { lastFailure })])
|
||||
expect(
|
||||
held({
|
||||
kind: 'refused',
|
||||
code: 'agent_session_journal_unreadable',
|
||||
details: { reason: 'journalUnavailable' }
|
||||
})
|
||||
).toEqual({
|
||||
[agentJournalSubmissionKey('held')]:
|
||||
"Orca couldn't open this chat's history right now. Your message was not sent."
|
||||
})
|
||||
expect(
|
||||
held({
|
||||
kind: 'refused',
|
||||
code: 'agent_session_operation_invalid',
|
||||
details: { reason: 'notSignedIn' }
|
||||
})
|
||||
).toEqual({
|
||||
[agentJournalSubmissionKey('held')]:
|
||||
'Your message was not sent. Claude is not signed in for the selected account. Sign in first.'
|
||||
})
|
||||
})
|
||||
|
||||
it('says a message is unconfirmed, and only that it was not sent when nothing more is known', () => {
|
||||
expect(
|
||||
texts([entry('doubt', { state: 'unconfirmed', retryAfterUnknownSubmittedAt: -1 })])
|
||||
).toEqual({
|
||||
[agentJournalSubmissionKey('doubt')]: 'Message delivery is unconfirmed.'
|
||||
})
|
||||
expect(texts([entry('bare', { state: 'rejected' })])).toEqual({
|
||||
[agentJournalSubmissionKey('bare')]: 'Message was not sent.'
|
||||
})
|
||||
})
|
||||
|
||||
it('never says a send attempted before a Stop was not sent: the host may hold it', () => {
|
||||
const interrupted = entry('stopped', { state: 'queued', lastAttemptAt: 5, outlivedStop: true })
|
||||
expect(texts([interrupted])).toEqual({
|
||||
[agentJournalSubmissionKey('stopped')]: 'Message delivery is unconfirmed.'
|
||||
})
|
||||
const neverSent = entry('unsent', { state: 'queued', outlivedStop: true })
|
||||
expect(texts([neverSent])).toEqual({
|
||||
[agentJournalSubmissionKey('unsent')]: 'Message was not sent.'
|
||||
})
|
||||
})
|
||||
|
||||
// The drain's own rule: a message behind the one the queue stopped on is only waiting, so it says
|
||||
// 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', 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('behind')]: SENDING,
|
||||
[agentJournalSubmissionKey('queued')]: SENDING
|
||||
})
|
||||
})
|
||||
|
||||
// Its Retry would put it back in the queue to wait unseen behind the stopped message.
|
||||
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', retryAfterUnknownSubmittedAt: -1 }),
|
||||
entry('rejected', { state: 'rejected' })
|
||||
],
|
||||
[entry('held', { outlivedStop: true }), entry('rejected', { state: 'rejected' })]
|
||||
]) {
|
||||
const notices = structuredAgentSessionDeliveryNotices(
|
||||
outbox,
|
||||
'Claude',
|
||||
retry,
|
||||
[],
|
||||
[],
|
||||
NOT_FAILED_HERE
|
||||
)
|
||||
expect(notices.get(agentJournalSubmissionKey('rejected'))).toEqual({
|
||||
text: 'Message was not sent.',
|
||||
notSent: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Ahead of the stopped message, its Retry sends it at once, so the row offers it.
|
||||
it.each([
|
||||
['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()
|
||||
const notices = structuredAgentSessionDeliveryNotices(
|
||||
[
|
||||
entry('refused', {
|
||||
lastAttemptAt: 1,
|
||||
lastFailure: {
|
||||
kind: 'refused',
|
||||
code: 'agent_session_journal_unreadable',
|
||||
details: { reason: 'journalWrittenByNewerOrca' }
|
||||
}
|
||||
}),
|
||||
entry('rejected', { state: 'rejected' }),
|
||||
entry('stuck', { lastAttemptAt: 2, ...patch })
|
||||
],
|
||||
'Claude',
|
||||
retry,
|
||||
[],
|
||||
[],
|
||||
NOT_FAILED_HERE
|
||||
)
|
||||
for (const id of ['refused', 'rejected', 'stuck']) {
|
||||
notices.get(agentJournalSubmissionKey(id))?.onRetry?.()
|
||||
}
|
||||
expect(retry.mock.calls).toEqual([['refused'], ['rejected'], ['stuck']])
|
||||
})
|
||||
|
||||
// Beside its own Retry the resend step is the button; without one the words keep it.
|
||||
it('leaves out sending again only where the message has its own Retry', () => {
|
||||
const startFailed = (clientMessageId: string): StructuredAgentSessionOutboxEntry =>
|
||||
entry(clientMessageId, {
|
||||
state: 'rejected',
|
||||
lastFailure: {
|
||||
kind: 'rejected',
|
||||
reason: 'Claude stopped before it finished starting. Send your message to try again.',
|
||||
rejection: { kind: 'providerStartFailed' }
|
||||
}
|
||||
})
|
||||
expect(texts([startFailed('first'), startFailed('second')])).toEqual({
|
||||
[agentJournalSubmissionKey('first')]: 'Claude stopped before it finished starting.',
|
||||
[agentJournalSubmissionKey('second')]: 'Claude stopped before it finished starting.'
|
||||
})
|
||||
expect(texts([entry('held', { outlivedStop: true }), startFailed('rejected')])).toMatchObject({
|
||||
[agentJournalSubmissionKey('rejected')]:
|
||||
'Claude stopped before it finished starting. Send your message to try again.'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'notDelivered',
|
||||
'This message was not delivered. Send it again to continue.',
|
||||
'This message was not delivered.'
|
||||
],
|
||||
[
|
||||
'hostFault',
|
||||
"Orca ran into a problem, so this didn't go through. Try again.",
|
||||
"Orca ran into a problem, so this didn't go through."
|
||||
describe('a message still in the outbox', () => {
|
||||
// Nothing in the outbox has failed: a send that ends leaves it. Whatever it waits on, it reads
|
||||
// as sending until the host holds a row for it, and never offers a Retry.
|
||||
it('reads as sending in every state it can wait in', () => {
|
||||
const outbox = [
|
||||
entry('queued'),
|
||||
entry('sending', { state: 'dispatching', lastAttemptAt: 1 }),
|
||||
entry('resent', { state: 'unconfirmed', lastAttemptAt: 1 }),
|
||||
entry('stopped', {
|
||||
state: 'unconfirmed',
|
||||
lastAttemptAt: 1,
|
||||
stoppedBy: { operationId: 'stop-1' }
|
||||
}),
|
||||
entry('legacy', { legacyUnsettled: true })
|
||||
]
|
||||
] as const)('leaves the step to the Retry beside a %s message', (kind, reason, shown) => {
|
||||
expect(
|
||||
texts([
|
||||
entry('rejected', {
|
||||
state: 'rejected',
|
||||
lastFailure: { kind: 'rejected', reason, rejection: { kind } }
|
||||
})
|
||||
])
|
||||
).toEqual({ [agentJournalSubmissionKey('rejected')]: shown })
|
||||
const notices = structuredAgentSessionDeliveryNotices(outbox, 'Claude', [], [])
|
||||
expect([...notices.values()]).toEqual(outbox.map(() => ({ sending: true })))
|
||||
})
|
||||
|
||||
// The journal holds the whole fact; the message's own copy keeps only its kind and attachment.
|
||||
it("words a recorded rejection from the journal's fact, whatever reason the host wrote", () => {
|
||||
const rejected = (id: string): StructuredAgentSessionOutboxEntry =>
|
||||
entry(id, {
|
||||
state: 'rejected',
|
||||
// An older host's words, and not the pane's agent name: never compared, never shown.
|
||||
lastFailure: {
|
||||
kind: 'rejected',
|
||||
reason: "The agent couldn't be started.",
|
||||
rejection: { kind: 'startFailed' }
|
||||
}
|
||||
})
|
||||
const recorded = (id: string, rejection: AgentSessionFailureFact): AgentJournalSubmission => ({
|
||||
clientMessageId: id,
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'rejected',
|
||||
providerItemId: null,
|
||||
reason: "The agent couldn't be started.",
|
||||
rejection,
|
||||
submittedAt: 1,
|
||||
resolvedAt: 1
|
||||
// Any row the host holds for it is the answer; the row itself then shows it.
|
||||
it.each(['pending', 'accepted'] as const)(
|
||||
'stops reading as sending once the journal holds a %s row for it',
|
||||
(dispatchState) => {
|
||||
const outbox = [entry('queued'), entry('resent', { state: 'unconfirmed', lastAttemptAt: 1 })]
|
||||
expect(texts(outbox, [row('queued', dispatchState), row('resent', dispatchState)])).toEqual(
|
||||
{}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it("reads as sending though another message's row is in the journal", () => {
|
||||
expect(texts([entry('mine')], [row('other', 'accepted')])).toEqual({
|
||||
[agentJournalSubmissionKey('mine')]: SENDING
|
||||
})
|
||||
})
|
||||
|
||||
// One state, one surface: a message the host recorded as not sent never also reads as sending.
|
||||
it('says only how it ended once the journal rejects it, never that it is sending', () => {
|
||||
const notices = structuredAgentSessionDeliveryNotices(
|
||||
[entry('m', { state: 'unconfirmed', lastAttemptAt: 1 })],
|
||||
'Claude',
|
||||
[row('m', 'rejected', { reason: 'not_delivered', resolvedAt: 2 })],
|
||||
[]
|
||||
)
|
||||
expect(notices.get(agentJournalSubmissionKey('m'))).toEqual({
|
||||
muted: true,
|
||||
text: 'Your message was not sent.'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('a message the host recorded and did not deliver', () => {
|
||||
it('says so, muted, on every client, in the words the sender would read', () => {
|
||||
const notices = structuredAgentSessionDeliveryNotices(
|
||||
[],
|
||||
'Claude',
|
||||
[row('elsewhere', 'rejected', { reason: 'not_delivered', resolvedAt: 1 })],
|
||||
[]
|
||||
)
|
||||
expect(notices.get(agentJournalSubmissionKey('elsewhere'))).toEqual({
|
||||
muted: true,
|
||||
text: 'Your message was not sent.'
|
||||
})
|
||||
})
|
||||
|
||||
it('says nothing for a send a Stop withdrew', () => {
|
||||
expect(
|
||||
texts([], [row('withdrawn', 'rejected', { reason: 'provider_cancelled_before_start' })])
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
it("words a recorded rejection from the journal's fact, whatever reason the host wrote", () => {
|
||||
const recorded = (id: string, rejection: AgentSessionFailureFact): AgentJournalSubmission =>
|
||||
row(id, 'rejected', { reason: "The agent couldn't be started.", rejection, resolvedAt: 1 })
|
||||
const facts: [string, AgentSessionFailureFact, string][] = [
|
||||
[
|
||||
'gone',
|
||||
@@ -320,11 +168,6 @@ describe('the notice on each message that did not go through', () => {
|
||||
},
|
||||
"Claude couldn't start. This chat is still open in a terminal agent. Quit that agent to continue the chat here."
|
||||
],
|
||||
[
|
||||
'resumable',
|
||||
{ kind: 'startFailed', refusal: { code: 'agent_session_ownership_unknown' } },
|
||||
"Claude couldn't start."
|
||||
],
|
||||
[
|
||||
'provider',
|
||||
{ kind: 'providerRejected', detail: { text: 'Image type .bmp', audience: 'person' } },
|
||||
@@ -338,408 +181,22 @@ describe('the notice on each message that did not go through', () => {
|
||||
]
|
||||
expect(
|
||||
texts(
|
||||
facts.map(([id]) => rejected(id)),
|
||||
[],
|
||||
facts.map(([id, fact]) => recorded(id, fact))
|
||||
)
|
||||
).toEqual(
|
||||
Object.fromEntries(facts.map(([id, , shown]) => [agentJournalSubmissionKey(id), shown]))
|
||||
)
|
||||
// Not loaded (older than the loaded page): the message's copy has no refusal or detail, so the
|
||||
// host's sentence, which holds them, is shown for those kinds; the table words the rest.
|
||||
expect(
|
||||
texts([
|
||||
rejected('gone'),
|
||||
entry('provider', {
|
||||
state: 'rejected',
|
||||
lastFailure: {
|
||||
kind: 'rejected',
|
||||
reason: 'The provider did not accept this message: Image type .bmp.',
|
||||
rejection: { kind: 'providerRejected' }
|
||||
}
|
||||
}),
|
||||
entry('stopped', {
|
||||
state: 'rejected',
|
||||
lastFailure: {
|
||||
kind: 'rejected',
|
||||
reason: 'The agent stopped before this message was sent.',
|
||||
rejection: { kind: 'providerExited' }
|
||||
}
|
||||
})
|
||||
])
|
||||
).toEqual({
|
||||
[agentJournalSubmissionKey('gone')]: "The agent couldn't be started.",
|
||||
[agentJournalSubmissionKey('provider')]:
|
||||
'The provider did not accept this message: Image type .bmp.',
|
||||
[agentJournalSubmissionKey('stopped')]: 'Claude stopped before this message was sent.'
|
||||
})
|
||||
})
|
||||
|
||||
it("shows the host's sentence when this build cannot read all of a rejection's fact", () => {
|
||||
const reason = "Claude couldn't start. Start a new chat to continue."
|
||||
const copiedReason = 'An image on this message uses a newer check.'
|
||||
// As a newer host sends them: a known code with a new reason, and a new attachment reason.
|
||||
const newerStart = JSON.parse(
|
||||
'{ "kind": "startFailed", "refusal": { "code": "agent_session_conflict", "details": { "reason": "newerReason" } } }'
|
||||
)
|
||||
const newerAttachment = JSON.parse(
|
||||
'{ "kind": "attachmentInvalid", "attachment": { "reason": "newerReason" } }'
|
||||
)
|
||||
expect(
|
||||
texts(
|
||||
[
|
||||
entry('recorded', {
|
||||
state: 'rejected',
|
||||
lastFailure: structuredAgentSessionRejectedFailure({ reason, rejection: newerStart })
|
||||
}),
|
||||
entry('copied', {
|
||||
state: 'rejected',
|
||||
lastFailure: structuredAgentSessionRejectedFailure({
|
||||
reason: copiedReason,
|
||||
rejection: newerAttachment
|
||||
})
|
||||
})
|
||||
],
|
||||
[
|
||||
{
|
||||
clientMessageId: 'recorded',
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'rejected',
|
||||
providerItemId: null,
|
||||
reason,
|
||||
rejection: newerStart,
|
||||
submittedAt: 1,
|
||||
resolvedAt: 1
|
||||
}
|
||||
]
|
||||
)
|
||||
).toEqual({
|
||||
[agentJournalSubmissionKey('recorded')]: reason,
|
||||
[agentJournalSubmissionKey('copied')]: copiedReason
|
||||
})
|
||||
})
|
||||
|
||||
// An earlier attempt under the id may have landed, so the row never says it was not sent.
|
||||
// Only a plain "not sent" reads muted; a doubt still reads as one to check.
|
||||
it('marks as not sent only words that say the message did not go out', () => {
|
||||
const notices = structuredAgentSessionDeliveryNotices(
|
||||
[
|
||||
entry('doubt', { state: 'unconfirmed' }),
|
||||
entry('expired', {
|
||||
lastAttemptAt: 1,
|
||||
lastFailure: { kind: 'refused', code: 'agent_session_operation_expired' }
|
||||
}),
|
||||
entry('rejected', { state: 'rejected' }),
|
||||
entry('held', {
|
||||
lastFailure: { kind: 'refused', code: 'agent_session_owner_restart_failed' }
|
||||
})
|
||||
],
|
||||
'Claude',
|
||||
vi.fn(),
|
||||
[
|
||||
{
|
||||
clientMessageId: 'elsewhere',
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'rejected',
|
||||
providerItemId: null,
|
||||
reason: 'not_delivered',
|
||||
submittedAt: 1,
|
||||
resolvedAt: 1
|
||||
}
|
||||
],
|
||||
[],
|
||||
NOT_FAILED_HERE
|
||||
)
|
||||
expect(
|
||||
Object.fromEntries([...notices].map(([id, notice]) => [id, notice.notSent === true]))
|
||||
).toEqual({
|
||||
[agentJournalSubmissionKey('doubt')]: false,
|
||||
[agentJournalSubmissionKey('expired')]: false,
|
||||
[agentJournalSubmissionKey('rejected')]: true,
|
||||
[agentJournalSubmissionKey('held')]: true,
|
||||
[agentJournalSubmissionKey('elsewhere')]: true
|
||||
})
|
||||
})
|
||||
|
||||
it('words a kept message whose id expired as an outcome Orca cannot confirm', () => {
|
||||
const notices = structuredAgentSessionDeliveryNotices(
|
||||
[
|
||||
entry('expired', {
|
||||
lastAttemptAt: 1,
|
||||
lastFailure: { kind: 'refused', code: 'agent_session_operation_expired' }
|
||||
}),
|
||||
entry('fresh', {
|
||||
state: 'rejected',
|
||||
lastFailure: { kind: 'refused', code: 'agent_session_operation_expired' }
|
||||
})
|
||||
],
|
||||
'Claude',
|
||||
vi.fn(),
|
||||
[],
|
||||
[],
|
||||
NOT_FAILED_HERE
|
||||
)
|
||||
expect(notices.get(agentJournalSubmissionKey('expired'))).toMatchObject({
|
||||
text: "Orca couldn't confirm what happened. Check the chat."
|
||||
})
|
||||
expect(notices.get(agentJournalSubmissionKey('expired'))?.onRetry).toBeDefined()
|
||||
// A first attempt's id was replaced when it was refused, so nothing can have landed.
|
||||
expect(notices.get(agentJournalSubmissionKey('fresh'))?.text).toBe('Your message was not sent.')
|
||||
})
|
||||
|
||||
// A cause read back from storage may have cleared; a Retry it still stops brings it back.
|
||||
it('words a held cause only where its failure was seen while the chat was open', () => {
|
||||
const held = entry('held', {
|
||||
lastAttemptAt: 1,
|
||||
lastFailure: {
|
||||
kind: 'refused',
|
||||
code: 'agent_session_journal_unreadable',
|
||||
details: { reason: 'journalWrittenByNewerOrca' }
|
||||
}
|
||||
})
|
||||
const words = (failedHere: ReadonlySet<string>) =>
|
||||
structuredAgentSessionDeliveryNotices([held], 'Claude', vi.fn(), [], [], failedHere).get(
|
||||
agentJournalSubmissionKey('held')
|
||||
)
|
||||
expect(words(NOT_FAILED_HERE)).toMatchObject({ text: 'Your message was not sent.' })
|
||||
expect(words(NOT_FAILED_HERE)?.onRetry).toBeDefined()
|
||||
expect(words(new Set(['held']))?.text).toBe(
|
||||
'Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them.'
|
||||
)
|
||||
})
|
||||
|
||||
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.
|
||||
describe('a message rejected by a start whose row already says why', () => {
|
||||
describe('rejected by a start whose row already says why', () => {
|
||||
const startFailed: AgentSessionFailureFact = {
|
||||
kind: 'startFailed',
|
||||
refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } }
|
||||
}
|
||||
const rejected = (id: string, fact: AgentSessionFailureFact) =>
|
||||
entry(id, {
|
||||
state: 'rejected',
|
||||
lastFailure: {
|
||||
kind: 'rejected',
|
||||
reason: 'Written by the host.',
|
||||
rejection: { kind: fact.kind }
|
||||
}
|
||||
})
|
||||
const recorded = (id: string, fact: AgentSessionFailureFact): AgentJournalSubmission => ({
|
||||
clientMessageId: id,
|
||||
fence: 1,
|
||||
payloadFingerprint: id,
|
||||
dispatchState: 'rejected',
|
||||
providerItemId: null,
|
||||
reason: 'Written by the host.',
|
||||
rejection: fact,
|
||||
submittedAt: 1,
|
||||
resolvedAt: 1
|
||||
})
|
||||
const recorded = (id: string, fact: AgentSessionFailureFact): AgentJournalSubmission =>
|
||||
row(id, 'rejected', { reason: 'Written by the host.', rejection: fact, resolvedAt: 1 })
|
||||
const statusRow = (itemId: string, fact: AgentSessionFailureFact): AgentJournalRenderItem => ({
|
||||
itemId,
|
||||
revision: 1,
|
||||
@@ -770,11 +227,7 @@ describe('the notice on each message that did not go through', () => {
|
||||
const facts = structuredAgentSessionStartFailureFacts([statusRow(startRowKey, startFailed)])
|
||||
expect(
|
||||
texts(
|
||||
[
|
||||
rejected('first', startFailed),
|
||||
rejected('second', startFailed),
|
||||
rejected('other', otherRefusal)
|
||||
],
|
||||
[],
|
||||
[
|
||||
recorded('first', startFailed),
|
||||
recorded('second', startFailed),
|
||||
@@ -789,116 +242,31 @@ describe('the notice on each message that did not go through', () => {
|
||||
"Claude couldn't start. This chat is still open in a terminal agent. Quit that agent to continue the chat here."
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the full notice when the rejection is not loaded, or no start row states it', () => {
|
||||
const shown = "Claude couldn't start. Start a new chat to continue."
|
||||
expect(texts([rejected('first', startFailed)], [], [startFailed])).toEqual({
|
||||
[agentJournalSubmissionKey('first')]: 'Written by the host.'
|
||||
})
|
||||
expect(texts([rejected('first', startFailed)], [recorded('first', startFailed)], [])).toEqual(
|
||||
{ [agentJournalSubmissionKey('first')]: shown }
|
||||
)
|
||||
// Its outcome was lost when the process sending it went away: the row says so for every viewer,
|
||||
// once nothing running could still deliver it.
|
||||
describe('a message whose outcome the host lost', () => {
|
||||
it.each([
|
||||
['a recovered unknown', row('lost', 'unknown', { recovered: true })],
|
||||
[
|
||||
"an older host's recovered unknown",
|
||||
row('lost', 'unknown', { reason: 'host_restarted_before_acknowledgement' })
|
||||
]
|
||||
])('says it is not confirmed, muted, for %s', (_label, lost) => {
|
||||
const notices = structuredAgentSessionDeliveryNotices([], 'Claude', [lost], [])
|
||||
expect(notices.get(agentJournalSubmissionKey('lost'))).toEqual({
|
||||
muted: true,
|
||||
text: NOT_CONFIRMED
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('a recorded rejection no outbox entry here carries', () => {
|
||||
const recorded = (patch: Partial<AgentJournalSubmission> = {}): AgentJournalSubmission => ({
|
||||
clientMessageId: 'elsewhere',
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'rejected',
|
||||
providerItemId: null,
|
||||
reason: 'not_delivered',
|
||||
submittedAt: 1,
|
||||
resolvedAt: 1,
|
||||
...patch
|
||||
it('says nothing while the agent works or starts, which may still resolve it', () => {
|
||||
expect(texts([], [row('lost', 'unknown', { recovered: true })], [], true)).toEqual({})
|
||||
})
|
||||
|
||||
it('says it was not sent on its own row, with no Retry, in the words the sender would read', () => {
|
||||
const notices = structuredAgentSessionDeliveryNotices(
|
||||
[],
|
||||
'Claude',
|
||||
() => {},
|
||||
[recorded()],
|
||||
[],
|
||||
NOT_FAILED_HERE
|
||||
)
|
||||
const senderNotices = structuredAgentSessionDeliveryNotices(
|
||||
[
|
||||
entry('elsewhere', {
|
||||
state: 'rejected',
|
||||
lastFailure: structuredAgentSessionRejectedFailure(recorded())
|
||||
})
|
||||
],
|
||||
'Claude',
|
||||
() => {},
|
||||
[recorded()],
|
||||
[],
|
||||
new Set(['elsewhere'])
|
||||
)
|
||||
const id = agentJournalSubmissionKey('elsewhere')
|
||||
expect(notices.get(id)?.onRetry).toBeUndefined()
|
||||
expect(senderNotices.get(id)?.onRetry).toBeDefined()
|
||||
expect(notices.get(id)?.text).toBe('Your message was not sent.')
|
||||
expect(senderNotices.get(id)?.text).toBe(notices.get(id)?.text)
|
||||
})
|
||||
|
||||
it("keeps the sender's Retry while its outbox entry exists", () => {
|
||||
const retry = vi.fn()
|
||||
const notices = structuredAgentSessionDeliveryNotices(
|
||||
[
|
||||
entry('mine', {
|
||||
state: 'rejected',
|
||||
lastFailure: structuredAgentSessionRejectedFailure(recorded({ clientMessageId: 'mine' }))
|
||||
})
|
||||
],
|
||||
'Claude',
|
||||
retry,
|
||||
[recorded({ clientMessageId: 'mine' })],
|
||||
[],
|
||||
NOT_FAILED_HERE
|
||||
)
|
||||
notices.get(agentJournalSubmissionKey('mine'))?.onRetry?.()
|
||||
expect(retry).toHaveBeenCalledWith('mine')
|
||||
expect(notices.size).toBe(1)
|
||||
})
|
||||
|
||||
it('says nothing for a send a Stop withdrew', () => {
|
||||
const notices = structuredAgentSessionDeliveryNotices(
|
||||
[],
|
||||
'Claude',
|
||||
() => {},
|
||||
[recorded({ reason: 'provider_cancelled_before_start' })],
|
||||
[],
|
||||
NOT_FAILED_HERE
|
||||
)
|
||||
expect(notices.size).toBe(0)
|
||||
})
|
||||
|
||||
it('says only that it was not sent when the start-failure row already says why', () => {
|
||||
const startFailed: AgentSessionFailureFact = { kind: 'startFailed' }
|
||||
const row: AgentJournalRenderItem = {
|
||||
itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('gen')),
|
||||
revision: 1,
|
||||
sequence: 1,
|
||||
observedAt: 1,
|
||||
body: {
|
||||
kind: 'status',
|
||||
tone: 'error',
|
||||
...agentSessionFailureWords(startFailed, { agentName: 'Claude', surface: 'row' })
|
||||
}
|
||||
}
|
||||
const notices = structuredAgentSessionDeliveryNotices(
|
||||
[],
|
||||
'Claude',
|
||||
() => {},
|
||||
[recorded({ reason: 'Written by the host.', rejection: startFailed })],
|
||||
structuredAgentSessionStartFailureFacts([row]),
|
||||
NOT_FAILED_HERE
|
||||
)
|
||||
expect(notices.get(agentJournalSubmissionKey('elsewhere'))?.text).toBe(
|
||||
'Your message was not sent.'
|
||||
)
|
||||
it('says nothing for a live unknown, which something running can still answer', () => {
|
||||
expect(texts([], [row('live', 'unknown')])).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
+41
-139
@@ -1,45 +1,21 @@
|
||||
// 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 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 a
|
||||
// loaded host row already states (a failed start's, or a command's result row) says only that it
|
||||
// was not sent: the row already says why. One no outbox entry here carries (another client's
|
||||
// send, or one whose entry is gone) is worded from the journal alone, with no Retry: this client
|
||||
// holds nothing to send.
|
||||
// What the structured chat's user messages say about their delivery, derived on every render and
|
||||
// never stored:
|
||||
// - one in this client's outbox reads quietly as sending until the host holds a row for it
|
||||
// (pending or accepted): nothing in the outbox has failed, since a send that ends leaves it;
|
||||
// - one the host recorded and then did not deliver says so, muted, on every client, worded from
|
||||
// the journal's own fact. A rejection a loaded host row already states (a failed start's, or a
|
||||
// command's result row) says only that it was not sent: the row already says why;
|
||||
// - one whose outcome the host lost when the process sending it went away says it is not
|
||||
// confirmed, once nothing is running that could still deliver it.
|
||||
|
||||
import {
|
||||
readWholeAgentSessionFailureFact,
|
||||
type AgentSessionFailureFact
|
||||
} from '../../../../shared/agent-session-failure'
|
||||
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
|
||||
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
|
||||
import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice'
|
||||
import {
|
||||
structuredAgentSessionEntryIdExpired,
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from '../../../../shared/structured-agent-session-outbox'
|
||||
import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure'
|
||||
import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection'
|
||||
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 {
|
||||
agentSessionFailureStatedByStartRow,
|
||||
structuredAgentSessionRecordedRejectionParts
|
||||
} from '../../../../shared/structured-agent-session-recorded-rejection-words'
|
||||
import { structuredAgentSessionRecordedRejectionParts } from '../../../../shared/structured-agent-session-recorded-rejection-words'
|
||||
import { isRecoveredStructuredAgentSessionSubmission } from '../../../../shared/structured-agent-session-unanswered-dispatch'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { agentSessionWriteNoticeText } from './agent-session-write-notice-text'
|
||||
import type { NativeChatDeliveryNotice } from './NativeChatMessageRow'
|
||||
@@ -47,129 +23,47 @@ 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 }
|
||||
|
||||
/** Whether the entry's words say only that it was not sent, never that it may have landed. */
|
||||
function deliveryNoticeSaysNotSent(entry: StructuredAgentSessionOutboxEntry): boolean {
|
||||
return (
|
||||
!deliveryIsInDoubt(entry) && !(entry.lastFailure && structuredAgentSessionEntryIdExpired(entry))
|
||||
function hostHoldsIt(submissions: readonly AgentJournalSubmission[], id: string): boolean {
|
||||
return submissions.some(
|
||||
(submission) =>
|
||||
submission.clientMessageId === id &&
|
||||
(submission.dispatchState === 'pending' || submission.dispatchState === 'accepted')
|
||||
)
|
||||
}
|
||||
|
||||
function deliveryIsInDoubt(entry: StructuredAgentSessionOutboxEntry): boolean {
|
||||
// A send attempted before a Stop and then interrupted may already be with the host.
|
||||
const attemptedAcrossStop =
|
||||
entry.outlivedStop === true && entry.lastAttemptAt !== null && !entry.lastFailure
|
||||
return entry.state === 'unconfirmed' || attemptedAcrossStop
|
||||
}
|
||||
|
||||
function deliveryNoticeText(
|
||||
entry: StructuredAgentSessionOutboxEntry,
|
||||
context: AgentSessionFailureWordsContext,
|
||||
recorded: AgentJournalSubmission | undefined,
|
||||
startFailures: readonly AgentSessionFailureFact[],
|
||||
failedHere: ReadonlySet<string>
|
||||
): string {
|
||||
if (deliveryIsInDoubt(entry)) {
|
||||
return translate(
|
||||
'auto.components.native.chat.NativeChatStructuredSession.1f772bb5d0',
|
||||
'Message delivery is unconfirmed.'
|
||||
)
|
||||
}
|
||||
if (!entry.lastFailure) {
|
||||
return translate(
|
||||
'auto.components.native.chat.NativeChatStructuredSession.93ef441197',
|
||||
'Message was not sent.'
|
||||
)
|
||||
}
|
||||
// An earlier attempt under the id the host forgot may already be in the chat.
|
||||
if (structuredAgentSessionEntryIdExpired(entry)) {
|
||||
return agentSessionWriteNoticeText(['outcomeUnknown'])
|
||||
}
|
||||
// Its cause may have cleared since it was saved; a Retry it still stops brings the cause back.
|
||||
if (structuredAgentSessionEntryHeldForRetry(entry) && !failedHere.has(entry.clientMessageId)) {
|
||||
return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send'))
|
||||
}
|
||||
if (
|
||||
entry.state === 'rejected' &&
|
||||
agentSessionFailureStatedByStartRow(recorded?.rejection, startFailures)
|
||||
) {
|
||||
return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send'))
|
||||
}
|
||||
return agentSessionWriteNoticeText(
|
||||
structuredAgentSessionAttemptFailureParts(
|
||||
entry.lastFailure,
|
||||
context,
|
||||
readWholeAgentSessionFailureFact(recorded?.rejection)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Keyed by the message id the transcript renders each entry under; `agentName` is the chat's
|
||||
/** Keyed by the message id the transcript renders each one under; `agentName` is the chat's
|
||||
* agent, for the words. */
|
||||
export function structuredAgentSessionDeliveryNotices(
|
||||
outbox: readonly StructuredAgentSessionOutboxEntry[],
|
||||
agentName: string,
|
||||
retry: (clientMessageId: string) => void,
|
||||
/** 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. */
|
||||
/** The journal's rows: rejected ones carry their whole fact, 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>,
|
||||
/** Commands whose loaded result row says how they ended, from `structuredAgentSessionCommandResultRows`. */
|
||||
commandResults?: ReadonlySet<string>
|
||||
commandResults?: ReadonlySet<string>,
|
||||
/** Whether the agent is working or starting, so a lost outcome may still resolve. */
|
||||
agentActive = false
|
||||
): ReadonlyMap<string, NativeChatDeliveryNotice> {
|
||||
// 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' ? 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 entries.entries()) {
|
||||
// Resent under its own id until the journal answers, so still sending, not failed.
|
||||
if (
|
||||
!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
|
||||
const text = deliveryNoticeText(
|
||||
entry,
|
||||
{ agentName, retryControl },
|
||||
rejected.get(entry.clientMessageId),
|
||||
startFailures,
|
||||
failedHere
|
||||
)
|
||||
notices.set(agentJournalSubmissionKey(entry.clientMessageId), {
|
||||
text,
|
||||
...(deliveryNoticeSaysNotSent(entry) ? { notSent: true as const } : {}),
|
||||
...(retryControl ? { onRetry: () => retry(entry.clientMessageId) } : {})
|
||||
})
|
||||
} else if (
|
||||
!submissions.some(
|
||||
(submission) =>
|
||||
submission.clientMessageId === entry.clientMessageId &&
|
||||
(submission.dispatchState === 'pending' || submission.dispatchState === 'accepted')
|
||||
)
|
||||
) {
|
||||
// As the transcript reads it, so a row that lands is answered here before the outbox commits it.
|
||||
for (const entry of reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions)) {
|
||||
if (!hostHoldsIt(submissions, entry.clientMessageId)) {
|
||||
notices.set(
|
||||
agentJournalSubmissionKey(entry.clientMessageId),
|
||||
STRUCTURED_AGENT_SESSION_DELIVERY_SENDING
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const submission of rejected.values()) {
|
||||
for (const submission of submissions) {
|
||||
const id = agentJournalSubmissionKey(submission.clientMessageId)
|
||||
if (!notices.has(id) && !dispatchWasWithdrawn(submission)) {
|
||||
if (notices.has(id)) {
|
||||
continue
|
||||
}
|
||||
if (submission.dispatchState === 'rejected' && !dispatchWasWithdrawn(submission)) {
|
||||
notices.set(id, {
|
||||
notSent: true,
|
||||
muted: true,
|
||||
text: agentSessionWriteNoticeText(
|
||||
structuredAgentSessionRecordedRejectionParts(
|
||||
submission,
|
||||
@@ -179,6 +73,14 @@ export function structuredAgentSessionDeliveryNotices(
|
||||
)
|
||||
)
|
||||
})
|
||||
} else if (!agentActive && isRecoveredStructuredAgentSessionSubmission(submission)) {
|
||||
notices.set(id, {
|
||||
muted: true,
|
||||
text: translate(
|
||||
'components.native-chat.messageNotConfirmed',
|
||||
"Not confirmed. Send it again if the agent didn't answer it."
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
return notices
|
||||
|
||||
+3
-5
@@ -95,7 +95,7 @@ describe('structured agent session message projection', () => {
|
||||
expect(projectStructuredAgentSessionMessages([withdrawnItem], [], [withdrawn])).toEqual([])
|
||||
})
|
||||
|
||||
it("shows the sender's recorded rejection once, from the journal, while its outbox entry holds the Retry", () => {
|
||||
it("shows the sender's recorded rejection once, from the journal, even before its entry leaves", () => {
|
||||
const rejected = { ...submission(0), dispatchState: 'rejected' as const, providerItemId: null }
|
||||
const refusedItem = { ...item(0), itemId: agentJournalSubmissionKey(rejected.clientMessageId) }
|
||||
const draft = {
|
||||
@@ -106,14 +106,14 @@ describe('structured agent session message projection', () => {
|
||||
attachments: [],
|
||||
queuedAt: 1
|
||||
}),
|
||||
state: 'rejected' as const
|
||||
state: 'dispatching' as const
|
||||
}
|
||||
expect(projectStructuredAgentSessionMessages([refusedItem], [draft], [rejected])).toEqual([
|
||||
expect.objectContaining({ id: refusedItem.itemId, unsent: true })
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps the not-sent original when the sender's Retry sends it again as a new message", () => {
|
||||
it('keeps the not-sent original when the same text is sent again as a new message', () => {
|
||||
const rejected = { ...submission(0), dispatchState: 'rejected' as const, providerItemId: null }
|
||||
const refusedItem = { ...item(0), itemId: agentJournalSubmissionKey(rejected.clientMessageId) }
|
||||
const resend = createStructuredAgentSessionOutboxEntry({
|
||||
@@ -241,10 +241,8 @@ describe('structured agent session message projection', () => {
|
||||
const notices = structuredAgentSessionDeliveryNotices(
|
||||
[],
|
||||
'Codex',
|
||||
() => {},
|
||||
[compact],
|
||||
structuredAgentSessionStartFailureFacts(items),
|
||||
new Set(),
|
||||
structuredAgentSessionCommandResultRows(items)
|
||||
)
|
||||
expect(notices.get(userItemId)?.text).toBe(line)
|
||||
|
||||
+6
-5
@@ -1,5 +1,5 @@
|
||||
// An owner change sends a send left dispatching again under its id. One a Stop outlived is put
|
||||
// back the same way, and its mark alone holds it: the drain never admits it.
|
||||
// An owner change sends a send left dispatching again under its id. One a Stop outran stays as it
|
||||
// is: nothing sends it again.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
@@ -28,12 +28,13 @@ function dispatching(
|
||||
}
|
||||
|
||||
describe('requeue after an owner change', () => {
|
||||
it('resends an interrupted send, but never one a Stop outlived', () => {
|
||||
it('resends an interrupted send, but never one a Stop outran', () => {
|
||||
const [stopped] = requeueInterruptedStructuredAgentSessionDispatches(
|
||||
[dispatching('stopped', { outlivedStop: true })],
|
||||
[dispatching('stopped', { stoppedBy: { operationId: 'stop-1' } })],
|
||||
1
|
||||
)
|
||||
expect(admitStructuredAgentSessionOutboxEntry([stopped]).state).toBe('blocked')
|
||||
expect(stopped?.state).toBe('dispatching')
|
||||
expect(admitStructuredAgentSessionOutboxEntry([stopped!]).state).toBe('idle')
|
||||
const [plain] = requeueInterruptedStructuredAgentSessionDispatches([dispatching('plain')], 1)
|
||||
expect(admitStructuredAgentSessionOutboxEntry([plain]).state).toBe('dispatch')
|
||||
})
|
||||
|
||||
+176
-93
@@ -1,20 +1,24 @@
|
||||
// The one sender of an outbox entry, for the open chat's drain and a launch prompt alike, and the
|
||||
// one place an answer changes the outbox: through the shared settlement
|
||||
// (structured-agent-session-send-settlement).
|
||||
|
||||
import type {
|
||||
AgentSessionMutationResult,
|
||||
AgentSessionSendResult
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
import { readAgentSessionErrorRefusal } from '../../../../shared/agent-session-write-failure'
|
||||
import { STRUCTURED_AGENT_SESSION_OUTBOX_NOT_SAVED } from '../../../../shared/structured-agent-session-send-failure-words'
|
||||
import {
|
||||
agentSessionRefusalFailure,
|
||||
readAgentSessionErrorRefusal
|
||||
} from '../../../../shared/agent-session-write-failure'
|
||||
import {
|
||||
disposeStructuredAgentSessionSendFailure,
|
||||
disposeStructuredAgentSessionSendRefusal,
|
||||
disposeStructuredAgentSessionSendResult,
|
||||
STRUCTURED_AGENT_SESSION_OUTBOX_NOT_SAVED,
|
||||
type StructuredAgentSessionSendDisposition
|
||||
} from '../../../../shared/structured-agent-session-send-disposition'
|
||||
applyStructuredAgentSessionSendSettlement,
|
||||
settleStructuredAgentSessionSendAnswer,
|
||||
type StructuredAgentSessionSendAnswer,
|
||||
type StructuredAgentSessionSendSettlement,
|
||||
type StructuredAgentSessionSettledOutbox
|
||||
} from '../../../../shared/structured-agent-session-send-settlement'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
|
||||
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
|
||||
import { structuredAgentSessionHostAnswersProve } from '@/runtime/structured-agent-session-host-capability'
|
||||
import {
|
||||
stageStructuredAgentSessionOutboxEntryForSend,
|
||||
structuredAgentSessionSendRequest,
|
||||
@@ -23,21 +27,20 @@ import {
|
||||
} from '../../../../shared/structured-agent-session-outbox'
|
||||
import {
|
||||
commitStructuredAgentSessionOutbox,
|
||||
getStructuredAgentSessionOutbox
|
||||
getStructuredAgentSessionOutbox,
|
||||
readOutbox
|
||||
} from './structured-agent-session-outbox-storage'
|
||||
import { agentSessionWriteNoticeText } from './agent-session-write-notice-text'
|
||||
import {
|
||||
getStructuredAgentLaunchPromptDispatch,
|
||||
shareStructuredAgentLaunchPromptDispatch
|
||||
} from '@/lib/structured-agent-session-launch-prompt'
|
||||
import {
|
||||
returnStructuredAgentSessionMessage,
|
||||
setStructuredAgentSessionChatLine
|
||||
} from './structured-agent-session-returned-send'
|
||||
|
||||
type MutableRef<T> = { current: T }
|
||||
|
||||
function isDesktopDeliveryUnknown(error: unknown): boolean {
|
||||
const text = error instanceof Error ? `${error.name}:${error.message}` : String(error)
|
||||
return /timeout|disconnect|connection|closed|unavailable|cutover/i.test(text)
|
||||
}
|
||||
|
||||
export function hasInFlightLaunchDispatch(
|
||||
entry: StructuredAgentSessionOutboxEntry,
|
||||
fence: number | null
|
||||
@@ -67,22 +70,151 @@ export function readMountedStructuredAgentSessionOutbox(
|
||||
)
|
||||
}
|
||||
|
||||
/** A send left dispatching when its owner changed goes out again, under the same id; one a Stop
|
||||
* outlived is held from there by its mark, until the user's Retry. */
|
||||
/** A send left dispatching when its owner changed goes out again, under the same id. */
|
||||
export function requeueInterruptedStructuredAgentSessionDispatches(
|
||||
entries: StructuredAgentSessionOutboxEntry[],
|
||||
fence: number | null
|
||||
): StructuredAgentSessionOutboxEntry[] {
|
||||
return entries.map((entry) =>
|
||||
entry.state === 'dispatching' && !hasInFlightLaunchDispatch(entry, fence)
|
||||
? { ...entry, state: 'queued' as const }
|
||||
entry.state === 'dispatching' && entry.stoppedBy === undefined
|
||||
? hasInFlightLaunchDispatch(entry, fence)
|
||||
? entry
|
||||
: { ...entry, state: 'queued' as const }
|
||||
: entry
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits a settlement: a message coming back goes to its conversation's draft before its entry
|
||||
* leaves the outbox, so a failure between the two repeats the text and never loses it.
|
||||
*/
|
||||
export function commitStructuredAgentSessionSettledOutbox(
|
||||
sessionId: string,
|
||||
settled: StructuredAgentSessionSettledOutbox
|
||||
): void {
|
||||
if (settled.returned) {
|
||||
returnStructuredAgentSessionMessage(settled.returned.entry)
|
||||
if (settled.returned.words) {
|
||||
setStructuredAgentSessionChatLine(sessionId, settled.returned.words)
|
||||
}
|
||||
}
|
||||
commitStructuredAgentSessionOutbox(sessionId, settled.entries)
|
||||
}
|
||||
|
||||
/** Settles one entry against the current outbox and commits it. */
|
||||
export function settleStructuredAgentSessionOutboxEntry(
|
||||
sessionId: string,
|
||||
clientMessageId: string,
|
||||
settlement: StructuredAgentSessionSendSettlement
|
||||
): void {
|
||||
const current = getStructuredAgentSessionOutbox(sessionId)
|
||||
const entry = current.find((candidate) => candidate.clientMessageId === clientMessageId)
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
// A send a Stop outran never goes again: no answer leaves it waiting for the Stop's.
|
||||
const kept =
|
||||
entry.stoppedBy !== undefined && settlement.kind === 'unanswered'
|
||||
? { kind: 'pending' as const }
|
||||
: settlement
|
||||
commitStructuredAgentSessionSettledOutbox(
|
||||
sessionId,
|
||||
applyStructuredAgentSessionSendSettlement(current, clientMessageId, kept)
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether no attempt under this id has gone out from anywhere: read from storage too, which every
|
||||
* window of this app shares, so one window's first attempt is never another's. */
|
||||
function stagedAsFirstAttempt(entry: StructuredAgentSessionOutboxEntry): boolean {
|
||||
if (entry.lastAttemptAt !== null) {
|
||||
return false
|
||||
}
|
||||
const stored = readOutbox(entry.sessionId, { recoverDispatching: false }).find(
|
||||
(candidate) => candidate.clientMessageId === entry.clientMessageId
|
||||
)
|
||||
return stored === undefined || stored.lastAttemptAt === null
|
||||
}
|
||||
|
||||
async function readSendAnswer(
|
||||
target: RuntimeClientTarget,
|
||||
entry: StructuredAgentSessionOutboxEntry,
|
||||
fence: number
|
||||
): Promise<StructuredAgentSessionSendAnswer> {
|
||||
try {
|
||||
return {
|
||||
kind: 'result',
|
||||
result: await callStructuredAgentSession<AgentSessionMutationResult<AgentSessionSendResult>>(
|
||||
target,
|
||||
'agentSession.send',
|
||||
structuredAgentSessionSendRequest(entry, fence)
|
||||
)
|
||||
}
|
||||
} catch (caught) {
|
||||
return {
|
||||
kind: 'thrown',
|
||||
carriedRefusal: readAgentSessionErrorRefusal(caught) !== undefined,
|
||||
rpcCode: caught instanceof RuntimeRpcCallError ? caught.code : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one entry and settles it by the answer. `isCurrent` says whether the answer still belongs
|
||||
* to the outbox that sent it (an owner change voids it; the entry then goes again under its id).
|
||||
* Resolves to the settlement, or null when the answer was voided or the entry could not be staged.
|
||||
*/
|
||||
export async function sendStructuredAgentSessionOutboxEntry(args: {
|
||||
/** The request's own copy; `entries` holds the copy to stage. */
|
||||
next: StructuredAgentSessionOutboxEntry
|
||||
entries: readonly StructuredAgentSessionOutboxEntry[]
|
||||
target: RuntimeClientTarget
|
||||
fence: number
|
||||
isCurrent: () => boolean
|
||||
/** Whether the loaded journal holds a row for the id; absent where no journal is loaded. */
|
||||
journalHasRow?: (clientMessageId: string) => boolean
|
||||
}): Promise<StructuredAgentSessionSendSettlement | null> {
|
||||
const { next } = args
|
||||
const sessionId = next.sessionId
|
||||
const firstAttempt = stagedAsFirstAttempt(next)
|
||||
const staged = updateStructuredAgentSessionOutboxEntry(
|
||||
args.entries,
|
||||
next.clientMessageId,
|
||||
(entry) => stageStructuredAgentSessionOutboxEntryForSend(entry, Date.now())
|
||||
)
|
||||
if (!commitStructuredAgentSessionOutbox(sessionId, staged, { onlyIfSaved: true })) {
|
||||
// Unsaved, a send could go out that a reload never settles; it comes back instead.
|
||||
settleStructuredAgentSessionOutboxEntry(sessionId, next.clientMessageId, {
|
||||
kind: 'returned',
|
||||
words: [...STRUCTURED_AGENT_SESSION_OUTBOX_NOT_SAVED]
|
||||
})
|
||||
return null
|
||||
}
|
||||
const answer = await readSendAnswer(args.target, next, args.fence)
|
||||
if (!args.isCurrent()) {
|
||||
return null
|
||||
}
|
||||
// Only a resend's refusal reads the host's proof; asked after the answer, so a send never waits
|
||||
// on it, and a first attempt's refusal proves no record on any host.
|
||||
const answersProve =
|
||||
!firstAttempt && answer.kind === 'result' && !answer.result.ok
|
||||
? await structuredAgentSessionHostAnswersProve(args.target)
|
||||
: false
|
||||
if (!args.isCurrent()) {
|
||||
return null
|
||||
}
|
||||
const settlement = settleStructuredAgentSessionSendAnswer(answer, next.clientMessageId, {
|
||||
firstAttempt,
|
||||
answersProve,
|
||||
journalHasRow: args.journalHasRow?.(next.clientMessageId) ?? false
|
||||
})
|
||||
settleStructuredAgentSessionOutboxEntry(sessionId, next.clientMessageId, settlement)
|
||||
return settlement
|
||||
}
|
||||
|
||||
/** The open chat's drain: one send at a time, released as part of the write that settles it. */
|
||||
export function dispatchStructuredAgentSessionOutboxEntry(args: {
|
||||
next: StructuredAgentSessionOutboxEntry
|
||||
/** The outbox to stage `next` in: the latest, so a hold only memory keeps survives it. */
|
||||
/** The outbox to stage `next` in: the latest. */
|
||||
entries: readonly StructuredAgentSessionOutboxEntry[]
|
||||
sessionId: string
|
||||
target: RuntimeClientTarget
|
||||
@@ -90,82 +222,33 @@ export function dispatchStructuredAgentSessionOutboxEntry(args: {
|
||||
dispatchGeneration: number
|
||||
dispatchGenerationRef: MutableRef<number>
|
||||
inFlightIdRef: MutableRef<string | null>
|
||||
setError: (error: string | null) => void
|
||||
applyDisposition: (disposition: StructuredAgentSessionSendDisposition) => void
|
||||
createOperationId: () => string
|
||||
journalHasRow: (clientMessageId: string) => boolean
|
||||
}): { promise: Promise<boolean>; started: boolean } {
|
||||
const start = async (): Promise<boolean> => {
|
||||
args.inFlightIdRef.current = args.next.clientMessageId
|
||||
const staged = updateStructuredAgentSessionOutboxEntry(
|
||||
args.entries,
|
||||
args.next.clientMessageId,
|
||||
(entry) => stageStructuredAgentSessionOutboxEntryForSend(entry, Date.now())
|
||||
)
|
||||
if (!commitStructuredAgentSessionOutbox(args.sessionId, staged, { onlyIfSaved: true })) {
|
||||
args.inFlightIdRef.current = null
|
||||
// Held for Retry. Saved if storage takes this one write; otherwise only the open chat's
|
||||
// outbox holds it.
|
||||
commitStructuredAgentSessionOutbox(
|
||||
args.sessionId,
|
||||
updateStructuredAgentSessionOutboxEntry(
|
||||
getStructuredAgentSessionOutbox(args.sessionId),
|
||||
args.next.clientMessageId,
|
||||
(entry) => ({ ...entry, lastFailure: { kind: 'failed' } })
|
||||
)
|
||||
)
|
||||
args.setError(agentSessionWriteNoticeText(STRUCTURED_AGENT_SESSION_OUTBOX_NOT_SAVED))
|
||||
return false
|
||||
const isCurrent = (): boolean => args.dispatchGenerationRef.current === args.dispatchGeneration
|
||||
const release = (): void => {
|
||||
if (isCurrent() && args.inFlightIdRef.current === args.next.clientMessageId) {
|
||||
args.inFlightIdRef.current = null
|
||||
}
|
||||
}
|
||||
// No `finally` release below: `applyDisposition` frees single-flight as part of the state
|
||||
// write that re-runs the drain, and a microtask later would leave the queue no trigger.
|
||||
try {
|
||||
const result = await callStructuredAgentSession<
|
||||
AgentSessionMutationResult<AgentSessionSendResult>
|
||||
>(args.target, 'agentSession.send', structuredAgentSessionSendRequest(args.next, args.fence))
|
||||
if (args.dispatchGenerationRef.current !== args.dispatchGeneration) {
|
||||
return false
|
||||
const settlement = await sendStructuredAgentSessionOutboxEntry({
|
||||
next: args.next,
|
||||
entries: args.entries,
|
||||
target: args.target,
|
||||
fence: args.fence,
|
||||
journalHasRow: args.journalHasRow,
|
||||
// Released before the settling write, which is what runs the drain again.
|
||||
isCurrent: () => {
|
||||
const current = isCurrent()
|
||||
if (current) {
|
||||
release()
|
||||
}
|
||||
return current
|
||||
}
|
||||
args.applyDisposition(
|
||||
disposeStructuredAgentSessionSendResult({
|
||||
entries: getStructuredAgentSessionOutbox(args.sessionId),
|
||||
entry: args.next,
|
||||
result,
|
||||
createOperationId: args.createOperationId
|
||||
})
|
||||
)
|
||||
if (!result.ok) {
|
||||
return false
|
||||
}
|
||||
// A queued answer retired the entry; the queue keeps moving.
|
||||
if ('queued' in result.value) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
result.value.submission.dispatchState === 'accepted' ||
|
||||
result.value.submission.dispatchState === 'pending'
|
||||
)
|
||||
} catch (caught) {
|
||||
if (args.dispatchGenerationRef.current !== args.dispatchGeneration) {
|
||||
return false
|
||||
}
|
||||
const input = { entries: getStructuredAgentSessionOutbox(args.sessionId), entry: args.next }
|
||||
const thrown = readAgentSessionErrorRefusal(caught)
|
||||
const refusal = thrown ? agentSessionRefusalFailure(thrown) : undefined
|
||||
args.applyDisposition(
|
||||
refusal
|
||||
? disposeStructuredAgentSessionSendRefusal({
|
||||
...input,
|
||||
refusal,
|
||||
createOperationId: args.createOperationId
|
||||
})
|
||||
: disposeStructuredAgentSessionSendFailure({
|
||||
...input,
|
||||
cause: caught,
|
||||
isDeliveryUnknown: isDesktopDeliveryUnknown
|
||||
})
|
||||
)
|
||||
return false
|
||||
}
|
||||
})
|
||||
release()
|
||||
return settlement?.kind === 'recorded' || settlement?.kind === 'pending'
|
||||
}
|
||||
return args.next.source === 'launch'
|
||||
? shareStructuredAgentLaunchPromptDispatch(
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
// The user's Retry on a stuck outbox entry: requeue it, rotating the operation
|
||||
// id when the recorded one can only ever replay a settled rejection.
|
||||
|
||||
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
structuredAgentSessionEntryIdExpired,
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from '../../../../shared/structured-agent-session-outbox'
|
||||
import {
|
||||
commitStructuredAgentSessionOutbox,
|
||||
getStructuredAgentSessionOutbox
|
||||
} from './structured-agent-session-outbox-storage'
|
||||
import { STRUCTURED_AGENT_SESSION_OUTBOX_NOT_SAVED } from '../../../../shared/structured-agent-session-send-disposition'
|
||||
import { agentSessionWriteNoticeText } from './agent-session-write-notice-text'
|
||||
|
||||
export function retryStructuredAgentSessionOutboxEntry(args: {
|
||||
clientMessageId: string
|
||||
sessionId: string
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
setError: (error: string | null) => void
|
||||
createOperationId: () => string
|
||||
}): void {
|
||||
const { clientMessageId, sessionId, setError, submissions } = args
|
||||
const submission = submissions.find((candidate) => candidate.clientMessageId === clientMessageId)
|
||||
const outbox = getStructuredAgentSessionOutbox(sessionId)
|
||||
const current = outbox.find((entry) => entry.clientMessageId === clientMessageId)
|
||||
// The host settled this id as rejected, and reusing it only replays that forever, so rotate the
|
||||
// id for a safe resend. Read from the message itself, which outlives a restart, or from a
|
||||
// reconciliation that settled an earlier unknown before the outbox caught up. A refusal that
|
||||
// settled the message already rotated it. An expired id is refused for good; its row told the
|
||||
// user to check the chat first.
|
||||
const recordedRejection =
|
||||
current?.state === 'rejected' && current.lastFailure?.kind === 'rejected'
|
||||
if (
|
||||
current &&
|
||||
(recordedRejection ||
|
||||
submission?.dispatchState === 'rejected' ||
|
||||
structuredAgentSessionEntryIdExpired(current))
|
||||
) {
|
||||
const rotated = outbox.map((entry) =>
|
||||
entry.clientMessageId === clientMessageId
|
||||
? {
|
||||
...retriedByUser(entry),
|
||||
clientMessageId: args.createOperationId(),
|
||||
state: 'queued' as const,
|
||||
lastAttemptAt: null,
|
||||
retryAfterUnknownSubmittedAt: null
|
||||
}
|
||||
: entry
|
||||
)
|
||||
if (!commitStructuredAgentSessionOutbox(sessionId, rotated, { onlyIfSaved: true })) {
|
||||
setError(agentSessionWriteNoticeText(STRUCTURED_AGENT_SESSION_OUTBOX_NOT_SAVED))
|
||||
}
|
||||
return
|
||||
}
|
||||
const retryAfterUnknownSubmittedAt =
|
||||
submission?.dispatchState === 'unknown'
|
||||
? submission.submittedAt
|
||||
: current?.state === 'unconfirmed'
|
||||
? -1
|
||||
: null
|
||||
const next = outbox.map((entry) =>
|
||||
entry.clientMessageId === clientMessageId
|
||||
? {
|
||||
...retriedByUser(entry),
|
||||
state: 'queued' as const,
|
||||
retryAfterUnknownSubmittedAt
|
||||
}
|
||||
: entry
|
||||
)
|
||||
if (!commitStructuredAgentSessionOutbox(sessionId, next, { onlyIfSaved: true })) {
|
||||
setError(agentSessionWriteNoticeText(STRUCTURED_AGENT_SESSION_OUTBOX_NOT_SAVED))
|
||||
}
|
||||
}
|
||||
|
||||
/** The user's own Retry is what a Stop, or a failure saved on the message, left it waiting for. */
|
||||
function retriedByUser({
|
||||
outlivedStop: _retried,
|
||||
lastFailure: _sentAgain,
|
||||
...entry
|
||||
}: StructuredAgentSessionOutboxEntry): StructuredAgentSessionOutboxEntry {
|
||||
return entry
|
||||
}
|
||||
+30
-109
@@ -1,16 +1,11 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createStructuredAgentSessionOutboxEntry,
|
||||
type StructuredAgentSessionAttemptFailure
|
||||
} from '../../../../shared/structured-agent-session-outbox'
|
||||
import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
import {
|
||||
hasUndeliveredStructuredAgentSessionOutbox,
|
||||
appendStructuredAgentSessionOutboxMessage,
|
||||
commitStructuredAgentSessionOutbox,
|
||||
getStructuredAgentSessionOutbox,
|
||||
mutateStructuredAgentSessionLaunchPrompt,
|
||||
readOutbox,
|
||||
resetUndeliveredStructuredAgentSessionOutboxForTests,
|
||||
subscribeToStructuredAgentSessionOutbox,
|
||||
@@ -113,102 +108,48 @@ describe('undelivered structured agent session outbox projection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("a saved message's last failure", () => {
|
||||
describe('a message an older build saved', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it.each<StructuredAgentSessionAttemptFailure>([
|
||||
{ kind: 'refused', code: 'agent_session_checkpoint_stale' },
|
||||
{ kind: 'refused', code: 'agent_session_conflict', details: { reason: 'chatStarting' } },
|
||||
{
|
||||
kind: 'refused',
|
||||
code: 'agent_session_ownership_unknown',
|
||||
details: { reason: 'ownerUnproven', ownerVerdict: 'unverifiable' }
|
||||
},
|
||||
{ kind: 'rejected', reason: 'Claude messages support at most 20 images' },
|
||||
{
|
||||
kind: 'rejected',
|
||||
reason: 'Claude accepts at most 20 images in one message, so this message was not sent.',
|
||||
rejection: { kind: 'attachmentInvalid', attachment: { reason: 'tooMany', limit: 20 } }
|
||||
},
|
||||
{ kind: 'rejected', reason: null },
|
||||
{ kind: 'failed' }
|
||||
])('reads back $kind as it was saved', (lastFailure) => {
|
||||
writeOutbox('session-a', [{ ...entry('session-a', 'client-1'), lastFailure }])
|
||||
|
||||
expect(readOutbox('session-a')[0]?.lastFailure).toEqual(lastFailure)
|
||||
})
|
||||
|
||||
it('keeps the message and drops a failure it cannot read', () => {
|
||||
// Each one was held for a Retry this build no longer has: never sent again on its own.
|
||||
it.each([
|
||||
['rejected', { state: 'rejected', lastFailure: { kind: 'rejected', reason: 'Nope.' } }],
|
||||
['held for its Retry', { lastFailure: { kind: 'refused', code: 'agent_session_conflict' } }],
|
||||
['held for a Retry with a failure this build cannot read', { lastFailure: 'restarting' }],
|
||||
['outlived by a Stop', { state: 'unconfirmed', lastAttemptAt: 2, outlivedStop: true }]
|
||||
])('reads back one %s as waiting for its settlement', (_label, saved) => {
|
||||
localStorage.setItem(
|
||||
'orca:desktopStructuredAgentSessionOutbox:v1:session-a',
|
||||
JSON.stringify([
|
||||
{ ...entry('session-a', 'client-1'), lastFailure: 'The agent was restarting.' },
|
||||
{ ...entry('session-a', 'client-2'), lastFailure: { kind: 'rejected', reason: 7 } }
|
||||
])
|
||||
JSON.stringify([{ ...entry('session-a', 'client-1'), ...saved }])
|
||||
)
|
||||
|
||||
const read = readOutbox('session-a')
|
||||
expect(read.map((saved) => saved.clientMessageId)).toEqual(['client-1', 'client-2'])
|
||||
expect(read.every((saved) => saved.lastFailure === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
// Written by a build that kept only the code or the reason; it reads as that build meant it.
|
||||
it('reads an entry saved before failures carried details as it was', () => {
|
||||
localStorage.setItem(
|
||||
'orca:desktopStructuredAgentSessionOutbox:v1:session-a',
|
||||
JSON.stringify([
|
||||
{
|
||||
...entry('session-a', 'client-1'),
|
||||
lastFailure: { kind: 'refused', code: 'agent_session_checkpoint_stale' }
|
||||
},
|
||||
{ ...entry('session-a', 'client-2'), lastFailure: { kind: 'rejected', reason: 'Nope.' } }
|
||||
])
|
||||
)
|
||||
|
||||
expect(readOutbox('session-a').map((saved) => saved.lastFailure)).toEqual([
|
||||
{ kind: 'refused', code: 'agent_session_checkpoint_stale' },
|
||||
{ kind: 'rejected', reason: 'Nope.' }
|
||||
expect(readOutbox('session-a')).toMatchObject([
|
||||
{ clientMessageId: 'client-1', legacyUnsettled: true }
|
||||
])
|
||||
expect(readOutbox('session-a')[0]).not.toHaveProperty('lastFailure')
|
||||
})
|
||||
|
||||
it('keeps only what a newer build saved that this one can place', () => {
|
||||
localStorage.setItem(
|
||||
'orca:desktopStructuredAgentSessionOutbox:v1:session-a',
|
||||
JSON.stringify([
|
||||
{
|
||||
...entry('session-a', 'client-1'),
|
||||
lastFailure: {
|
||||
kind: 'refused',
|
||||
code: 'agent_session_checkpoint_stale',
|
||||
details: { reason: 'fromTheFuture', currentFence: 4 }
|
||||
}
|
||||
},
|
||||
{
|
||||
...entry('session-a', 'client-2'),
|
||||
lastFailure: {
|
||||
kind: 'rejected',
|
||||
reason: 'Claude stopped before it finished starting.',
|
||||
rejection: { kind: 'providerStartFailed', detail: { text: 'exit 1', audience: 'log' } }
|
||||
}
|
||||
},
|
||||
{
|
||||
...entry('session-a', 'client-3'),
|
||||
lastFailure: { kind: 'rejected', reason: 'Nope.', rejection: { kind: 'fromTheFuture' } }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(readOutbox('session-a').map((saved) => saved.lastFailure)).toEqual([
|
||||
{ kind: 'refused', code: 'agent_session_checkpoint_stale' },
|
||||
it("reads back a Stop's stamp with its answer, and drops a malformed one", () => {
|
||||
writeOutbox('session-a', [
|
||||
{
|
||||
kind: 'rejected',
|
||||
reason: 'Claude stopped before it finished starting.',
|
||||
rejection: { kind: 'providerStartFailed' }
|
||||
...entry('session-a', 'client-1'),
|
||||
stoppedBy: { operationId: 'stop-1', cursor: { epoch: 'e', sequence: 4 } }
|
||||
},
|
||||
{ kind: 'rejected', reason: 'Nope.' }
|
||||
{
|
||||
...entry('session-a', 'client-2'),
|
||||
stoppedBy: { operationId: 'stop-2', unanswerable: true }
|
||||
}
|
||||
])
|
||||
localStorage.setItem(
|
||||
'orca:desktopStructuredAgentSessionOutbox:v1:session-b',
|
||||
JSON.stringify([{ ...entry('session-b', 'client-3'), stoppedBy: { cursor: 4 } }])
|
||||
)
|
||||
expect(readOutbox('session-a').map((saved) => saved.stoppedBy)).toEqual([
|
||||
{ operationId: 'stop-1', cursor: { epoch: 'e', sequence: 4 } },
|
||||
{ operationId: 'stop-2', unanswerable: true }
|
||||
])
|
||||
expect(readOutbox('session-b')[0]).not.toHaveProperty('stoppedBy')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -264,24 +205,4 @@ describe('the session outbox store', () => {
|
||||
|
||||
expect(getStructuredAgentSessionOutbox('session-1')).toEqual([])
|
||||
})
|
||||
|
||||
it('lets a launch settlement read its own send as unconfirmed and leaves other sends alone', () => {
|
||||
const release = subscribeToStructuredAgentSessionOutbox('session-1', () => [], vi.fn())
|
||||
commitStructuredAgentSessionOutbox('session-1', [
|
||||
{ ...entry('session-1', 'launch'), state: 'dispatching' },
|
||||
{ ...entry('session-1', 'composer'), state: 'dispatching' }
|
||||
])
|
||||
const seen: string[] = []
|
||||
|
||||
mutateStructuredAgentSessionLaunchPrompt('session-1', 'launch', (current) => {
|
||||
seen.push(current.state)
|
||||
return null
|
||||
})
|
||||
|
||||
expect(seen).toEqual(['unconfirmed'])
|
||||
expect(getStructuredAgentSessionOutbox('session-1')).toMatchObject([
|
||||
{ clientMessageId: 'composer', state: 'dispatching' }
|
||||
])
|
||||
release()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -211,28 +211,3 @@ export function enqueueStructuredAgentSessionLaunchPrompt(
|
||||
export function discardStructuredAgentSessionLaunchOutbox(sessionId: string): void {
|
||||
commitStructuredAgentSessionOutbox(sessionId, [])
|
||||
}
|
||||
|
||||
export function mutateStructuredAgentSessionLaunchPrompt(
|
||||
sessionId: string,
|
||||
clientMessageId: string,
|
||||
update: StructuredAgentSessionLaunchPromptMutation,
|
||||
options: { onlyIfSaved?: boolean } = {}
|
||||
): boolean {
|
||||
let matched = false
|
||||
const next = getStructuredAgentSessionOutbox(sessionId).flatMap((entry) => {
|
||||
if (entry.clientMessageId !== clientMessageId) {
|
||||
return [entry]
|
||||
}
|
||||
matched = true
|
||||
// The settlement reads its own in-flight send as storage recovery would: unconfirmed.
|
||||
const replacement = update(
|
||||
entry.state === 'dispatching' ? { ...entry, state: 'unconfirmed' } : entry
|
||||
)
|
||||
return replacement ? [replacement] : []
|
||||
})
|
||||
return matched && commitStructuredAgentSessionOutbox(sessionId, next, options)
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionLaunchPromptMutation = (
|
||||
entry: StructuredAgentSessionOutboxEntry
|
||||
) => StructuredAgentSessionOutboxEntry | null
|
||||
|
||||
+7
-9
@@ -177,7 +177,6 @@ describe('queued message cards', () => {
|
||||
state: 'queued',
|
||||
queuedAt: 1,
|
||||
lastAttemptAt: null,
|
||||
retryAfterUnknownSubmittedAt: null,
|
||||
...overrides
|
||||
})
|
||||
const ids = (entries: readonly StructuredAgentSessionOutboxEntry[]): string[] =>
|
||||
@@ -188,15 +187,15 @@ describe('queued message cards', () => {
|
||||
]
|
||||
expect(ids(outboxOutsideQueuedCards(inFlight, [], true, QUEUEING))).toEqual([])
|
||||
expect(ids(outboxOutsideQueuedCards(inFlight, [], false, QUEUEING))).toEqual(['a', 'b'])
|
||||
// Refused and held for Retry: its text stays in view, and what follows it is on its way.
|
||||
const refused = [entry('a', { lastFailure: { kind: 'failed' } }), entry('b')]
|
||||
expect(ids(outboxOutsideQueuedCards(refused, [], true, QUEUEING))).toEqual(['a'])
|
||||
// A rejected send holds nothing up: what follows it is still on its way to a card.
|
||||
const rejected = [
|
||||
entry('a', { state: 'rejected', lastFailure: { kind: 'rejected', reason: null } }),
|
||||
// One only an answer already owed settles (a Stop outran it, or an older build held it) stays
|
||||
// in view, and holds nothing up: what follows it is on its way.
|
||||
const stopped = [
|
||||
entry('a', { state: 'dispatching', lastAttemptAt: 2, stoppedBy: { operationId: 'stop-1' } }),
|
||||
entry('b')
|
||||
]
|
||||
expect(ids(outboxOutsideQueuedCards(rejected, [], true, QUEUEING))).toEqual(['a'])
|
||||
expect(ids(outboxOutsideQueuedCards(stopped, [], true, QUEUEING))).toEqual(['a'])
|
||||
const legacy = [entry('a', { legacyUnsettled: true }), entry('b')]
|
||||
expect(ids(outboxOutsideQueuedCards(legacy, [], true, QUEUEING))).toEqual(['a'])
|
||||
const unconfirmed = [entry('a', { state: 'unconfirmed' }), entry('b')]
|
||||
expect(ids(outboxOutsideQueuedCards(unconfirmed, [], true, QUEUEING))).toEqual(['a', 'b'])
|
||||
// Once the host visibly holds it, it is a card whatever this queue last heard.
|
||||
@@ -215,7 +214,6 @@ describe('queued message cards', () => {
|
||||
state: 'queued',
|
||||
queuedAt: 1,
|
||||
lastAttemptAt: null,
|
||||
retryAfterUnknownSubmittedAt: null,
|
||||
...overrides
|
||||
})
|
||||
const ids = (entries: readonly StructuredAgentSessionOutboxEntry[]): string[] =>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
import {
|
||||
admitStructuredAgentSessionOutboxEntry,
|
||||
structuredAgentSessionEntryHeldForRetry
|
||||
structuredAgentSessionEntryAwaitsSettlement
|
||||
} from '../../../../shared/structured-agent-session-outbox-admission'
|
||||
|
||||
/** Why a card is not on its way right now; decides the caption under the text. */
|
||||
@@ -104,8 +104,8 @@ export function newestSteerableQueuedMessageCard(
|
||||
* read from what its request carries — otherwise it paints
|
||||
* in the transcript until the queued answer retires it. A plain send stays a bubble. From
|
||||
* the entry the drain is stopped on (read through the drain's own rule), nothing is on its
|
||||
* way, nor is one held for its Retry: those stay bubbles so their text is visible beside the
|
||||
* Retry row.
|
||||
* way, nor is one only an answer already owed settles: those stay bubbles so their text stays
|
||||
* visible.
|
||||
*/
|
||||
export function outboxOutsideQueuedCards(
|
||||
outbox: readonly StructuredAgentSessionOutboxEntry[],
|
||||
@@ -121,7 +121,7 @@ export function outboxOutsideQueuedCards(
|
||||
isWorking &&
|
||||
(stalledFrom === -1 || index < stalledFrom) &&
|
||||
(entry.state === 'queued' || entry.state === 'dispatching') &&
|
||||
!structuredAgentSessionEntryHeldForRetry(entry) &&
|
||||
!structuredAgentSessionEntryAwaitsSettlement(entry) &&
|
||||
structuredAgentSessionEntryAsksToQueue(entry, host)
|
||||
return !held.has(entry.clientMessageId) && !onItsWay
|
||||
})
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
|
||||
import {
|
||||
clearNativeChatDraftCacheForTests,
|
||||
readNativeChatDraftCache,
|
||||
writeNativeChatDraftCache
|
||||
} from './native-chat-draft-cache'
|
||||
import { readNativeChatAttachmentCache } from './use-native-chat-composer-attachments'
|
||||
import {
|
||||
resetStructuredAgentSessionChatLinesForTests,
|
||||
returnStructuredAgentSessionMessage,
|
||||
setStructuredAgentSessionChatLine,
|
||||
useStructuredAgentSessionChatLine
|
||||
} from './structured-agent-session-returned-send'
|
||||
|
||||
const SCOPE = structuredAgentSessionDraftScopeKey('session-1')
|
||||
|
||||
function entry(text: string, images: string[] = []) {
|
||||
return createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: `id-${text}`,
|
||||
sessionId: 'session-1',
|
||||
text,
|
||||
attachments: images.map((path) => ({ path, previewUri: `file://${path}` })),
|
||||
queuedAt: 1
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
clearNativeChatDraftCacheForTests()
|
||||
resetStructuredAgentSessionChatLinesForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearNativeChatDraftCacheForTests()
|
||||
})
|
||||
|
||||
describe("a message given back to its conversation's draft", () => {
|
||||
it('lands after what is typed, after a blank line, with no composer open', () => {
|
||||
writeNativeChatDraftCache(SCOPE, 'typed')
|
||||
returnStructuredAgentSessionMessage(entry('returned'))
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('typed\n\nreturned')
|
||||
})
|
||||
|
||||
// A crash between the hand-back and the outbox save repeats it; the repeat adds nothing.
|
||||
it('adds nothing the second time, text or images', () => {
|
||||
const message = entry('returned', ['/tmp/a.png'])
|
||||
returnStructuredAgentSessionMessage(message)
|
||||
returnStructuredAgentSessionMessage(message)
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('returned')
|
||||
expect(readNativeChatAttachmentCache(SCOPE).map((image) => image.path)).toEqual(['/tmp/a.png'])
|
||||
})
|
||||
|
||||
it('goes to the conversation, not to any other chat', () => {
|
||||
returnStructuredAgentSessionMessage(entry('mine'))
|
||||
expect(readNativeChatDraftCache(structuredAgentSessionDraftScopeKey('session-2'))).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the chat line', () => {
|
||||
it('says why once, to its own chat, until cleared', () => {
|
||||
const mine = renderHook(() => useStructuredAgentSessionChatLine('session-1'))
|
||||
const other = renderHook(() => useStructuredAgentSessionChatLine('session-2'))
|
||||
act(() => setStructuredAgentSessionChatLine('session-1', ['messageNotSaved', 'tryAgain']))
|
||||
expect(mine.result.current).toBe("Couldn't save your message. Try again.")
|
||||
expect(other.result.current).toBeNull()
|
||||
act(() => setStructuredAgentSessionChatLine('session-1', null))
|
||||
expect(mine.result.current).toBeNull()
|
||||
})
|
||||
|
||||
it('is there for a chat that opens after its words were set', () => {
|
||||
setStructuredAgentSessionChatLine('session-1', ['sendOutcomeLost'])
|
||||
const opened = renderHook(() => useStructuredAgentSessionChatLine('session-1'))
|
||||
expect(opened.result.current).toBe(
|
||||
"Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed."
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
// Where a chat send goes when it comes back to the person: its text and images into the
|
||||
// conversation's draft, after whatever is there, and why, once, on the chat line above the
|
||||
// composer. Keyed by the conversation, not a pane, so an answer that arrives after the tab closed
|
||||
// still lands where the chat is reopened.
|
||||
|
||||
import { useCallback, useSyncExternalStore } from 'react'
|
||||
import type { AgentSessionWriteNoticePart } from '../../../../shared/agent-session-write-notice-copy'
|
||||
import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
|
||||
import { returnNativeChatDraftText } from './native-chat-draft-cache'
|
||||
import { appendNativeChatAttachmentCache } from './use-native-chat-composer-attachments'
|
||||
import { agentSessionWriteNoticeText } from './agent-session-write-notice-text'
|
||||
|
||||
/**
|
||||
* Gives a message's text and images back to its conversation's draft. Called before the entry
|
||||
* leaves the outbox, so a failure between the two repeats the text rather than losing it; the
|
||||
* append skips text and images already there, so a repeat adds nothing.
|
||||
*/
|
||||
export function returnStructuredAgentSessionMessage(
|
||||
entry: StructuredAgentSessionOutboxEntry
|
||||
): void {
|
||||
const scopeKey = structuredAgentSessionDraftScopeKey(entry.sessionId)
|
||||
const blocks = entry.body.blocks
|
||||
returnNativeChatDraftText(
|
||||
scopeKey,
|
||||
blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n')
|
||||
)
|
||||
appendNativeChatAttachmentCache(
|
||||
scopeKey,
|
||||
blocks.flatMap((block, index) =>
|
||||
block.type === 'image-ref' && block.path
|
||||
? [{ id: `returned-${entry.clientMessageId}-${index}`, path: block.path }]
|
||||
: []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
type ChatLine = { text: string | null; listeners: Set<() => void> }
|
||||
|
||||
// Memory only: the words are said once, where the person is; the text itself is in the draft.
|
||||
const chatLines = new Map<string, ChatLine>()
|
||||
|
||||
function chatLine(sessionId: string): ChatLine {
|
||||
let line = chatLines.get(sessionId)
|
||||
if (!line) {
|
||||
line = { text: null, listeners: new Set() }
|
||||
chatLines.set(sessionId, line)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
/** Says why a send of this chat did not go through, or clears the line with null. */
|
||||
export function setStructuredAgentSessionChatLine(
|
||||
sessionId: string,
|
||||
words: readonly AgentSessionWriteNoticePart[] | null
|
||||
): void {
|
||||
const line = chatLine(sessionId)
|
||||
const text = words ? agentSessionWriteNoticeText([...words]) : null
|
||||
if (line.text === text) {
|
||||
return
|
||||
}
|
||||
line.text = text
|
||||
for (const listener of line.listeners) {
|
||||
listener()
|
||||
}
|
||||
if (text === null && line.listeners.size === 0) {
|
||||
chatLines.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/** The chat line's text, and a way to clear it (the person's next send does). */
|
||||
export function useStructuredAgentSessionChatLine(sessionId: string): string | null {
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => {
|
||||
const line = chatLine(sessionId)
|
||||
line.listeners.add(listener)
|
||||
return () => {
|
||||
line.listeners.delete(listener)
|
||||
if (line.listeners.size === 0 && line.text === null && chatLines.get(sessionId) === line) {
|
||||
chatLines.delete(sessionId)
|
||||
}
|
||||
}
|
||||
},
|
||||
[sessionId]
|
||||
)
|
||||
return useSyncExternalStore(subscribe, () => chatLines.get(sessionId)?.text ?? null)
|
||||
}
|
||||
|
||||
export function resetStructuredAgentSessionChatLinesForTests(): void {
|
||||
chatLines.clear()
|
||||
}
|
||||
-475
@@ -1,475 +0,0 @@
|
||||
// A message a Stop withdrew never ran, and nothing sends it again. Its sender gets the text and
|
||||
// images back in the composer, once, after whatever is typed there.
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSubmission
|
||||
} from '../../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
DISPATCH_REJECTED_CANCELLED,
|
||||
DISPATCH_REJECTED_WRITE_FAILED
|
||||
} from '../../../../shared/structured-agent-session-dispatch-rejection'
|
||||
|
||||
type SendParams = { envelope?: { clientOperationId: string } }
|
||||
type ReadState = { submissions: AgentJournalSubmission[]; items: AgentJournalRenderItem[] }
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const read: ReadState = { submissions: [], items: [] }
|
||||
return {
|
||||
call: vi.fn<(target: unknown, method: string, params: SendParams) => Promise<unknown>>(),
|
||||
toastError: vi.fn(),
|
||||
read
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.call,
|
||||
supportsStructuredAgentSessionPromptCancel: vi.fn(async () => false)
|
||||
}))
|
||||
|
||||
vi.mock('./use-structured-agent-session-read', () => ({
|
||||
useStructuredAgentSessionRead: () => ({
|
||||
state: {
|
||||
fence: 1,
|
||||
items: mocks.read.items,
|
||||
submissions: mocks.read.submissions,
|
||||
status: 'ready',
|
||||
error: null,
|
||||
hasOlder: false
|
||||
},
|
||||
loadingOlder: false,
|
||||
loadOlder: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
|
||||
|
||||
import {
|
||||
AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/protocol-version'
|
||||
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
|
||||
import {
|
||||
appendNativeChatDraftCache,
|
||||
clearNativeChatDraftCacheForTests,
|
||||
readNativeChatDraftCache,
|
||||
subscribeToNativeChatDraftAppend,
|
||||
writeNativeChatDraftCache
|
||||
} from './native-chat-draft-cache'
|
||||
import {
|
||||
appendNativeChatAttachmentCache,
|
||||
clearNativeChatAttachmentCacheForTests,
|
||||
readNativeChatAttachmentCache,
|
||||
useNativeChatComposerAttachments
|
||||
} from './use-native-chat-composer-attachments'
|
||||
import { useNativeChatDraft } from './use-native-chat-draft'
|
||||
import {
|
||||
enqueueStructuredAgentSessionLaunchPrompt,
|
||||
readOutbox,
|
||||
writeOutbox
|
||||
} from './structured-agent-session-outbox-storage'
|
||||
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
|
||||
import { useStructuredAgentSession } from './use-structured-agent-session'
|
||||
|
||||
const SESSION = 'session-1'
|
||||
const PANE = 'tab-1::session-1'
|
||||
const OTHER_PANE = 'tab-2::session-1'
|
||||
const target = { kind: 'local' } as const
|
||||
const NONE: AgentJournalSubmission[] = []
|
||||
|
||||
function submission(
|
||||
clientMessageId: string,
|
||||
overrides: Partial<AgentJournalSubmission> = {}
|
||||
): AgentJournalSubmission {
|
||||
return {
|
||||
clientMessageId,
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'pending',
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
submittedAt: 10,
|
||||
resolvedAt: null,
|
||||
handoverRecorded: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function withdrawn(
|
||||
clientMessageId: string,
|
||||
overrides: Partial<AgentJournalSubmission> = {}
|
||||
): AgentJournalSubmission {
|
||||
return submission(clientMessageId, {
|
||||
dispatchState: 'rejected',
|
||||
reason: DISPATCH_REJECTED_CANCELLED,
|
||||
resolvedAt: 11,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
function answerSendsPending(): void {
|
||||
mocks.call.mockImplementation(async (_target, method, params) => {
|
||||
if (method !== 'agentSession.send') {
|
||||
return null
|
||||
}
|
||||
const id = params.envelope?.clientOperationId ?? ''
|
||||
return {
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-1', sequence: 2 },
|
||||
value: { clientMessageId: id, submission: submission(id) }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function renderOutbox(composerScopeKey: string | null = PANE) {
|
||||
return renderHook(
|
||||
(props: { submissions: AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: SESSION,
|
||||
target,
|
||||
fence: 1,
|
||||
submissions: props.submissions,
|
||||
...(composerScopeKey ? { composerScopeKey } : {})
|
||||
}),
|
||||
{ initialProps: { submissions: NONE } }
|
||||
)
|
||||
}
|
||||
|
||||
async function sendToHost(
|
||||
result: { current: ReturnType<typeof useStructuredAgentSessionOutbox> },
|
||||
text: string,
|
||||
attachments: { path: string; previewUri: string }[] = []
|
||||
): Promise<string> {
|
||||
act(() => expect(result.current.send(text, attachments)).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox.at(-1)?.state).toBe('dispatching'))
|
||||
return result.current.outbox.at(-1)!.clientMessageId
|
||||
}
|
||||
|
||||
function occurrences(text: string, of: string): number {
|
||||
return text.split(of).length - 1
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
setLocalRuntimeCapabilitiesForTests(null)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
clearNativeChatDraftCacheForTests()
|
||||
clearNativeChatAttachmentCacheForTests()
|
||||
mocks.read.submissions = []
|
||||
mocks.read.items = []
|
||||
let uuid = 0
|
||||
vi.spyOn(globalThis.crypto, 'randomUUID').mockImplementation(() => {
|
||||
uuid += 1
|
||||
return `11111111-1111-4111-8111-${uuid.toString(16).padStart(12, '0')}`
|
||||
})
|
||||
})
|
||||
|
||||
describe('a message the host withdrew at a Stop', () => {
|
||||
it.each([
|
||||
['a Stop during the start withdrew it from the host queue', {}],
|
||||
['Claude cancelled it as a queued follow-up', { handedOverAt: 12 }]
|
||||
])(
|
||||
'comes back to the composer with its images, after the draft, when %s',
|
||||
async (_case, handover) => {
|
||||
answerSendsPending()
|
||||
writeNativeChatDraftCache(PANE, 'already typed')
|
||||
appendNativeChatAttachmentCache(PANE, [{ id: 'typed', path: '/tmp/typed.png' }])
|
||||
const { result, rerender } = renderOutbox()
|
||||
const id = await sendToHost(result, 'hello', [
|
||||
{ path: '/tmp/shot.png', previewUri: '/tmp/shot.png' }
|
||||
])
|
||||
rerender({ submissions: [submission(id, handover)] })
|
||||
|
||||
rerender({ submissions: [withdrawn(id, handover)] })
|
||||
|
||||
await waitFor(() => expect(result.current.outbox).toEqual([]))
|
||||
expect(readNativeChatDraftCache(PANE)).toBe('already typed\n\nhello')
|
||||
expect(readNativeChatAttachmentCache(PANE)).toEqual([
|
||||
{ id: 'typed', path: '/tmp/typed.png' },
|
||||
{ id: expect.any(String), path: '/tmp/shot.png' }
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('comes back when the host wrote the withdrawal as a typed fact in a sentence', async () => {
|
||||
answerSendsPending()
|
||||
const { result, rerender } = renderOutbox()
|
||||
const id = await sendToHost(result, 'hello')
|
||||
|
||||
rerender({
|
||||
submissions: [
|
||||
withdrawn(id, {
|
||||
reason: 'This message was withdrawn before the agent started it.',
|
||||
rejection: { kind: 'cancelled' }
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.outbox).toEqual([]))
|
||||
expect(readNativeChatDraftCache(PANE)).toBe('hello')
|
||||
})
|
||||
|
||||
it('comes back once, whatever replays the journal or shows the chat again', async () => {
|
||||
answerSendsPending()
|
||||
const first = renderOutbox()
|
||||
const id = await sendToHost(first.result, 'hello')
|
||||
// A second view of the same chat in this window reads the same outbox.
|
||||
const second = renderOutbox(OTHER_PANE)
|
||||
|
||||
first.rerender({ submissions: [withdrawn(id)] })
|
||||
second.rerender({ submissions: [withdrawn(id)] })
|
||||
// A replayed snapshot, then a reconnect remounting the view.
|
||||
first.rerender({ submissions: [{ ...withdrawn(id) }] })
|
||||
first.unmount()
|
||||
renderOutbox().rerender({ submissions: [withdrawn(id)] })
|
||||
|
||||
await waitFor(() => expect(readOutbox(SESSION)).toEqual([]))
|
||||
const restored = readNativeChatDraftCache(PANE) + readNativeChatDraftCache(OTHER_PANE)
|
||||
expect(occurrences(restored, 'hello')).toBe(1)
|
||||
})
|
||||
|
||||
it('is back in the composer before it leaves storage, so a crash between repeats it', async () => {
|
||||
answerSendsPending()
|
||||
const { result, rerender } = renderOutbox()
|
||||
const id = await sendToHost(result, 'hello')
|
||||
let storedAtRestore: string[] = []
|
||||
const unsubscribe = subscribeToNativeChatDraftAppend(PANE, () => {
|
||||
storedAtRestore = readOutbox(SESSION).map((entry) => entry.clientMessageId)
|
||||
})
|
||||
const beforeDrop = readOutbox(SESSION, { recoverDispatching: false })
|
||||
|
||||
rerender({ submissions: [withdrawn(id)] })
|
||||
unsubscribe()
|
||||
|
||||
expect(storedAtRestore).toEqual([id])
|
||||
// The drop never reached storage: the next mount gives the text back again rather than losing it.
|
||||
cleanup()
|
||||
writeOutbox(SESSION, beforeDrop)
|
||||
renderOutbox().rerender({ submissions: [withdrawn(id)] })
|
||||
expect(readNativeChatDraftCache(PANE)).toBe('hello\n\nhello')
|
||||
})
|
||||
|
||||
it('keeps a message refused for any other reason on its Retry, and gives nothing back', async () => {
|
||||
answerSendsPending()
|
||||
const { result, rerender } = renderOutbox()
|
||||
const id = await sendToHost(result, 'hello')
|
||||
|
||||
rerender({
|
||||
submissions: [
|
||||
submission(id, { dispatchState: 'rejected', reason: DISPATCH_REJECTED_WRITE_FAILED })
|
||||
]
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected'))
|
||||
expect(readNativeChatDraftCache(PANE)).toBe('')
|
||||
})
|
||||
|
||||
it('drops a withdrawn launch prompt where no composer shows the chat', () => {
|
||||
const launch = enqueueStructuredAgentSessionLaunchPrompt(SESSION, 'launch text')!
|
||||
const { result, rerender } = renderOutbox(null)
|
||||
|
||||
expect(() => rerender({ submissions: [withdrawn(launch.clientMessageId)] })).not.toThrow()
|
||||
|
||||
expect(result.current.outbox).toEqual([])
|
||||
expect(readOutbox(SESSION)).toEqual([])
|
||||
expect(readNativeChatDraftCache(PANE)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a message a Stop took out of the outbox before the host held it', () => {
|
||||
it('comes back to the composer when the Stop follows the send at once', async () => {
|
||||
mocks.call.mockImplementation(() => new Promise<never>(() => {}))
|
||||
const { result } = renderOutbox()
|
||||
act(() => expect(result.current.send('first')).toBe(true))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1))
|
||||
act(() => expect(result.current.send('second')).toBe(true))
|
||||
|
||||
act(() => result.current.withdrawUnsent())
|
||||
|
||||
expect(result.current.outbox.map((entry) => entry.body.blocks)).toEqual([
|
||||
[{ type: 'text', text: 'first' }]
|
||||
])
|
||||
expect(readNativeChatDraftCache(PANE)).toBe('second')
|
||||
})
|
||||
|
||||
it('gives back a send already on its way only when the host withdraws it', async () => {
|
||||
answerSendsPending()
|
||||
const reply = Promise.withResolvers<void>()
|
||||
const answer = mocks.call.getMockImplementation()!
|
||||
mocks.call.mockImplementationOnce(async (...args) => {
|
||||
await reply.promise
|
||||
return answer(...args)
|
||||
})
|
||||
const { result, rerender } = renderOutbox()
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1))
|
||||
const id = result.current.outbox[0]!.clientMessageId
|
||||
|
||||
act(() => result.current.withdrawUnsent())
|
||||
// It lands ahead of the Stop and reads as sent, not as back in the composer.
|
||||
await act(async () => reply.resolve())
|
||||
rerender({ submissions: [submission(id)] })
|
||||
expect(readNativeChatDraftCache(PANE)).toBe('')
|
||||
expect(result.current.outbox.map((entry) => entry.clientMessageId)).toEqual([id])
|
||||
|
||||
rerender({ submissions: [withdrawn(id)] })
|
||||
|
||||
await waitFor(() => expect(result.current.outbox).toEqual([]))
|
||||
expect(readNativeChatDraftCache(PANE)).toBe('hello')
|
||||
})
|
||||
|
||||
it('leaves a message waiting on Retry where it is, and gives back only what it withdrew', async () => {
|
||||
writeOutbox(SESSION, [
|
||||
{
|
||||
clientMessageId: 'refused-1',
|
||||
sessionId: SESSION,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'refused' }] },
|
||||
previewUris: [],
|
||||
state: 'rejected',
|
||||
queuedAt: 1,
|
||||
lastAttemptAt: null,
|
||||
retryAfterUnknownSubmittedAt: null
|
||||
}
|
||||
])
|
||||
mocks.call.mockImplementation(() => new Promise<never>(() => {}))
|
||||
const { result } = renderOutbox()
|
||||
act(() => expect(result.current.send('second')).toBe(true))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1))
|
||||
act(() => expect(result.current.send('third')).toBe(true))
|
||||
const onItsWay = result.current.outbox[1]!.clientMessageId
|
||||
|
||||
act(() => result.current.withdrawUnsent())
|
||||
|
||||
expect(result.current.outbox.map((entry) => entry.clientMessageId)).toEqual([
|
||||
'refused-1',
|
||||
onItsWay
|
||||
])
|
||||
expect(readNativeChatDraftCache(PANE)).toBe('third')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Stop through the chat', () => {
|
||||
beforeEach(() => {
|
||||
setLocalRuntimeCapabilitiesForTests([
|
||||
AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY
|
||||
])
|
||||
writeOutbox(SESSION, [
|
||||
{
|
||||
clientMessageId: 'queued-1',
|
||||
sessionId: SESSION,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
|
||||
previewUris: [],
|
||||
state: 'dispatching',
|
||||
queuedAt: 1,
|
||||
lastAttemptAt: 1,
|
||||
retryAfterUnknownSubmittedAt: null
|
||||
}
|
||||
])
|
||||
mocks.read.submissions = [submission('queued-1')]
|
||||
})
|
||||
|
||||
function renderChat() {
|
||||
return renderHook(() =>
|
||||
useStructuredAgentSession({
|
||||
sessionId: SESSION,
|
||||
agent: 'claude',
|
||||
target,
|
||||
isVisible: true,
|
||||
composerScopeKey: PANE
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
it('gives back what the host withdrew into the pane that pressed it', async () => {
|
||||
mocks.call.mockImplementation(async (_target, method) =>
|
||||
method === 'agentSession.cancel' ? { ok: true, value: { cancelled: true } } : null
|
||||
)
|
||||
const { result, rerender } = renderChat()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.stop()
|
||||
})
|
||||
mocks.read.submissions = [withdrawn('queued-1')]
|
||||
rerender()
|
||||
|
||||
await waitFor(() => expect(result.current.outbox).toEqual([]))
|
||||
expect(readNativeChatDraftCache(PANE)).toBe('hello')
|
||||
})
|
||||
|
||||
it('says a failed Stop in a toast and gives nothing back', async () => {
|
||||
mocks.call.mockImplementation(async (_target, method) =>
|
||||
method === 'agentSession.cancel'
|
||||
? { ok: false, refusal: { code: 'agent_session_not_found', message: 'Gone.' } }
|
||||
: null
|
||||
)
|
||||
const { result, rerender } = renderChat()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.stop()
|
||||
})
|
||||
rerender()
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledOnce()
|
||||
expect(readNativeChatDraftCache(PANE)).toBe('')
|
||||
expect(result.current.outbox.map((entry) => entry.clientMessageId)).toEqual(['queued-1'])
|
||||
})
|
||||
})
|
||||
|
||||
const notComposing = (): boolean => false
|
||||
|
||||
describe('an open composer', () => {
|
||||
it('shows text put back while it is open, after what is typed', () => {
|
||||
const { result } = renderHook(() => useNativeChatDraft(PANE, notComposing))
|
||||
act(() => result.current.setDraft('typed'))
|
||||
|
||||
act(() => appendNativeChatDraftCache(PANE, 'hello'))
|
||||
|
||||
expect(result.current.draft).toBe('typed\n\nhello')
|
||||
})
|
||||
|
||||
it('keeps text put back mid-composition through the composed writes, even if it unmounts', () => {
|
||||
const { result, unmount } = renderHook(() => useNativeChatDraft(PANE, () => true))
|
||||
act(() => appendNativeChatDraftCache(PANE, 'hello'))
|
||||
act(() => result.current.setDraft('typed'))
|
||||
|
||||
expect(result.current.draft).toBe('typed')
|
||||
unmount()
|
||||
expect(readNativeChatDraftCache(PANE)).toBe('typed\n\nhello')
|
||||
})
|
||||
|
||||
it('shows images put back while it is open, beside the ones attached', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatComposerAttachments({
|
||||
attachmentScopeKey: PANE,
|
||||
allowWithoutTarget: true,
|
||||
caret: 0,
|
||||
disabled: false,
|
||||
isComposing: () => false,
|
||||
resolveTarget: () => null,
|
||||
textareaRef: { current: null },
|
||||
setCaret: () => {},
|
||||
setDraft: () => {},
|
||||
setNotice: () => {}
|
||||
})
|
||||
)
|
||||
act(() => result.current.attachResolvedPaths(['/tmp/typed.png']))
|
||||
|
||||
act(() => appendNativeChatAttachmentCache(PANE, [{ id: 'restored', path: '/tmp/shot.png' }]))
|
||||
|
||||
expect(result.current.imageAttachments.map((attachment) => attachment.path)).toEqual([
|
||||
'/tmp/typed.png',
|
||||
'/tmp/shot.png'
|
||||
])
|
||||
})
|
||||
})
|
||||
-83
@@ -1,83 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
|
||||
import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection'
|
||||
import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
import { appendNativeChatDraftCache } from './native-chat-draft-cache'
|
||||
import { getStructuredAgentSessionOutbox } from './structured-agent-session-outbox-storage'
|
||||
import { appendNativeChatAttachmentCache } from './use-native-chat-composer-attachments'
|
||||
|
||||
/**
|
||||
* Gives the sender back what a Stop withdrew: its text and images go into this pane's composer,
|
||||
* after whatever is there. Called before the entries leave storage, so a failure between the two
|
||||
* repeats the text rather than losing it. Only this client's outbox holds them, so no other viewer
|
||||
* gets them.
|
||||
*/
|
||||
function restoreWithdrawnMessages(
|
||||
sessionId: string,
|
||||
composerScopeKey: string | undefined,
|
||||
withdrawn: readonly StructuredAgentSessionOutboxEntry[]
|
||||
): void {
|
||||
if (!composerScopeKey || withdrawn.length === 0) {
|
||||
return
|
||||
}
|
||||
// What the outbox no longer holds was already given back by whichever view dropped it first.
|
||||
const held = new Set(
|
||||
getStructuredAgentSessionOutbox(sessionId).map((entry) => entry.clientMessageId)
|
||||
)
|
||||
for (const entry of withdrawn) {
|
||||
if (!held.has(entry.clientMessageId)) {
|
||||
continue
|
||||
}
|
||||
const blocks = entry.body.blocks
|
||||
appendNativeChatDraftCache(
|
||||
composerScopeKey,
|
||||
blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n')
|
||||
)
|
||||
appendNativeChatAttachmentCache(
|
||||
composerScopeKey,
|
||||
blocks.flatMap((block, index) =>
|
||||
block.type === 'image-ref' && block.path
|
||||
? [{ id: `withdrawn-${entry.clientMessageId}-${index}`, path: block.path }]
|
||||
: []
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function useStructuredAgentSessionWithdrawnRestore(
|
||||
sessionId: string,
|
||||
/** Absent where no composer shows this session; the entries are then only dropped. */
|
||||
composerScopeKey: string | undefined
|
||||
): {
|
||||
/** The entries the host settled as withdrawn by a Stop. */
|
||||
byHost: (
|
||||
entries: readonly StructuredAgentSessionOutboxEntry[],
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
) => void
|
||||
/** Entries a Stop took out of the outbox here, before the host held them. */
|
||||
byStop: (entries: readonly StructuredAgentSessionOutboxEntry[]) => void
|
||||
} {
|
||||
return useMemo(
|
||||
() => ({
|
||||
byHost: (entries, submissions) => {
|
||||
// A hand-off of a queued draft is never restored: the Stop that withdrew it put the draft
|
||||
// back as a card, which carries the text.
|
||||
const withdrawn = new Set(
|
||||
submissions
|
||||
.filter(
|
||||
(submission) =>
|
||||
dispatchWasWithdrawn(submission) && submission.queuedMessageId === undefined
|
||||
)
|
||||
.map((submission) => submission.clientMessageId)
|
||||
)
|
||||
restoreWithdrawnMessages(
|
||||
sessionId,
|
||||
composerScopeKey,
|
||||
entries.filter((entry) => withdrawn.has(entry.clientMessageId))
|
||||
)
|
||||
},
|
||||
byStop: (entries) => restoreWithdrawnMessages(sessionId, composerScopeKey, entries)
|
||||
}),
|
||||
[composerScopeKey, sessionId]
|
||||
)
|
||||
}
|
||||
+12
-2
@@ -63,7 +63,12 @@ async function sent(
|
||||
blocked: false,
|
||||
startFailures: () => [],
|
||||
recorded: (clientMessageId) => recordedIds.includes(clientMessageId),
|
||||
send: async () => ({ kind: 'done', value: result, operationId: 'op-command' })
|
||||
send: async () => ({
|
||||
kind: 'done',
|
||||
value: result,
|
||||
operationId: 'op-command',
|
||||
cursor: { epoch: 'epoch-1', sequence: 1 }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -183,7 +188,12 @@ describe('the line under the composer after a conversation command failed', () =
|
||||
blocked: false,
|
||||
startFailures: () => [START_FAILED],
|
||||
recorded: () => false,
|
||||
send: async () => ({ kind: 'done', value: result, operationId: 'op-command' })
|
||||
send: async () => ({
|
||||
kind: 'done',
|
||||
value: result,
|
||||
operationId: 'op-command',
|
||||
cursor: { epoch: 'epoch-1', sequence: 1 }
|
||||
})
|
||||
})
|
||||
).toEqual({ accepted: false, error })
|
||||
})
|
||||
|
||||
@@ -53,7 +53,7 @@ function send(structuredTransport: NativeChatStructuredComposerTransport): (text
|
||||
draftScopeKey: 'tab-1:pane',
|
||||
imageAttachments: [],
|
||||
structuredTransport,
|
||||
clearImageAttachments: vi.fn(),
|
||||
isComposing: () => false,
|
||||
clearSkillOrigin: vi.fn(),
|
||||
setHistory: vi.fn(),
|
||||
setDraft: vi.fn(),
|
||||
|
||||
@@ -301,9 +301,10 @@ export function readNativeChatAttachmentCache(
|
||||
return readNativeChatComposerDraft(scopeKey).images.map((image) => ({ ...image }))
|
||||
}
|
||||
|
||||
/** Adds settled images after the ones the draft holds now. Saved at once: when Stop gives images
|
||||
* back, the copy they came from goes right after this. Only an image the user attaches
|
||||
* (`fromUser`) takes the place of a placeholder with its file name, as a re-pick does. */
|
||||
/** Adds settled images after the ones the draft holds now, skipping one it already holds, as a
|
||||
* repeated hand-back does. Saved at once: when Stop gives images back, the copy they came from
|
||||
* goes right after this. Only an image the user attaches (`fromUser`) takes the place of a
|
||||
* placeholder with its file name, as a re-pick does. */
|
||||
export function appendNativeChatAttachmentCache(
|
||||
scopeKey: string,
|
||||
appended: readonly NativeChatComposerImageAttachment[],
|
||||
@@ -319,10 +320,10 @@ export function appendNativeChatAttachmentCache(
|
||||
const placeholder = options?.fromUser
|
||||
? images.findIndex((held) => held.unavailableName === basename(path))
|
||||
: -1
|
||||
if (placeholder === -1) {
|
||||
images.push(image)
|
||||
} else {
|
||||
if (placeholder !== -1) {
|
||||
images[placeholder] = image
|
||||
} else if (!images.some((held) => held.id === id)) {
|
||||
images.push(image)
|
||||
}
|
||||
}
|
||||
updateNativeChatComposerDraft(scopeKey, { images }, 'immediate')
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ function harness(
|
||||
draftScopeKey: 'tab-1:pane',
|
||||
imageAttachments: [ATTACHMENT],
|
||||
structuredTransport,
|
||||
clearImageAttachments: vi.fn(),
|
||||
isComposing: () => false,
|
||||
clearSkillOrigin: vi.fn(),
|
||||
setHistory: vi.fn(),
|
||||
setDraft: vi.fn(),
|
||||
|
||||
+19
-24
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry'
|
||||
import { reportStructuredSessionUserInput } from '@/lib/worker-terminal-takeover-report'
|
||||
import {
|
||||
@@ -12,16 +12,17 @@ import type { NativeChatStructuredComposerTransport } from './native-chat-compos
|
||||
import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
|
||||
import { nativeChatAttachImagesAgainReason } from './native-chat-image-reattach'
|
||||
import {
|
||||
clearNativeChatComposerDraftIfUnchanged,
|
||||
readNativeChatComposerDraft
|
||||
readNativeChatComposerDraft,
|
||||
updateNativeChatComposerDraft
|
||||
} from './native-chat-composer-draft-store'
|
||||
import { nativeChatComposerDraftLeftAfterSend } from './native-chat-composer-draft-comparison'
|
||||
|
||||
export type UseNativeChatStructuredComposerSendArgs = {
|
||||
agent: AgentType
|
||||
draftScopeKey: string
|
||||
imageAttachments: readonly NativeChatComposerImageAttachment[]
|
||||
structuredTransport?: NativeChatStructuredComposerTransport
|
||||
clearImageAttachments: () => void
|
||||
isComposing: () => boolean
|
||||
clearSkillOrigin: () => void
|
||||
setHistory: (updater: (previous: HistoryState) => HistoryState) => void
|
||||
setDraft: (value: string) => void
|
||||
@@ -35,7 +36,7 @@ export function useNativeChatStructuredComposerSend({
|
||||
draftScopeKey,
|
||||
imageAttachments,
|
||||
structuredTransport,
|
||||
clearImageAttachments,
|
||||
isComposing,
|
||||
clearSkillOrigin,
|
||||
setHistory,
|
||||
setDraft,
|
||||
@@ -44,13 +45,6 @@ export function useNativeChatStructuredComposerSend({
|
||||
text: string,
|
||||
attachments?: readonly NativeChatComposerImageAttachment[]
|
||||
) => void {
|
||||
const mounted = useRef(true)
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
return () => {
|
||||
mounted.current = false
|
||||
}
|
||||
}, [])
|
||||
return useCallback(
|
||||
(text: string, attachments = imageAttachments): void => {
|
||||
if (!structuredTransport) {
|
||||
@@ -85,20 +79,21 @@ export function useNativeChatStructuredComposerSend({
|
||||
structuredTransport.runtimeEnvironmentId
|
||||
)
|
||||
setHistory((previous) => pushHistory(previous, text))
|
||||
// Why: a host command settles after a round trip, and a replaced composer's send after
|
||||
// the user may have typed in the new one; either clears only a draft still as sent, and
|
||||
// leaves an image pasted meanwhile, which was never part of it.
|
||||
if (hostCommand || !mounted.current) {
|
||||
if (clearNativeChatComposerDraftIfUnchanged(draftScopeKey, submitted)) {
|
||||
setCaret(0)
|
||||
clearSkillOrigin()
|
||||
}
|
||||
// Why: the send settles after a round trip, while this or another composer of the same
|
||||
// conversation may have changed the draft; only what was sent leaves it.
|
||||
const left = nativeChatComposerDraftLeftAfterSend(
|
||||
readNativeChatComposerDraft(draftScopeKey),
|
||||
submitted
|
||||
)
|
||||
if (!left) {
|
||||
return
|
||||
}
|
||||
setDraft('')
|
||||
setCaret(0)
|
||||
updateNativeChatComposerDraft(draftScopeKey, { images: left.images }, 'immediate')
|
||||
// A live composition owns the field, which keeps only what it composed once cleared.
|
||||
const composing = isComposing()
|
||||
setDraft(composing ? '' : left.text)
|
||||
setCaret(composing ? 0 : left.text.length)
|
||||
clearSkillOrigin()
|
||||
clearImageAttachments()
|
||||
})
|
||||
.catch((error) =>
|
||||
structuredTransport.onError(error instanceof Error ? error.message : String(error))
|
||||
@@ -106,10 +101,10 @@ export function useNativeChatStructuredComposerSend({
|
||||
},
|
||||
[
|
||||
agent,
|
||||
clearImageAttachments,
|
||||
clearSkillOrigin,
|
||||
draftScopeKey,
|
||||
imageAttachments,
|
||||
isComposing,
|
||||
setCaret,
|
||||
setDraft,
|
||||
setHistory,
|
||||
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
// A Stop naming no turn: the sends it outran are stamped with its own id before it goes, its
|
||||
// answer is recorded on them, and a Stop whose answer was lost goes again under the same id until
|
||||
// it meets one, unless a newer message exists, which a resend could stop instead.
|
||||
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createStructuredAgentSessionOutboxEntry,
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from '../../../../shared/structured-agent-session-outbox'
|
||||
import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation'
|
||||
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { useStructuredAgentSessionConversationStop } from './use-structured-agent-session-conversation-stop'
|
||||
import type {
|
||||
StructuredAgentSessionWriteAs,
|
||||
StructuredAgentSessionWriteOutcome
|
||||
} from './use-structured-agent-session-mutate'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
let uuid = 0
|
||||
beforeEach(() => {
|
||||
uuid = 0
|
||||
vi.spyOn(globalThis.crypto, 'randomUUID').mockImplementation(() => {
|
||||
uuid += 1
|
||||
return `11111111-1111-4111-8111-${uuid.toString(16).padStart(12, '0')}`
|
||||
})
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const DONE: StructuredAgentSessionWriteOutcome<unknown> = {
|
||||
kind: 'done',
|
||||
value: { cancelled: true },
|
||||
operationId: 'ignored',
|
||||
cursor: { epoch: 'e', sequence: 9 }
|
||||
}
|
||||
|
||||
function stamped(stopId: string, queuedAt = 1): StructuredAgentSessionOutboxEntry {
|
||||
return {
|
||||
...createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: 'out',
|
||||
sessionId: 'session-1',
|
||||
text: 'out',
|
||||
attachments: [],
|
||||
queuedAt
|
||||
}),
|
||||
state: 'unconfirmed',
|
||||
lastAttemptAt: 1,
|
||||
stoppedBy: { operationId: stopId }
|
||||
}
|
||||
}
|
||||
|
||||
type StopWrite = (stopId: string) => Promise<StructuredAgentSessionWriteOutcome<unknown>>
|
||||
|
||||
function asWriteAs(write: StopWrite): StructuredAgentSessionWriteAs {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the Stop hook calls writeAs only for agentSession.cancel and reads kind, cursor, answered and notice, never the value's type.
|
||||
return write as unknown as StructuredAgentSessionWriteAs
|
||||
}
|
||||
|
||||
function harness(writeAs: StopWrite) {
|
||||
const stopOutbox = vi.fn()
|
||||
const recordStopAnswer = vi.fn()
|
||||
const view = renderHook(
|
||||
(props: {
|
||||
outbox: StructuredAgentSessionOutboxEntry[]
|
||||
submissions?: AgentJournalSubmission[]
|
||||
}) =>
|
||||
useStructuredAgentSessionConversationStop({
|
||||
outbox: props.outbox,
|
||||
submissions: props.submissions ?? [],
|
||||
attached: true,
|
||||
writeAs: asWriteAs(writeAs),
|
||||
stopOutbox,
|
||||
recordStopAnswer
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
outbox: Array.of<StructuredAgentSessionOutboxEntry>(),
|
||||
submissions: Array.of<AgentJournalSubmission>()
|
||||
}
|
||||
}
|
||||
)
|
||||
return { view, stopOutbox, recordStopAnswer }
|
||||
}
|
||||
|
||||
describe('a conversation Stop', () => {
|
||||
it('stamps the outbox with its own id, sends under that id, and records where the journal stood', async () => {
|
||||
const writeAs = vi.fn<StopWrite>(async () => DONE)
|
||||
const { view, stopOutbox, recordStopAnswer } = harness(writeAs)
|
||||
await act(async () => {
|
||||
await view.result.current()
|
||||
})
|
||||
const stopId = stopOutbox.mock.calls[0]?.[0]
|
||||
expect(writeAs).toHaveBeenCalledWith(stopId, 'agentSession.cancel', 'agentSession.cancel', {})
|
||||
expect(recordStopAnswer).toHaveBeenCalledWith(stopId, {
|
||||
kind: 'answered',
|
||||
cursor: { epoch: 'e', sequence: 9 }
|
||||
})
|
||||
})
|
||||
|
||||
it('records a refused Stop as one that will never be answered', async () => {
|
||||
const writeAs = vi.fn<StopWrite>(async () => ({
|
||||
kind: 'not-done',
|
||||
notice: 'No.',
|
||||
answered: true
|
||||
}))
|
||||
const { view, stopOutbox, recordStopAnswer } = harness(writeAs)
|
||||
await act(async () => {
|
||||
await view.result.current()
|
||||
})
|
||||
expect(recordStopAnswer).toHaveBeenCalledWith(stopOutbox.mock.calls[0]?.[0], {
|
||||
kind: 'unanswerable'
|
||||
})
|
||||
})
|
||||
|
||||
it('sends a Stop whose answer was lost again under the same id until it is answered', async () => {
|
||||
const outcomes: StructuredAgentSessionWriteOutcome<unknown>[] = [
|
||||
{ kind: 'not-done', notice: 'Lost.', answered: false },
|
||||
{ kind: 'not-done', notice: 'Lost.', answered: false },
|
||||
DONE
|
||||
]
|
||||
const writeAs = vi.fn<StopWrite>(async () => outcomes.shift() ?? DONE)
|
||||
const { view, stopOutbox, recordStopAnswer } = harness(writeAs)
|
||||
await act(async () => {
|
||||
await view.result.current()
|
||||
})
|
||||
const stopId: string = stopOutbox.mock.calls[0]?.[0]
|
||||
// The outbox now holds a send stamped by it, still owed its answer.
|
||||
view.rerender({ outbox: [stamped(stopId)] })
|
||||
for (let step = 0; step < 10; step += 1) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
})
|
||||
}
|
||||
// Every attempt is the one Stop, never a new one (the mock outbox keeps the stamp owed).
|
||||
expect(writeAs.mock.calls.slice(0, 3).map((call) => call[0])).toEqual([stopId, stopId, stopId])
|
||||
expect(new Set(writeAs.mock.calls.map((call) => call[0]))).toEqual(new Set([stopId]))
|
||||
expect(recordStopAnswer).toHaveBeenCalledWith(stopId, {
|
||||
kind: 'answered',
|
||||
cursor: { epoch: 'e', sequence: 9 }
|
||||
})
|
||||
})
|
||||
|
||||
it('never sends a lost Stop again once a newer message exists: its stamp is given up', async () => {
|
||||
const writeAs = vi.fn<StopWrite>(async () => ({
|
||||
kind: 'not-done',
|
||||
notice: 'Lost.',
|
||||
answered: false
|
||||
}))
|
||||
const { view, stopOutbox, recordStopAnswer } = harness(writeAs)
|
||||
await act(async () => {
|
||||
await view.result.current()
|
||||
})
|
||||
const stopId: string = stopOutbox.mock.calls[0]?.[0]
|
||||
const newer = {
|
||||
...createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: createStructuredAgentSessionOperationId(
|
||||
createBrowserUuid,
|
||||
Date.now() + 1_000
|
||||
),
|
||||
sessionId: 'session-1',
|
||||
text: 'newer',
|
||||
attachments: [],
|
||||
queuedAt: Date.now() + 1
|
||||
})
|
||||
}
|
||||
view.rerender({ outbox: [stamped(stopId), newer] })
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
expect(writeAs).toHaveBeenCalledOnce()
|
||||
expect(recordStopAnswer).toHaveBeenCalledWith(stopId, { kind: 'unanswerable' })
|
||||
})
|
||||
|
||||
it('gives the stamp up too for a newer message another client sent, seen in the journal', async () => {
|
||||
const writeAs = vi.fn<StopWrite>(async () => ({
|
||||
kind: 'not-done',
|
||||
notice: 'Lost.',
|
||||
answered: false
|
||||
}))
|
||||
const { view, stopOutbox, recordStopAnswer } = harness(writeAs)
|
||||
await act(async () => {
|
||||
await view.result.current()
|
||||
})
|
||||
const stopId: string = stopOutbox.mock.calls[0]?.[0]
|
||||
const elsewhere: AgentJournalSubmission = {
|
||||
clientMessageId: createStructuredAgentSessionOperationId(
|
||||
createBrowserUuid,
|
||||
Date.now() + 1_000
|
||||
),
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fp',
|
||||
dispatchState: 'pending',
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
submittedAt: 1,
|
||||
resolvedAt: null
|
||||
}
|
||||
view.rerender({ outbox: [stamped(stopId)], submissions: [elsewhere] })
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
expect(writeAs).toHaveBeenCalledOnce()
|
||||
expect(recordStopAnswer).toHaveBeenCalledWith(stopId, { kind: 'unanswerable' })
|
||||
})
|
||||
})
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
// A Stop naming no turn, and what it means for the sends it outran. Each press has its own
|
||||
// operation id, stamped on those sends before the request goes. The Stop's answer, or its refusal,
|
||||
// is recorded on them; a Stop whose answer was lost goes again under the same id (the host records
|
||||
// an id once), so every stamp meets an answer. It is not sent again once a newer message exists,
|
||||
// since then it could stop that message's turn instead.
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { parseAgentSessionOperationTimestamp } from '../../../../shared/agent-session-host-authority'
|
||||
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
|
||||
import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
import type { StructuredAgentSessionWriteAs } from './use-structured-agent-session-mutate'
|
||||
import {
|
||||
structuredSessionOperationId,
|
||||
type StructuredAgentSessionStopAnswer
|
||||
} from './use-structured-agent-session-outbox'
|
||||
|
||||
const STOP_RESEND_BASE_DELAY_MS = 1_000
|
||||
const STOP_RESEND_MAX_DELAY_MS = 16_000
|
||||
|
||||
/** The oldest Stop still owed an answer by the sends it stamped. */
|
||||
function unansweredStop(outbox: readonly StructuredAgentSessionOutboxEntry[]): string | null {
|
||||
for (const entry of outbox) {
|
||||
const stop = entry.stoppedBy
|
||||
if (stop && !stop.cursor && stop.unanswerable !== true) {
|
||||
return stop.operationId
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Whether a message was sent after this Stop was pressed, from here or another client: by the
|
||||
* time its id was made, so no host clock is compared. */
|
||||
function newerSendExists(
|
||||
outbox: readonly StructuredAgentSessionOutboxEntry[],
|
||||
submissions: readonly AgentJournalSubmission[],
|
||||
stopOperationId: string
|
||||
): boolean {
|
||||
const pressedAt = parseAgentSessionOperationTimestamp(stopOperationId)
|
||||
const madeAfter = (id: string): boolean =>
|
||||
(parseAgentSessionOperationTimestamp(id) ?? -Infinity) > (pressedAt ?? Infinity)
|
||||
return (
|
||||
outbox.some((entry) => madeAfter(entry.clientMessageId)) ||
|
||||
submissions.some((submission) => madeAfter(submission.clientMessageId))
|
||||
)
|
||||
}
|
||||
|
||||
export function useStructuredAgentSessionConversationStop(args: {
|
||||
outbox: readonly StructuredAgentSessionOutboxEntry[]
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
attached: boolean
|
||||
writeAs: StructuredAgentSessionWriteAs
|
||||
stopOutbox: (stopOperationId: string) => void
|
||||
recordStopAnswer: (stopOperationId: string, answer: StructuredAgentSessionStopAnswer) => void
|
||||
}): () => Promise<void> {
|
||||
const { attached, outbox, recordStopAnswer, stopOutbox, submissions, writeAs } = args
|
||||
const inFlight = useRef(new Set<string>())
|
||||
const [resends, setResends] = useState<{ id: string | null; attempts: number }>({
|
||||
id: null,
|
||||
attempts: 0
|
||||
})
|
||||
|
||||
const sendStop = useCallback(
|
||||
async (stopOperationId: string, firstPress: boolean): Promise<void> => {
|
||||
if (inFlight.current.has(stopOperationId)) {
|
||||
return
|
||||
}
|
||||
inFlight.current.add(stopOperationId)
|
||||
try {
|
||||
const outcome = await writeAs(
|
||||
stopOperationId,
|
||||
'agentSession.cancel',
|
||||
'agentSession.cancel',
|
||||
{}
|
||||
)
|
||||
if (outcome.kind === 'done') {
|
||||
recordStopAnswer(stopOperationId, { kind: 'answered', cursor: outcome.cursor })
|
||||
return
|
||||
}
|
||||
if (outcome.kind === 'not-done' && firstPress) {
|
||||
toast.error(outcome.notice)
|
||||
}
|
||||
// A refusal is the host's answer; a lost answer goes again from the effect below.
|
||||
if (outcome.kind === 'dropped' || outcome.answered) {
|
||||
recordStopAnswer(stopOperationId, { kind: 'unanswerable' })
|
||||
}
|
||||
} finally {
|
||||
inFlight.current.delete(stopOperationId)
|
||||
setResends((current) => ({ ...current }))
|
||||
}
|
||||
},
|
||||
[recordStopAnswer, writeAs]
|
||||
)
|
||||
|
||||
const owed = unansweredStop(outbox)
|
||||
const newer = owed !== null && newerSendExists(outbox, submissions, owed)
|
||||
useLayoutEffect(() => {
|
||||
if (owed !== null && newer) {
|
||||
recordStopAnswer(owed, { kind: 'unanswerable' })
|
||||
}
|
||||
}, [newer, owed, recordStopAnswer])
|
||||
|
||||
useEffect(() => {
|
||||
if (owed === null || newer || !attached || inFlight.current.has(owed)) {
|
||||
return
|
||||
}
|
||||
const attempts = resends.id === owed ? resends.attempts : 0
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
setResends({ id: owed, attempts: attempts + 1 })
|
||||
void sendStop(owed, false)
|
||||
},
|
||||
Math.min(STOP_RESEND_BASE_DELAY_MS * 2 ** attempts, STOP_RESEND_MAX_DELAY_MS)
|
||||
)
|
||||
return () => clearTimeout(timer)
|
||||
}, [attached, newer, owed, resends, sendStop])
|
||||
|
||||
return useCallback(async (): Promise<void> => {
|
||||
const stopOperationId = structuredSessionOperationId()
|
||||
stopOutbox(stopOperationId)
|
||||
await sendStop(stopOperationId, true)
|
||||
}, [sendStop, stopOutbox])
|
||||
}
|
||||
+14
-20
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSubmission
|
||||
} from '../../../../shared/agent-session-journal-types'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
import { isRecoveredStructuredAgentSessionSubmission } from '../../../../shared/structured-agent-session-unanswered-dispatch'
|
||||
import type { NativeChatDeliveryNotice } from './NativeChatMessageRow'
|
||||
import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices'
|
||||
import { useStructuredAgentSessionCommandResultRows } from './use-structured-agent-session-command-result-rows'
|
||||
@@ -18,23 +19,17 @@ export function useStructuredAgentSessionDeliveryNotices(input: {
|
||||
journalItems: readonly AgentJournalRenderItem[]
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
outbox: readonly StructuredAgentSessionOutboxEntry[]
|
||||
failedHere: ReadonlySet<string>
|
||||
retry: (clientMessageId: string) => void
|
||||
agentLabel: string
|
||||
/** The agent is working or starting, so a lost outcome may still resolve. */
|
||||
agentActive: boolean
|
||||
}): ReadonlyMap<string, NativeChatDeliveryNotice> {
|
||||
const { outbox, failedHere, agentLabel } = input
|
||||
// Read at click time, so the notices stay put while the outbox's Retry is rebuilt each render.
|
||||
const retryRef = useRef(input.retry)
|
||||
useEffect(() => {
|
||||
retryRef.current = input.retry
|
||||
})
|
||||
const retry = useCallback((clientMessageId: string) => {
|
||||
retryRef.current(clientMessageId)
|
||||
}, [])
|
||||
// Only a row shown as not sent, or a message still in this window's outbox, reads the journal's
|
||||
// rows, so a new batch of them re-renders no row else. Not sent is read from the transcript, not
|
||||
// the outbox: the host's record alone shows one.
|
||||
const hasNotSent = input.messages.some((message) => message.unsent === true)
|
||||
const { agentActive, agentLabel, outbox } = input
|
||||
// Only a message shown as not sent or not confirmed, or one still in this window's outbox,
|
||||
// reads the journal's rows, so a new batch of them re-renders no row else. Read from the
|
||||
// transcript and the journal, not this window's outbox: the host's record alone shows one.
|
||||
const hasNotSent =
|
||||
input.messages.some((message) => message.unsent === true) ||
|
||||
input.submissions.some(isRecoveredStructuredAgentSessionSubmission)
|
||||
const journalRows = hasNotSent || outbox.length > 0 ? input.submissions : NO_SUBMISSIONS
|
||||
const startFailures = useStructuredAgentSessionStartFailureFacts(input.journalItems, hasNotSent)
|
||||
const commandResults = useStructuredAgentSessionCommandResultRows(input.journalItems, hasNotSent)
|
||||
@@ -43,12 +38,11 @@ export function useStructuredAgentSessionDeliveryNotices(input: {
|
||||
structuredAgentSessionDeliveryNotices(
|
||||
outbox,
|
||||
agentLabel,
|
||||
retry,
|
||||
journalRows,
|
||||
startFailures,
|
||||
failedHere,
|
||||
commandResults
|
||||
commandResults,
|
||||
agentActive
|
||||
),
|
||||
[outbox, agentLabel, retry, journalRows, startFailures, failedHere, commandResults]
|
||||
[outbox, agentLabel, journalRows, startFailures, commandResults, agentActive]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { AgentSessionMutationResult } from '../../../../shared/agent-session-wire'
|
||||
import type { AgentJournalCursor } from '../../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
agentSessionRefusalFailure,
|
||||
agentSessionThrownFailure,
|
||||
@@ -27,10 +28,11 @@ import { structuredSessionOperationId } from './use-structured-agent-session-out
|
||||
import { agentSessionWriteFailureText } from './agent-session-write-notice-text'
|
||||
|
||||
export type StructuredAgentSessionWriteOutcome<T> =
|
||||
/** `operationId`: the id the host recorded this write under. */
|
||||
| { kind: 'done'; value: T; operationId: string }
|
||||
/** Refused or failed, with what to tell the person. */
|
||||
| { kind: 'not-done'; notice: string }
|
||||
/** `operationId`: the id the host recorded this write under; `cursor`: where its journal stood. */
|
||||
| { kind: 'done'; value: T; operationId: string; cursor: AgentJournalCursor }
|
||||
/** Refused or failed, with what to tell the person; `answered` when the host returned a refusal,
|
||||
* not when the request threw. */
|
||||
| { kind: 'not-done'; notice: string; answered: boolean }
|
||||
/** Settled for an owner or session this pane no longer shows; there is nothing to say. */
|
||||
| { kind: 'dropped' }
|
||||
|
||||
@@ -40,6 +42,12 @@ export type StructuredAgentSessionWrite = <T>(
|
||||
...args: WriteArgs
|
||||
) => Promise<StructuredAgentSessionWriteOutcome<T>>
|
||||
|
||||
/** A write under an id the caller chose: the same id twice is one write to the host. */
|
||||
export type StructuredAgentSessionWriteAs = <T>(
|
||||
clientOperationId: string,
|
||||
...args: WriteArgs
|
||||
) => Promise<StructuredAgentSessionWriteOutcome<T>>
|
||||
|
||||
/** A write whose failure is shown as a toast; the control that sent it is the way to try again. */
|
||||
export type StructuredAgentSessionMutate = <T>(...args: WriteArgs) => Promise<T | null>
|
||||
|
||||
@@ -63,6 +71,7 @@ export function useStructuredAgentSessionMutate(args: {
|
||||
stateRef: { current: { fence: number | null } }
|
||||
}): {
|
||||
write: StructuredAgentSessionWrite
|
||||
writeAs: StructuredAgentSessionWriteAs
|
||||
mutate: StructuredAgentSessionMutate
|
||||
} {
|
||||
const { enabled = true, sessionId, stateRef, target } = args
|
||||
@@ -85,7 +94,8 @@ export function useStructuredAgentSessionMutate(args: {
|
||||
fingerprintMethod: string,
|
||||
fields: Record<string, unknown>,
|
||||
/** The fence when the press happened, read before any await, so a write never retargets. */
|
||||
pressedFence: number | null = stateRef.current.fence
|
||||
pressedFence: number | null = stateRef.current.fence,
|
||||
clientOperationId: string = structuredSessionOperationId()
|
||||
): Promise<StructuredAgentSessionWriteOutcome<T>> => {
|
||||
if (!enabled || !enabledRef.current || pressedFence === null) {
|
||||
return { kind: 'dropped' }
|
||||
@@ -106,7 +116,6 @@ export function useStructuredAgentSessionMutate(args: {
|
||||
}
|
||||
return enabledRef.current && (stateRef.current.fence === targetFence || waitedOn)
|
||||
}
|
||||
const clientOperationId = structuredSessionOperationId()
|
||||
let result: AgentSessionMutationResult<T>
|
||||
try {
|
||||
result = await callStructuredAgentSession<AgentSessionMutationResult<T>>(target, method, {
|
||||
@@ -126,6 +135,7 @@ export function useStructuredAgentSessionMutate(args: {
|
||||
return settlesHere()
|
||||
? {
|
||||
kind: 'not-done',
|
||||
answered: false,
|
||||
notice: agentSessionWriteFailureText(
|
||||
agentSessionThrownFailure(
|
||||
error,
|
||||
@@ -140,6 +150,7 @@ export function useStructuredAgentSessionMutate(args: {
|
||||
return settlesHere()
|
||||
? {
|
||||
kind: 'not-done',
|
||||
answered: true,
|
||||
notice: agentSessionWriteFailureText(
|
||||
agentSessionRefusalFailure(result.refusal),
|
||||
writeKind(fingerprintMethod, fields)
|
||||
@@ -150,7 +161,12 @@ export function useStructuredAgentSessionMutate(args: {
|
||||
if (!settlesHere()) {
|
||||
return { kind: 'dropped' }
|
||||
}
|
||||
return { kind: 'done', value: result.value, operationId: clientOperationId }
|
||||
return {
|
||||
kind: 'done',
|
||||
value: result.value,
|
||||
operationId: clientOperationId,
|
||||
cursor: result.cursor
|
||||
}
|
||||
},
|
||||
[enabled, sessionId, stateRef, target]
|
||||
)
|
||||
@@ -204,5 +220,14 @@ export function useStructuredAgentSessionMutate(args: {
|
||||
[write]
|
||||
)
|
||||
|
||||
return { write, mutate }
|
||||
const writeAs = useCallback(
|
||||
<T>(
|
||||
clientOperationId: string,
|
||||
...[method, fingerprintMethod, fields]: WriteArgs
|
||||
): Promise<StructuredAgentSessionWriteOutcome<T>> =>
|
||||
send<T>(method, fingerprintMethod, fields, stateRef.current.fence, clientOperationId),
|
||||
[send, stateRef]
|
||||
)
|
||||
|
||||
return { write, writeAs, mutate }
|
||||
}
|
||||
|
||||
+11
-107
@@ -21,10 +21,11 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
|
||||
import { enqueueStructuredAgentSessionLaunchPrompt } from './structured-agent-session-outbox-storage'
|
||||
import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt'
|
||||
import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices'
|
||||
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
|
||||
import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
import { structuredAgentSessionEntryHeldForRetry } from '../../../../shared/structured-agent-session-outbox-admission'
|
||||
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
|
||||
import {
|
||||
clearNativeChatDraftCacheForTests,
|
||||
readNativeChatDraftCache
|
||||
} from './native-chat-draft-cache'
|
||||
|
||||
const LOCAL_TARGET = { kind: 'local' } as const
|
||||
|
||||
@@ -53,13 +54,6 @@ function sentTexts(): (string | undefined)[] {
|
||||
return mocks.call.mock.calls.map((call) => requestText(call[2]))
|
||||
}
|
||||
|
||||
/** The messages that wait for their own Retry. */
|
||||
function heldIds(outbox: readonly StructuredAgentSessionOutboxEntry[]): string[] {
|
||||
return outbox
|
||||
.filter(structuredAgentSessionEntryHeldForRetry)
|
||||
.map((entry) => entry.clientMessageId)
|
||||
}
|
||||
|
||||
function submissionResult(
|
||||
clientMessageId: string,
|
||||
dispatchState: 'pending' | 'accepted',
|
||||
@@ -116,6 +110,7 @@ describe('structured agent session outbox admission', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
clearNativeChatDraftCacheForTests()
|
||||
randomUuidSequence = 0
|
||||
vi.spyOn(globalThis.crypto, 'randomUUID').mockImplementation(() => {
|
||||
randomUuidSequence += 1
|
||||
@@ -164,8 +159,8 @@ describe('structured agent session outbox admission', () => {
|
||||
expect(mocks.call).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// A refused message lands only by its own Retry, so nothing it could be reordered around.
|
||||
it('sends a message past a refused one, which waits for its own Retry', async () => {
|
||||
// A refused message is back in the composer, so nothing waits on it.
|
||||
it('sends a message past a refused one, whose text went back to the draft', async () => {
|
||||
mocks.call.mockImplementation((_target, _method, params) =>
|
||||
Promise.resolve(
|
||||
requestText(params) === 'first'
|
||||
@@ -176,14 +171,13 @@ describe('structured agent session outbox admission', () => {
|
||||
const { result } = renderOutbox()
|
||||
|
||||
act(() => expect(result.current.send('first')).toBe(true))
|
||||
await waitFor(() => expect(heldIds(result.current.outbox)).toHaveLength(1))
|
||||
const refusedId = heldIds(result.current.outbox)[0]
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
|
||||
act(() => expect(result.current.send('second')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(1))
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
|
||||
await settleTimers(50)
|
||||
|
||||
expect(sentTexts()).toEqual(['first', 'second'])
|
||||
expect(heldIds(result.current.outbox)).toEqual([refusedId])
|
||||
expect(readNativeChatDraftCache(structuredAgentSessionDraftScopeKey('session-1'))).toBe('first')
|
||||
})
|
||||
|
||||
it.each(['accepted', 'pending'] as const)(
|
||||
@@ -247,94 +241,4 @@ describe('structured agent session outbox admission', () => {
|
||||
await act(async () => admission.resolve(submissionResult(String(id), 'pending', 10)))
|
||||
expect(sentTexts()).toEqual(['first', 'second'])
|
||||
})
|
||||
|
||||
// A message the queue is held on goes out only by its own Retry, never another message's.
|
||||
it("keeps a refused message held when a rejected message's Retry shows while a head waits", async () => {
|
||||
const inFlight = deferred<ReturnType<typeof submissionResult>>()
|
||||
mocks.call.mockImplementation((_target, _method, params) => {
|
||||
const text = requestText(params)
|
||||
const attempt = sentTexts().filter((sent) => sent === text).length
|
||||
const id = requestId(params)
|
||||
if (text === 'held') {
|
||||
return Promise.resolve(refusedResult('agent_session_ownership_unknown'))
|
||||
}
|
||||
if (attempt === 1) {
|
||||
return Promise.resolve(refusedResult('agent_session_owner_restart_failed'))
|
||||
}
|
||||
return text === 'x' ? inFlight.promise : Promise.resolve(submissionResult(id, 'accepted', 20))
|
||||
})
|
||||
const { result } = renderOutbox()
|
||||
for (const text of ['x', 'a', 'c']) {
|
||||
act(() => expect(result.current.send(text)).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox.at(-1)?.state).toBe('rejected'))
|
||||
}
|
||||
act(() => expect(result.current.send('held')).toBe(true))
|
||||
await waitFor(() => expect(heldIds(result.current.outbox)).toHaveLength(1))
|
||||
const heldId = heldIds(result.current.outbox)[0]
|
||||
const [x, a, c] = result.current.outbox
|
||||
|
||||
// x goes out and stays in flight, so a, retried after it, waits queued ahead of the held one.
|
||||
act(() => result.current.retry(x!.clientMessageId))
|
||||
await waitFor(() => expect(sentTexts()).toEqual(['x', 'a', 'c', 'held', 'x']))
|
||||
act(() => result.current.retry(a!.clientMessageId))
|
||||
await settleTimers(20)
|
||||
// The drain would send a next, so the queue reads as moving and c offers its own Retry.
|
||||
const notices = structuredAgentSessionDeliveryNotices(
|
||||
result.current.outbox,
|
||||
'Claude',
|
||||
() => {},
|
||||
[],
|
||||
[],
|
||||
result.current.failedHere
|
||||
)
|
||||
expect(notices.get(agentJournalSubmissionKey(c!.clientMessageId))?.onRetry).toBeDefined()
|
||||
|
||||
act(() => result.current.retry(c!.clientMessageId))
|
||||
expect(heldIds(result.current.outbox)).toEqual([heldId])
|
||||
await act(async () => inFlight.resolve(submissionResult(x!.clientMessageId, 'accepted', 20)))
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(1))
|
||||
await settleTimers(50)
|
||||
expect(sentTexts().filter((text) => text === 'held')).toHaveLength(1)
|
||||
expect(heldIds(result.current.outbox)).toEqual([heldId])
|
||||
})
|
||||
|
||||
it('keeps a refused message held when an unconfirmed message ahead of it is retried', async () => {
|
||||
const none: readonly AgentJournalSubmission[] = []
|
||||
mocks.call.mockImplementation((_target, _method, params) => {
|
||||
const id = requestId(params)
|
||||
if (requestText(params) === 'held') {
|
||||
return Promise.resolve(refusedResult('agent_session_ownership_unknown'))
|
||||
}
|
||||
const sent = sentTexts().filter((text) => text === 'first').length
|
||||
return Promise.resolve(submissionResult(id, sent === 1 ? 'pending' : 'accepted', 10))
|
||||
})
|
||||
const { result, rerender } = renderHook(
|
||||
({ submissions }: { submissions: readonly AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
submissions
|
||||
}),
|
||||
{ initialProps: { submissions: none } }
|
||||
)
|
||||
act(() => expect(result.current.send('first')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching'))
|
||||
act(() => expect(result.current.send('held')).toBe(true))
|
||||
await waitFor(() => expect(heldIds(result.current.outbox)).toHaveLength(1))
|
||||
const heldId = heldIds(result.current.outbox)[0]
|
||||
const firstId = String(result.current.outbox[0]?.clientMessageId)
|
||||
const unknown: AgentJournalSubmission = {
|
||||
...submissionResult(firstId, 'pending', 10).value.submission,
|
||||
dispatchState: 'unknown'
|
||||
}
|
||||
rerender({ submissions: [unknown] })
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed'))
|
||||
|
||||
act(() => result.current.retry(firstId))
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(1))
|
||||
await settleTimers(50)
|
||||
expect(sentTexts()).toEqual(['first', 'held', 'first'])
|
||||
expect(heldIds(result.current.outbox)).toEqual([heldId])
|
||||
})
|
||||
})
|
||||
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
|
||||
const NONE: ReadonlySet<string> = new Set()
|
||||
|
||||
/**
|
||||
* The messages whose send failed or was refused while this chat was open, in memory only. Only
|
||||
* they word their saved cause: one read back from storage may have outlived it (Orca was updated
|
||||
* since, say), and its Retry, refused again if the cause holds, brings the words back.
|
||||
*/
|
||||
export function useStructuredAgentSessionOutboxFailedHere(sessionId: string): {
|
||||
failedHere: ReadonlySet<string>
|
||||
/** Records the entries a send's answer gave a failure they did not carry before. */
|
||||
recordFailures: (
|
||||
before: readonly StructuredAgentSessionOutboxEntry[],
|
||||
after: readonly StructuredAgentSessionOutboxEntry[]
|
||||
) => void
|
||||
forget: (clientMessageId: string) => void
|
||||
} {
|
||||
const [recorded, setRecorded] = useState({ sessionId, ids: NONE })
|
||||
const recordFailures = useCallback(
|
||||
(
|
||||
before: readonly StructuredAgentSessionOutboxEntry[],
|
||||
after: readonly StructuredAgentSessionOutboxEntry[]
|
||||
): void => {
|
||||
const previous = new Map(before.map((entry) => [entry.clientMessageId, entry.lastFailure]))
|
||||
const failed = after.filter(
|
||||
(entry) =>
|
||||
entry.lastFailure !== undefined &&
|
||||
previous.get(entry.clientMessageId) !== entry.lastFailure
|
||||
)
|
||||
if (failed.length === 0) {
|
||||
return
|
||||
}
|
||||
setRecorded((current) => ({
|
||||
sessionId,
|
||||
ids: new Set([
|
||||
...(current.sessionId === sessionId ? current.ids : NONE),
|
||||
...failed.map((entry) => entry.clientMessageId)
|
||||
])
|
||||
}))
|
||||
},
|
||||
[sessionId]
|
||||
)
|
||||
const forget = useCallback(
|
||||
(clientMessageId: string): void => {
|
||||
setRecorded((current) => {
|
||||
if (current.sessionId !== sessionId || !current.ids.has(clientMessageId)) {
|
||||
return current
|
||||
}
|
||||
const ids = new Set(current.ids)
|
||||
ids.delete(clientMessageId)
|
||||
return { sessionId, ids }
|
||||
})
|
||||
},
|
||||
[sessionId]
|
||||
)
|
||||
return {
|
||||
failedHere: recorded.sessionId === sessionId ? recorded.ids : NONE,
|
||||
recordFailures,
|
||||
forget
|
||||
}
|
||||
}
|
||||
+20
-20
@@ -19,6 +19,8 @@ import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-cap
|
||||
import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt'
|
||||
import { enqueueStructuredAgentSessionLaunchPrompt } from './structured-agent-session-outbox-storage'
|
||||
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
|
||||
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
|
||||
import { readNativeChatDraftCache } from './native-chat-draft-cache'
|
||||
|
||||
// Why: every hook here shares the session outbox store; one left mounted would drain the next test's.
|
||||
afterEach(cleanup)
|
||||
@@ -106,31 +108,28 @@ describe('an outbox on a host that accepts a send before any agent has it', () =
|
||||
expect(mocks.call).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps a failed send held across a fence change; only Retry sends it', async () => {
|
||||
mocks.call.mockRejectedValueOnce(new Error('send failed')).mockResolvedValue({ ok: true })
|
||||
it('keeps a send with no answer in doubt across a fence change, and sends it again under its id', async () => {
|
||||
mocks.call
|
||||
.mockRejectedValueOnce(new Error('send failed'))
|
||||
.mockImplementation(async (_target, _method, params) =>
|
||||
pendingResult(params.envelope.clientOperationId)
|
||||
)
|
||||
const { result, rerender } = render()
|
||||
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox[0]?.lastFailure).toBeDefined())
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed'))
|
||||
rerender({ fence: 2 })
|
||||
await settle()
|
||||
expect(mocks.call).toHaveBeenCalledTimes(1)
|
||||
// The failure stays on the message; the fence change neither clears nor resends it.
|
||||
expect(result.current.outbox[0]?.lastFailure).toEqual({ kind: 'failed' })
|
||||
|
||||
act(() => result.current.retry(result.current.outbox[0]!.clientMessageId))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
// No answer is not a failure: the same id goes again, never a new one.
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2), { timeout: 3000 })
|
||||
expect(sentId(1)).toBe(sentId(0))
|
||||
})
|
||||
|
||||
it('leaves a launch prompt whose staging save failed queued, so the chat still sends it', async () => {
|
||||
mocks.call.mockImplementation(async (_target, _method, params) =>
|
||||
pendingResult(params.envelope.clientOperationId)
|
||||
)
|
||||
it('gives a launch prompt whose staging save failed back to the chat draft, unsent', async () => {
|
||||
const staged = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'launch notes')
|
||||
if (!staged) {
|
||||
throw new Error('fixture outbox entry was not persisted')
|
||||
}
|
||||
const { result, rerender } = render(null)
|
||||
const { result } = render(null)
|
||||
|
||||
const setItem = vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
|
||||
throw new Error('quota exceeded')
|
||||
@@ -144,13 +143,14 @@ describe('an outbox on a host that accepts a send before any agent has it', () =
|
||||
})
|
||||
)
|
||||
setItem.mockRestore()
|
||||
// Unsaved, it could go out where a reload never settles it, so it comes back instead.
|
||||
expect(delivery).toEqual({ delivered: false, failureNotified: false })
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
expect(result.current.outbox).toMatchObject([{ state: 'queued' }])
|
||||
|
||||
rerender({ fence: 1 })
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1))
|
||||
expect(sentId(0)).toBe(staged.clientMessageId)
|
||||
expect(result.current.outbox).toEqual([])
|
||||
expect(readNativeChatDraftCache(structuredAgentSessionDraftScopeKey('session-1'))).toBe(
|
||||
'launch notes'
|
||||
)
|
||||
expect(result.current.error).toBe("Couldn't save your message. Try again.")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+28
-34
@@ -1,8 +1,9 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
// A message the chat said was not sent goes out only on its Retry, on every host, however the
|
||||
// chat's agent owner moves. A send still in flight when the owner moves, on a host not known to
|
||||
// record sends before starting an agent, goes out again under its id.
|
||||
// A message the host refused is back in the composer and never goes out again on its own, however
|
||||
// the chat's agent owner moves; nor does one an older build held for a Retry. A send still in
|
||||
// flight when the owner moves, on a host not known to record sends before starting an agent, goes
|
||||
// out again under its id.
|
||||
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -27,11 +28,7 @@ vi.mock('@/runtime/runtime-host-contact-regained', () => ({
|
||||
}))
|
||||
|
||||
import type { AgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire'
|
||||
import {
|
||||
createStructuredAgentSessionOutboxEntry,
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from '../../../../shared/structured-agent-session-outbox'
|
||||
import { writeOutbox } from './structured-agent-session-outbox-storage'
|
||||
import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
|
||||
|
||||
type SendRequest = { envelope?: { clientOperationId?: string; expectedRuntimeFence?: number } }
|
||||
@@ -57,10 +54,8 @@ function sentAttempts(): string[] {
|
||||
|
||||
const QUEUED = { ok: true, replayed: false, fence: 2, value: { queued: {} } }
|
||||
|
||||
function heldEntry(
|
||||
clientMessageId: string,
|
||||
code: AgentSessionWireRefusalCode
|
||||
): StructuredAgentSessionOutboxEntry {
|
||||
/** Saved by an older build, held for its Retry under an id already sent once. */
|
||||
function heldEntry(clientMessageId: string, code: AgentSessionWireRefusalCode): unknown {
|
||||
return {
|
||||
...createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId,
|
||||
@@ -74,6 +69,13 @@ function heldEntry(
|
||||
}
|
||||
}
|
||||
|
||||
function writeSaved(entries: unknown[]): void {
|
||||
localStorage.setItem(
|
||||
'orca:desktopStructuredAgentSessionOutbox:v1:session-1',
|
||||
JSON.stringify(entries)
|
||||
)
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await act(() => new Promise<void>((resolve) => setTimeout(resolve, 50)))
|
||||
}
|
||||
@@ -102,11 +104,12 @@ async function refusedOnce(code: string) {
|
||||
act(() => void hook.result.current.send('hello'))
|
||||
await settle()
|
||||
expect(sentIds()).toHaveLength(1)
|
||||
expect(hook.result.current.outbox[0]?.lastFailure).toBeDefined()
|
||||
// A first attempt the host refused proves no record: it is back in the composer.
|
||||
expect(hook.result.current.outbox).toEqual([])
|
||||
return hook
|
||||
}
|
||||
|
||||
describe('a refused message when the chat gets a new owner', () => {
|
||||
describe('a refused or held message when the chat gets a new owner', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
mocks.call.mockReset()
|
||||
@@ -125,7 +128,6 @@ describe('a refused message when the chat gets a new owner', () => {
|
||||
act(() => mocks.regained.forEach((listener) => listener()))
|
||||
await settle()
|
||||
expect(sentIds()).toHaveLength(1)
|
||||
expect(hook.result.current.outbox[0]?.lastFailure).toBeDefined()
|
||||
})
|
||||
|
||||
it('is not sent while the capability probe has not answered', async () => {
|
||||
@@ -145,41 +147,33 @@ describe('a refused message when the chat gets a new owner', () => {
|
||||
'agent_session_journal_unreadable',
|
||||
'agent_session_operation_capacity'
|
||||
] as const)(
|
||||
'waits for its Retry on a host known to be older when the owner moves, after %s',
|
||||
'is never sent when an older build held it after %s, on a host known to be older, when the owner moves',
|
||||
async (code) => {
|
||||
mocks.probe.mockResolvedValue(false)
|
||||
// Held under an id already sent once, so its Retry keeps the id.
|
||||
writeOutbox('session-1', [heldEntry('op-held', code)])
|
||||
writeSaved([heldEntry('op-held', code)])
|
||||
mocks.call.mockResolvedValue(QUEUED)
|
||||
const hook = mount(1)
|
||||
await settle()
|
||||
hook.rerender({ fence: 2 })
|
||||
await settle()
|
||||
expect(sentIds()).toEqual([])
|
||||
expect(hook.result.current.outbox[0]?.lastFailure).toBeDefined()
|
||||
|
||||
act(() => hook.result.current.retry('op-held'))
|
||||
await settle()
|
||||
expect(sentIds()).toEqual(['op-held'])
|
||||
expect(hook.result.current.outbox).toEqual([])
|
||||
expect(hook.result.current.outbox).toMatchObject([
|
||||
{ clientMessageId: 'op-held', legacyUnsettled: true }
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('waits for its Retry on a host known to be older when the send never reached it', async () => {
|
||||
it('sends again under its id a send with no answer, on a host known to be older, once the owner moves', async () => {
|
||||
mocks.probe.mockResolvedValue(false)
|
||||
mocks.call.mockRejectedValueOnce(new Error('send failed')).mockResolvedValue(QUEUED)
|
||||
const hook = mount(1)
|
||||
await settle()
|
||||
act(() => void hook.result.current.send('hello'))
|
||||
await settle()
|
||||
expect(hook.result.current.outbox[0]?.lastFailure).toEqual({ kind: 'failed' })
|
||||
hook.rerender({ fence: 2 })
|
||||
await settle()
|
||||
expect(sentIds()).toHaveLength(1)
|
||||
|
||||
act(() => hook.result.current.retry(hook.result.current.outbox[0]!.clientMessageId))
|
||||
await settle()
|
||||
expect(hook.result.current.outbox[0]?.state).toBe('unconfirmed')
|
||||
await act(() => new Promise<void>((resolve) => setTimeout(resolve, 1200)))
|
||||
expect(sentIds()).toHaveLength(2)
|
||||
expect(new Set(sentIds()).size).toBe(1)
|
||||
expect(hook.result.current.outbox).toEqual([])
|
||||
})
|
||||
|
||||
@@ -212,7 +206,7 @@ describe('a refused message when the chat gets a new owner', () => {
|
||||
|
||||
it('sends the send in flight again on a new owner, but not a message it refused', async () => {
|
||||
mocks.probe.mockResolvedValue(false)
|
||||
writeOutbox('session-1', [heldEntry('op-held', 'agent_session_checkpoint_stale')])
|
||||
writeSaved([heldEntry('op-held', 'agent_session_checkpoint_stale')])
|
||||
mocks.call.mockReturnValueOnce(new Promise(() => {})).mockResolvedValue(QUEUED)
|
||||
const hook = mount(1)
|
||||
await settle()
|
||||
@@ -226,7 +220,7 @@ describe('a refused message when the chat gets a new owner', () => {
|
||||
await settle()
|
||||
expect(sentAttempts()).toEqual([`${first}@1`, `${first}@2`, `${second}@2`])
|
||||
expect(hook.result.current.outbox).toMatchObject([
|
||||
{ clientMessageId: 'op-held', lastFailure: { code: 'agent_session_checkpoint_stale' } }
|
||||
{ clientMessageId: 'op-held', legacyUnsettled: true }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
// Who owns an outbox entry's text when it leaves this client's queue without a
|
||||
// send answer. A Stop hands unsent text back to the composer — a local move; no
|
||||
// text crosses a wire. A host that visibly holds the entry as a queued draft
|
||||
// (same id) owns it: those entries retire with no local restore, so the same
|
||||
// words can never come back twice.
|
||||
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
|
||||
import { withdrawUnsentStructuredAgentSessionOutboxEntries } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal'
|
||||
import {
|
||||
commitStructuredAgentSessionOutbox,
|
||||
getStructuredAgentSessionOutbox
|
||||
} from './structured-agent-session-outbox-storage'
|
||||
import type { useStructuredAgentSessionWithdrawnRestore } from './structured-agent-session-withdrawn-message-restore'
|
||||
|
||||
export function useStructuredAgentSessionOutboxOwnership(args: {
|
||||
sessionId: string
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
/** Ids of the host's published drafts; an entry with one of these ids is host-owned. */
|
||||
queuedMessageIds: readonly string[] | undefined
|
||||
/** The send in flight and its generation: a host that holds that send answered it. */
|
||||
inFlightIdRef: { current: string | null }
|
||||
dispatchGenerationRef: { current: number }
|
||||
restoreWithdrawn: ReturnType<typeof useStructuredAgentSessionWithdrawnRestore>
|
||||
}): {
|
||||
/** Stop's local step, before its RPC, so the drain has nothing left to send after it. */
|
||||
withdrawUnsent: () => void
|
||||
} {
|
||||
const { queuedMessageIds, restoreWithdrawn, sessionId } = args
|
||||
const { dispatchGenerationRef, inFlightIdRef, submissions } = args
|
||||
|
||||
const withdrawUnsent = useCallback((): void => {
|
||||
const current = getStructuredAgentSessionOutbox(sessionId)
|
||||
const next = withdrawUnsentStructuredAgentSessionOutboxEntries(
|
||||
current,
|
||||
submissions,
|
||||
inFlightIdRef.current
|
||||
)
|
||||
if (next.length === current.length && next.every((entry, index) => entry === current[index])) {
|
||||
return
|
||||
}
|
||||
// By id: a kept entry comes back marked, as a new object.
|
||||
const kept = new Set(next.map((entry) => entry.clientMessageId))
|
||||
restoreWithdrawn.byStop(current.filter((entry) => !kept.has(entry.clientMessageId)))
|
||||
commitStructuredAgentSessionOutbox(sessionId, next)
|
||||
}, [inFlightIdRef, restoreWithdrawn, sessionId, submissions])
|
||||
|
||||
// Drop host-owned entries without a restore: the published card is the text now.
|
||||
const retire = useCallback(
|
||||
(ids: readonly string[]): void => {
|
||||
const owned = new Set(ids)
|
||||
// Like a journal row answering it: free single-flight and void the unsettled send's reply.
|
||||
if (inFlightIdRef.current !== null && owned.has(inFlightIdRef.current)) {
|
||||
dispatchGenerationRef.current += 1
|
||||
inFlightIdRef.current = null
|
||||
}
|
||||
const current = getStructuredAgentSessionOutbox(sessionId)
|
||||
const next = current.filter((entry) => !owned.has(entry.clientMessageId))
|
||||
if (next.length !== current.length) {
|
||||
commitStructuredAgentSessionOutbox(sessionId, next)
|
||||
}
|
||||
},
|
||||
[dispatchGenerationRef, inFlightIdRef, sessionId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (queuedMessageIds && queuedMessageIds.length > 0) {
|
||||
retire(queuedMessageIds)
|
||||
}
|
||||
}, [queuedMessageIds, retire])
|
||||
|
||||
return { withdrawUnsent }
|
||||
}
|
||||
-543
@@ -1,543 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
|
||||
import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
call: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.call
|
||||
}))
|
||||
|
||||
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
|
||||
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
|
||||
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
|
||||
import { agentSessionWriteNoticeEnglish } from '../../../../shared/agent-session-refusal-notice'
|
||||
import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition'
|
||||
import {
|
||||
createStructuredAgentSessionOutboxEntry,
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from '../../../../shared/structured-agent-session-outbox'
|
||||
import { writeOutbox } from './structured-agent-session-outbox-storage'
|
||||
|
||||
// Why: every hook here shares the session outbox store; one left mounted would drain the next test's.
|
||||
afterEach(cleanup)
|
||||
|
||||
function shownFailure(entry: StructuredAgentSessionOutboxEntry | undefined): string | undefined {
|
||||
return (
|
||||
entry?.lastFailure &&
|
||||
agentSessionWriteNoticeEnglish(structuredAgentSessionAttemptFailureParts(entry.lastFailure))
|
||||
)
|
||||
}
|
||||
|
||||
// What the host answers when the child it restarted for this send died before starting.
|
||||
const REASON =
|
||||
'The provider stopped before it finished starting: claude stream-json exited (code 1): claude: not signed in.'
|
||||
|
||||
function acceptedResultFor(clientMessageId: string) {
|
||||
return {
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-1', sequence: 2 },
|
||||
value: {
|
||||
clientMessageId,
|
||||
submission: {
|
||||
clientMessageId,
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'accepted',
|
||||
providerItemId: `provider-${clientMessageId}`,
|
||||
reason: null,
|
||||
submittedAt: 1,
|
||||
resolvedAt: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rejectedResultFor(clientMessageId: string) {
|
||||
return {
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 3,
|
||||
cursor: { epoch: 'epoch-1', sequence: 4 },
|
||||
value: {
|
||||
clientMessageId,
|
||||
submission: {
|
||||
clientMessageId,
|
||||
fence: 3,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'rejected',
|
||||
providerItemId: null,
|
||||
reason: REASON,
|
||||
submittedAt: 10,
|
||||
resolvedAt: 11
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('a send the host rejected because the agent never started', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('names the cause on the message and keeps it for Retry', async () => {
|
||||
mocks.call.mockImplementationOnce(
|
||||
async (
|
||||
_target: unknown,
|
||||
_method: unknown,
|
||||
params: { envelope: { clientOperationId: string } }
|
||||
) => rejectedResultFor(params.envelope.clientOperationId)
|
||||
)
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: { kind: 'local' },
|
||||
fence: 1,
|
||||
submissions: []
|
||||
})
|
||||
)
|
||||
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
|
||||
await waitFor(() => expect(shownFailure(result.current.outbox[0])).toBe(REASON))
|
||||
// Settled as not delivered: it waits for Retry and holds no later message up.
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(result.current.outbox[0]?.state).toBe('rejected')
|
||||
})
|
||||
|
||||
it('sends a new message past one the host could not start the agent for, without resending it', async () => {
|
||||
const message = "Claude couldn't restart: Not logged in. Please run /login."
|
||||
mocks.call.mockImplementation(async (_target, _method, params) => {
|
||||
const request = params as {
|
||||
envelope: { clientOperationId: string }
|
||||
body: { blocks: { text?: string }[] }
|
||||
}
|
||||
return request.body.blocks[0]?.text === 'first'
|
||||
? { ok: false, refusal: { code: 'agent_session_owner_restart_failed', message } }
|
||||
: acceptedResultFor(request.envelope.clientOperationId)
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: { kind: 'local' },
|
||||
fence: 1,
|
||||
submissions: []
|
||||
})
|
||||
)
|
||||
|
||||
act(() => expect(result.current.send('first')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected'))
|
||||
const rejectedId = result.current.outbox[0]!.clientMessageId
|
||||
|
||||
// The user's next message is the retry of the start: it goes out on its own.
|
||||
act(() => expect(result.current.send('second')).toBe(true))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 50)))
|
||||
|
||||
const sent = mocks.call.mock.calls.map(
|
||||
(call) => (call[2] as { body?: { blocks?: { text?: string }[] } })?.body?.blocks?.[0]?.text
|
||||
)
|
||||
expect(sent).toEqual(['first', 'second'])
|
||||
expect(result.current.outbox.map((entry) => [entry.clientMessageId, entry.state])).toEqual([
|
||||
[rejectedId, 'rejected']
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a message the host accepted and then could not deliver, with its reason and Retry', async () => {
|
||||
const reason = "Codex couldn't restart: spawn codex ENOENT."
|
||||
mocks.call.mockImplementation(
|
||||
async (
|
||||
_target: unknown,
|
||||
_method: unknown,
|
||||
params: { envelope: { clientOperationId: string } }
|
||||
) => pendingResultFor(params.envelope.clientOperationId)
|
||||
)
|
||||
const target = { kind: 'local' } as const
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { submissions: AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target,
|
||||
fence: 1,
|
||||
submissions: props.submissions
|
||||
}),
|
||||
{ initialProps: { submissions: NO_SUBMISSIONS } }
|
||||
)
|
||||
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching'))
|
||||
const id = result.current.outbox[0]!.clientMessageId
|
||||
|
||||
rerender({
|
||||
submissions: [{ ...pendingResultFor(id).value.submission, dispatchState: 'rejected', reason }]
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected'))
|
||||
expect(shownFailure(result.current.outbox[0])).toBe(reason)
|
||||
expect(result.current.error).toBeNull()
|
||||
|
||||
// Retry is a new message with the same text: a fresh id, sent once.
|
||||
act(() => result.current.retry(id))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
const retried: {
|
||||
envelope: { clientOperationId: string }
|
||||
body: { blocks: { text?: string }[] }
|
||||
} = mocks.call.mock.calls[1]![2]
|
||||
expect(retried.envelope.clientOperationId).not.toBe(id)
|
||||
expect(retried.body.blocks[0]?.text).toBe('hello')
|
||||
})
|
||||
|
||||
it('says nothing when a Stop withdrew the message', async () => {
|
||||
mocks.call.mockImplementation(
|
||||
async (
|
||||
_target: unknown,
|
||||
_method: unknown,
|
||||
params: { envelope: { clientOperationId: string } }
|
||||
) => pendingResultFor(params.envelope.clientOperationId)
|
||||
)
|
||||
const target = { kind: 'local' } as const
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { submissions: AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target,
|
||||
fence: 1,
|
||||
submissions: props.submissions
|
||||
}),
|
||||
{ initialProps: { submissions: NO_SUBMISSIONS } }
|
||||
)
|
||||
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching'))
|
||||
const id = result.current.outbox[0]!.clientMessageId
|
||||
|
||||
rerender({
|
||||
submissions: [
|
||||
{
|
||||
...pendingResultFor(id).value.submission,
|
||||
dispatchState: 'rejected',
|
||||
reason: DISPATCH_REJECTED_CANCELLED
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.outbox).toEqual([]))
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('reads a message rejected while the chat was closed as not sent, and sends past it', async () => {
|
||||
const reason = "Codex couldn't restart: spawn codex ENOENT."
|
||||
mocks.call.mockImplementation(
|
||||
async (
|
||||
_target: unknown,
|
||||
_method: unknown,
|
||||
params: { envelope: { clientOperationId: string } }
|
||||
) => pendingResultFor(params.envelope.clientOperationId)
|
||||
)
|
||||
const target = { kind: 'local' } as const
|
||||
const first = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target,
|
||||
fence: 1,
|
||||
submissions: NO_SUBMISSIONS
|
||||
})
|
||||
)
|
||||
act(() => expect(first.result.current.send('hello')).toBe(true))
|
||||
await waitFor(() => expect(first.result.current.outbox[0]?.state).toBe('dispatching'))
|
||||
const id = first.result.current.outbox[0]!.clientMessageId
|
||||
first.unmount()
|
||||
|
||||
// Reopened after the start failed, or after a quit settled the message as not sent.
|
||||
const rejected = [
|
||||
{ ...pendingResultFor(id).value.submission, dispatchState: 'rejected' as const, reason }
|
||||
]
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target,
|
||||
fence: 1,
|
||||
submissions: rejected
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected'))
|
||||
expect(shownFailure(result.current.outbox[0])).toBe(reason)
|
||||
act(() => expect(result.current.send('second')).toBe(true))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
// After a restart nothing in memory remembers the rejection, and its journal row may be older
|
||||
// than the loaded page: the message's own state is what says a resend needs a new id.
|
||||
it('retries a message rejected before a restart under a new id', async () => {
|
||||
const rejected = createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: 'rejected-before-restart',
|
||||
sessionId: 'session-1',
|
||||
text: 'first',
|
||||
attachments: [],
|
||||
queuedAt: 1
|
||||
})
|
||||
writeOutbox('session-1', [
|
||||
{
|
||||
...rejected,
|
||||
state: 'rejected',
|
||||
lastFailure: {
|
||||
kind: 'rejected',
|
||||
reason: 'The provider did not accept this message.',
|
||||
rejection: { kind: 'providerRejected' }
|
||||
}
|
||||
}
|
||||
])
|
||||
mocks.call.mockImplementation(async (_target, _method, params) =>
|
||||
acceptedResultFor(String(params.envelope.clientOperationId))
|
||||
)
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: { kind: 'local' },
|
||||
fence: 1,
|
||||
submissions: []
|
||||
})
|
||||
)
|
||||
expect(result.current.outbox[0]?.state).toBe('rejected')
|
||||
|
||||
act(() => result.current.retry('rejected-before-restart'))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
|
||||
const sentId: unknown = mocks.call.mock.calls[0]![2].envelope.clientOperationId
|
||||
expect(sentId).not.toBe('rejected-before-restart')
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
|
||||
})
|
||||
|
||||
it('keeps the rejection when the journal settles the message before the send answers', async () => {
|
||||
const reason = "Codex couldn't restart: spawn codex ENOENT."
|
||||
let answer: (value: unknown) => void = () => undefined
|
||||
mocks.call.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
answer = resolve
|
||||
})
|
||||
)
|
||||
const target = { kind: 'local' } as const
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { submissions: AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target,
|
||||
fence: 1,
|
||||
submissions: props.submissions
|
||||
}),
|
||||
{ initialProps: { submissions: NO_SUBMISSIONS } }
|
||||
)
|
||||
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching'))
|
||||
const id = result.current.outbox[0]!.clientMessageId
|
||||
|
||||
// A start refused at once: the rejection frame lands before the send's own `pending` answer.
|
||||
rerender({
|
||||
submissions: [{ ...pendingResultFor(id).value.submission, dispatchState: 'rejected', reason }]
|
||||
})
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected'))
|
||||
await act(async () => answer(pendingResultFor(id)))
|
||||
|
||||
expect(result.current.outbox[0]?.state).toBe('rejected')
|
||||
expect(shownFailure(result.current.outbox[0])).toBe(reason)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
const NO_SUBMISSIONS: AgentJournalSubmission[] = []
|
||||
|
||||
function pendingResultFor(clientMessageId: string) {
|
||||
const submission: AgentJournalSubmission = {
|
||||
clientMessageId,
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'pending',
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
submittedAt: 1,
|
||||
resolvedAt: null,
|
||||
handoverRecorded: true
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-1', sequence: 2 },
|
||||
value: {
|
||||
clientMessageId,
|
||||
submission
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stable across renders, as a mounted pane's target is.
|
||||
const LOCAL_TARGET = { kind: 'local' } as const
|
||||
|
||||
function submission(
|
||||
clientMessageId: string,
|
||||
dispatchState: 'pending' | 'accepted'
|
||||
): AgentJournalSubmission {
|
||||
return {
|
||||
clientMessageId,
|
||||
fence: 3,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState,
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
submittedAt: 10,
|
||||
resolvedAt: dispatchState === 'accepted' ? 11 : null
|
||||
}
|
||||
}
|
||||
|
||||
// An older host restarts the agent inside the send. A send it refused was shown as not sent, so it
|
||||
// waits for the user's Retry even once the agent has a new owner, as on every host.
|
||||
describe('a send refused while its agent restarted', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
setLocalRuntimeCapabilitiesForTests([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setLocalRuntimeCapabilitiesForTests(null)
|
||||
})
|
||||
|
||||
// The live order: the restart takes seconds, so the journal settles the resend before its reply.
|
||||
it('waits for its Retry across an agent restart, and leaves no error once that Retry lands', async () => {
|
||||
mocks.call
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'agent_session_checkpoint_stale',
|
||||
message: 'Expected runtime fence 1; the session is at 3.'
|
||||
}
|
||||
})
|
||||
.mockReturnValueOnce(new Promise(() => {}))
|
||||
const { result, rerender } = renderHook(
|
||||
({ fence, submissions }: { fence: number; submissions: AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence,
|
||||
submissions
|
||||
}),
|
||||
{ initialProps: { fence: 1, submissions: NO_SUBMISSIONS } }
|
||||
)
|
||||
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
await waitFor(() =>
|
||||
expect(shownFailure(result.current.outbox[0])).toBe('Your message was not sent.')
|
||||
)
|
||||
|
||||
// The pane learns the new owner; the message still waits for its Retry.
|
||||
rerender({ fence: 3, submissions: [] })
|
||||
await act(() => new Promise<void>((resolve) => setTimeout(resolve, 50)))
|
||||
expect(mocks.call).toHaveBeenCalledTimes(1)
|
||||
expect(shownFailure(result.current.outbox[0])).toBe('Your message was not sent.')
|
||||
|
||||
act(() => result.current.retry(result.current.outbox[0]!.clientMessageId))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
expect(result.current.outbox[0]).toMatchObject({ state: 'dispatching' })
|
||||
expect(shownFailure(result.current.outbox[0])).toBeUndefined()
|
||||
|
||||
const id = result.current.outbox[0]!.clientMessageId
|
||||
rerender({ fence: 3, submissions: [submission(id, 'pending')] })
|
||||
rerender({ fence: 3, submissions: [submission(id, 'accepted')] })
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(mocks.call).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a send the host refused by throwing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it("keeps the refusal on the message, with the refusal's words and not a bare failure", async () => {
|
||||
// As `mapRuntimeError` sends a thrown refusal (pinned in `rpc/errors.test.ts`).
|
||||
mocks.call.mockRejectedValue(
|
||||
new RuntimeRpcCallError({
|
||||
id: 'req-1',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'runtime_error',
|
||||
message: 'agent_session_journal_unreadable',
|
||||
data: {
|
||||
refusal: {
|
||||
code: 'agent_session_journal_unreadable',
|
||||
details: { reason: 'journalCorrupt' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: { kind: 'local' },
|
||||
fence: 1,
|
||||
submissions: []
|
||||
})
|
||||
)
|
||||
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.outbox[0]?.lastFailure).toEqual({
|
||||
kind: 'refused',
|
||||
code: 'agent_session_journal_unreadable',
|
||||
details: { reason: 'journalCorrupt' }
|
||||
})
|
||||
)
|
||||
expect(shownFailure(result.current.outbox[0])).toBe(
|
||||
'Unable to load this chat. Your message was not sent.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a send refused on a journal a newer Orca wrote', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('says to update Orca, not to try again', async () => {
|
||||
// As the host answers it (pinned in `journal-open-failure.test.ts`).
|
||||
mocks.call.mockResolvedValue({
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'agent_session_journal_unreadable',
|
||||
message: 'Chats were saved by a newer Orca. Update Orca to keep using them.',
|
||||
details: { reason: 'journalWrittenByNewerOrca' }
|
||||
}
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: { kind: 'local' },
|
||||
fence: 1,
|
||||
submissions: []
|
||||
})
|
||||
)
|
||||
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(shownFailure(result.current.outbox[0])).toBe(
|
||||
'Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them.'
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
-565
@@ -1,565 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
// A message the chat said was not sent, with a Retry beside it, waits for that Retry. The hold is
|
||||
// read from the saved message, so quitting and reopening Orca (a new mount over the same storage)
|
||||
// holds it exactly as the refusal did.
|
||||
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import type { AgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ call: vi.fn() }))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.call
|
||||
}))
|
||||
|
||||
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
|
||||
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
|
||||
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
|
||||
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
|
||||
import {
|
||||
clearNativeChatDraftCacheForTests,
|
||||
readNativeChatDraftCache
|
||||
} from './native-chat-draft-cache'
|
||||
import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
import { writeOutbox } from './structured-agent-session-outbox-storage'
|
||||
import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices'
|
||||
|
||||
const SESSION = 'session-1'
|
||||
// Stable, as the view passes it: a new object each render would re-run the owner-change requeue.
|
||||
const LOCAL_TARGET = { kind: 'local' } as const
|
||||
// Read back from storage, the cause may have cleared since (the user updated Orca, say).
|
||||
const NOT_SENT_WORDS = 'Your message was not sent.'
|
||||
const NEWER_ORCA_WORDS =
|
||||
'Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them.'
|
||||
|
||||
type SendRequest = {
|
||||
body?: { blocks?: { text?: string }[] }
|
||||
envelope?: { clientOperationId?: string }
|
||||
}
|
||||
|
||||
function requestId(params: SendRequest | undefined): string {
|
||||
return String(params?.envelope?.clientOperationId)
|
||||
}
|
||||
|
||||
function sentIds(): string[] {
|
||||
return mocks.call.mock.calls.map((call) => requestId(call[2]))
|
||||
}
|
||||
|
||||
function newerOrcaRefusal() {
|
||||
return {
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'agent_session_journal_unreadable',
|
||||
message: 'Chats were saved by a newer Orca. Update Orca to keep using them.',
|
||||
details: { reason: 'journalWrittenByNewerOrca' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refusal(code: AgentSessionWireRefusalCode) {
|
||||
return { ok: false, refusal: { code, message: code } }
|
||||
}
|
||||
|
||||
function accepted(clientMessageId: string) {
|
||||
return {
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-1', sequence: 2 },
|
||||
value: {
|
||||
clientMessageId,
|
||||
submission: {
|
||||
clientMessageId,
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'accepted',
|
||||
providerItemId: `provider-${clientMessageId}`,
|
||||
reason: null,
|
||||
submittedAt: 1,
|
||||
resolvedAt: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hostAccepts(): void {
|
||||
mocks.call.mockImplementation((_target, _method, params: SendRequest) =>
|
||||
Promise.resolve(accepted(requestId(params)))
|
||||
)
|
||||
}
|
||||
|
||||
function mount(fence = 1) {
|
||||
return renderHook(
|
||||
({ fence: current }: { fence: number }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: SESSION,
|
||||
target: LOCAL_TARGET,
|
||||
fence: current,
|
||||
submissions: []
|
||||
}),
|
||||
{ initialProps: { fence } }
|
||||
)
|
||||
}
|
||||
|
||||
type Outbox = ReturnType<typeof mount>['result']['current']
|
||||
|
||||
function notices(outbox: Outbox) {
|
||||
return structuredAgentSessionDeliveryNotices(
|
||||
outbox.outbox,
|
||||
'Claude',
|
||||
outbox.retry,
|
||||
[],
|
||||
[],
|
||||
outbox.failedHere
|
||||
)
|
||||
}
|
||||
|
||||
function noticeFor(outbox: Outbox, clientMessageId: string) {
|
||||
return notices(outbox).get(agentJournalSubmissionKey(clientMessageId))
|
||||
}
|
||||
|
||||
/** Long enough for any effect a mount or a state change schedules to have sent. */
|
||||
async function settle(): Promise<void> {
|
||||
await act(() => new Promise<void>((resolve) => setTimeout(resolve, 50)))
|
||||
}
|
||||
|
||||
describe('a message the host refused, across a relaunch', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
setLocalRuntimeCapabilitiesForTests(null)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY])
|
||||
})
|
||||
|
||||
it.each(['returned', 'thrown'] as const)(
|
||||
'is not sent on its own after a relaunch; its Retry sends it (refusal %s)',
|
||||
async (refusalDelivery) => {
|
||||
if (refusalDelivery === 'returned') {
|
||||
mocks.call.mockResolvedValueOnce(newerOrcaRefusal())
|
||||
} else {
|
||||
// As `mapRuntimeError` sends a thrown refusal (pinned in `rpc/errors.test.ts`).
|
||||
mocks.call.mockRejectedValueOnce(
|
||||
new RuntimeRpcCallError({
|
||||
id: 'req-1',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'runtime_error',
|
||||
message: 'agent_session_journal_unreadable',
|
||||
data: {
|
||||
refusal: {
|
||||
code: 'agent_session_journal_unreadable',
|
||||
details: { reason: 'journalWrittenByNewerOrca' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
const before = mount()
|
||||
act(() => expect(before.result.current.send('hello')).toBe(true))
|
||||
await waitFor(() => expect(before.result.current.outbox[0]?.lastFailure).toBeDefined())
|
||||
const refusedId = sentIds()[0]!
|
||||
expect(noticeFor(before.result.current, refusedId)?.text).toBe(NEWER_ORCA_WORDS)
|
||||
before.unmount()
|
||||
|
||||
// The user updates Orca and opens the chat again; the host now takes sends.
|
||||
hostAccepts()
|
||||
const after = mount()
|
||||
await settle()
|
||||
expect(mocks.call).toHaveBeenCalledTimes(1)
|
||||
const notice = noticeFor(after.result.current, refusedId)
|
||||
expect(notice?.text).toBe(NOT_SENT_WORDS)
|
||||
expect(notice?.onRetry).toBeDefined()
|
||||
|
||||
act(() => notice?.onRetry?.())
|
||||
await waitFor(() => expect(after.result.current.outbox).toHaveLength(0))
|
||||
// The refusal recorded nothing, so the Retry goes out under the refused id.
|
||||
expect(sentIds()).toEqual([refusedId, refusedId])
|
||||
}
|
||||
)
|
||||
|
||||
it('sends a message typed again after a refusal once, not a second time after a relaunch', async () => {
|
||||
// The chat's history is from a newer Orca: every send is refused until the user updates.
|
||||
mocks.call.mockResolvedValue(newerOrcaRefusal())
|
||||
const before = mount()
|
||||
act(() => expect(before.result.current.send('hello')).toBe(true))
|
||||
await waitFor(() => expect(before.result.current.outbox[0]?.lastFailure).toBeDefined())
|
||||
// The user types the same message again rather than pressing Retry.
|
||||
act(() => expect(before.result.current.send('hello')).toBe(true))
|
||||
await settle()
|
||||
const refusedBeforeUpdate = mocks.call.mock.calls.length
|
||||
before.unmount()
|
||||
|
||||
hostAccepts()
|
||||
const after = mount()
|
||||
await settle()
|
||||
// Nothing goes out on its own: each message still says it was not sent, with its own Retry.
|
||||
expect(mocks.call).toHaveBeenCalledTimes(refusedBeforeUpdate)
|
||||
expect(after.result.current.outbox).toHaveLength(2)
|
||||
for (const entry of after.result.current.outbox) {
|
||||
expect(noticeFor(after.result.current, entry.clientMessageId)?.onRetry).toBeDefined()
|
||||
}
|
||||
|
||||
// One Retry delivers the message once; the other copy stays unsent.
|
||||
act(() => after.result.current.retry(after.result.current.outbox[1]!.clientMessageId))
|
||||
await waitFor(() => expect(after.result.current.outbox).toHaveLength(1))
|
||||
await settle()
|
||||
expect(mocks.call).toHaveBeenCalledTimes(refusedBeforeUpdate + 1)
|
||||
})
|
||||
|
||||
it('keeps a send that never reached the host held across a relaunch', async () => {
|
||||
mocks.call.mockRejectedValueOnce(new Error('send failed'))
|
||||
const before = mount()
|
||||
act(() => expect(before.result.current.send('hello')).toBe(true))
|
||||
await waitFor(() =>
|
||||
expect(before.result.current.outbox[0]?.lastFailure).toEqual({ kind: 'failed' })
|
||||
)
|
||||
const failedId = sentIds()[0]!
|
||||
before.unmount()
|
||||
|
||||
hostAccepts()
|
||||
const after = mount()
|
||||
await settle()
|
||||
expect(mocks.call).toHaveBeenCalledTimes(1)
|
||||
expect(noticeFor(after.result.current, failedId)?.onRetry).toBeDefined()
|
||||
})
|
||||
|
||||
it('holds a refused message an earlier Orca saved, in the shape that build writes', async () => {
|
||||
// Exactly what a build from before this hold leaves behind after the refusal: no new field.
|
||||
localStorage.setItem(
|
||||
`orca:desktopStructuredAgentSessionOutbox:v1:${SESSION}`,
|
||||
JSON.stringify([
|
||||
{
|
||||
clientMessageId: 'op-refused',
|
||||
sessionId: SESSION,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
|
||||
previewUris: [],
|
||||
state: 'queued',
|
||||
queuedAt: 1,
|
||||
lastAttemptAt: 2,
|
||||
retryAfterUnknownSubmittedAt: null,
|
||||
lastFailure: {
|
||||
kind: 'refused',
|
||||
code: 'agent_session_journal_unreadable',
|
||||
details: { reason: 'journalWrittenByNewerOrca' }
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
hostAccepts()
|
||||
const { result } = mount()
|
||||
await settle()
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
expect(noticeFor(result.current, 'op-refused')?.text).toBe(NOT_SENT_WORDS)
|
||||
})
|
||||
|
||||
it("words the cause again once a Retry is refused for it; a relaunch's row only says not sent", async () => {
|
||||
mocks.call.mockResolvedValue(newerOrcaRefusal())
|
||||
const before = mount()
|
||||
act(() => expect(before.result.current.send('hello')).toBe(true))
|
||||
await waitFor(() => expect(before.result.current.outbox[0]?.lastFailure).toBeDefined())
|
||||
const refusedId = sentIds()[0]!
|
||||
before.unmount()
|
||||
|
||||
// The chat's history is still from a newer Orca.
|
||||
const after = mount()
|
||||
await settle()
|
||||
expect(noticeFor(after.result.current, refusedId)).toMatchObject({ text: NOT_SENT_WORDS })
|
||||
|
||||
act(() => noticeFor(after.result.current, refusedId)?.onRetry?.())
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
await waitFor(() =>
|
||||
expect(noticeFor(after.result.current, refusedId)?.text).toBe(NEWER_ORCA_WORDS)
|
||||
)
|
||||
expect(noticeFor(after.result.current, refusedId)?.onRetry).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// An older host restarts the agent inside a send and refuses it unrecorded when that fails. The
|
||||
// refused message was shown as not sent, so neither a relaunch nor a new owner sends it.
|
||||
describe('a message an older host refused, across a relaunch', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
setLocalRuntimeCapabilitiesForTests(null)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
setLocalRuntimeCapabilitiesForTests([])
|
||||
})
|
||||
|
||||
it('is not sent on its own when the chat reopens on a moved fence', async () => {
|
||||
mocks.call.mockResolvedValueOnce(refusal('agent_session_checkpoint_stale'))
|
||||
const before = mount(1)
|
||||
act(() => expect(before.result.current.send('hello')).toBe(true))
|
||||
await waitFor(() => expect(before.result.current.outbox[0]?.lastFailure).toBeDefined())
|
||||
before.unmount()
|
||||
|
||||
hostAccepts()
|
||||
mount(3)
|
||||
await settle()
|
||||
expect(mocks.call).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('is not sent when the fence moves while the chat is open; its Retry sends it once', async () => {
|
||||
hostAccepts()
|
||||
mocks.call.mockResolvedValueOnce(refusal('agent_session_checkpoint_stale'))
|
||||
const { result, rerender } = mount(1)
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox[0]?.lastFailure).toBeDefined())
|
||||
|
||||
rerender({ fence: 3 })
|
||||
await settle()
|
||||
expect(mocks.call).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => result.current.retry(result.current.outbox[0]!.clientMessageId))
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
|
||||
await settle()
|
||||
expect(sentIds()).toHaveLength(2)
|
||||
expect(sentIds()[1]).toBe(sentIds()[0])
|
||||
})
|
||||
})
|
||||
|
||||
// A message left on its way out is in doubt after a relaunch, whatever an earlier attempt failed
|
||||
// with: the unconfirmed probe resends it under its id rather than holding it for a Retry.
|
||||
describe('a message in doubt that carries an earlier failure', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
setLocalRuntimeCapabilitiesForTests(null)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY])
|
||||
})
|
||||
|
||||
it('is probed and sent again, not held', async () => {
|
||||
// What an earlier build left: on its way out, beside a failure it never cleared.
|
||||
const inDoubt = {
|
||||
...createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: 'op-in-doubt',
|
||||
sessionId: SESSION,
|
||||
text: 'hello',
|
||||
attachments: [],
|
||||
queuedAt: 1
|
||||
}),
|
||||
state: 'dispatching' as const,
|
||||
lastAttemptAt: 2,
|
||||
lastFailure: { kind: 'refused' as const, code: 'execution_owner_reconciling' as const }
|
||||
}
|
||||
writeOutbox(SESSION, [inDoubt])
|
||||
hostAccepts()
|
||||
const { result } = mount()
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(0), { timeout: 5000 })
|
||||
expect(sentIds()).toEqual(['op-in-doubt'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a message whose send could not be saved before it went out', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
setLocalRuntimeCapabilitiesForTests(null)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY])
|
||||
})
|
||||
|
||||
it('waits for its Retry, and does not hold back the next message', async () => {
|
||||
hostAccepts()
|
||||
const save = localStorage.setItem.bind(localStorage)
|
||||
// The send saves; the save that marks it on its way out fails.
|
||||
const setItem = vi
|
||||
.spyOn(localStorage, 'setItem')
|
||||
.mockImplementationOnce(save)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('storage full')
|
||||
})
|
||||
const { result } = mount()
|
||||
act(() => expect(result.current.send('first')).toBe(true))
|
||||
await waitFor(() => expect(result.current.error).toBe("Couldn't save your message. Try again."))
|
||||
setItem.mockRestore()
|
||||
const firstId = result.current.outbox[0]!.clientMessageId
|
||||
|
||||
act(() => expect(result.current.send('second')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(1))
|
||||
await settle()
|
||||
expect(mocks.call).toHaveBeenCalledOnce()
|
||||
expect(sentIds()).not.toContain(firstId)
|
||||
expect(noticeFor(result.current, firstId)?.onRetry).toBeDefined()
|
||||
})
|
||||
|
||||
function failingWrites(failing: readonly number[]): void {
|
||||
const save = localStorage.setItem.bind(localStorage)
|
||||
let writes = 0
|
||||
vi.spyOn(localStorage, 'setItem').mockImplementation((key: string, value: string) => {
|
||||
writes += 1
|
||||
if (failing.includes(writes)) {
|
||||
throw new Error('storage full')
|
||||
}
|
||||
save(key, value)
|
||||
})
|
||||
}
|
||||
|
||||
it('stays held when the message behind it goes out, though no save recorded the hold', async () => {
|
||||
hostAccepts()
|
||||
// Both messages save; the save marking the first on its way out, and the one recording its
|
||||
// hold, fail; storage then works again for the second.
|
||||
failingWrites([3, 4])
|
||||
const detached: { fence: number | null } = { fence: null }
|
||||
const { result, rerender } = renderHook(
|
||||
({ fence }: { fence: number | null }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: SESSION,
|
||||
target: LOCAL_TARGET,
|
||||
fence,
|
||||
submissions: []
|
||||
}),
|
||||
{ initialProps: detached }
|
||||
)
|
||||
act(() => expect(result.current.send('first')).toBe(true))
|
||||
act(() => expect(result.current.send('second')).toBe(true))
|
||||
const [firstId, secondId] = result.current.outbox.map((entry) => entry.clientMessageId)
|
||||
rerender({ fence: 1 })
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(1))
|
||||
await settle()
|
||||
expect(sentIds()).toEqual([secondId])
|
||||
expect(result.current.outbox[0]).toMatchObject({
|
||||
clientMessageId: firstId,
|
||||
lastFailure: { kind: 'failed' }
|
||||
})
|
||||
expect(noticeFor(result.current, firstId!)?.onRetry).toBeDefined()
|
||||
})
|
||||
|
||||
it('stays held across a relaunch once storage takes the hold', async () => {
|
||||
hostAccepts()
|
||||
// The send saves; the save marking it on its way out fails; the hold's own save goes through.
|
||||
failingWrites([2])
|
||||
const before = mount()
|
||||
act(() => expect(before.result.current.send('first')).toBe(true))
|
||||
await waitFor(() =>
|
||||
expect(before.result.current.error).toBe("Couldn't save your message. Try again.")
|
||||
)
|
||||
const firstId = before.result.current.outbox[0]!.clientMessageId
|
||||
before.unmount()
|
||||
vi.restoreAllMocks()
|
||||
|
||||
const after = mount()
|
||||
await settle()
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
expect(noticeFor(after.result.current, firstId)?.onRetry).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// A host forgets an operation id a day after it was made and refuses it for good after that. An
|
||||
// earlier attempt under that id may already be in the chat, so the message stays a held row that
|
||||
// says so; only the user's Retry sends it, under a new id.
|
||||
describe('a held message whose id expired', () => {
|
||||
const DAY = 24 * 60 * 60 * 1000
|
||||
const EXPIRED_WORDS = "Orca couldn't confirm what happened. Check the chat."
|
||||
|
||||
function expired() {
|
||||
return {
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'agent_session_operation_expired',
|
||||
message: 'Operation expired.',
|
||||
details: { reason: 'operationExpired' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
setLocalRuntimeCapabilitiesForTests(null)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
clearNativeChatDraftCacheForTests()
|
||||
setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY])
|
||||
})
|
||||
|
||||
it('stays a row that says it may be in the chat; its Retry sends it under a new id', async () => {
|
||||
mocks.call.mockResolvedValueOnce(newerOrcaRefusal()).mockResolvedValueOnce(expired())
|
||||
const before = mount()
|
||||
act(() => expect(before.result.current.send('hello')).toBe(true))
|
||||
await waitFor(() => expect(before.result.current.outbox[0]?.lastFailure).toBeDefined())
|
||||
const keptId = sentIds()[0]!
|
||||
act(() => before.result.current.retry(keptId))
|
||||
await waitFor(() =>
|
||||
expect(before.result.current.outbox[0]?.lastFailure).toMatchObject({
|
||||
code: 'agent_session_operation_expired'
|
||||
})
|
||||
)
|
||||
expect(before.result.current.error).toBeNull()
|
||||
before.unmount()
|
||||
|
||||
hostAccepts()
|
||||
const after = mount()
|
||||
await settle()
|
||||
expect(mocks.call).toHaveBeenCalledTimes(2)
|
||||
const notice = noticeFor(after.result.current, keptId)
|
||||
expect(notice?.text).toBe(EXPIRED_WORDS)
|
||||
expect(notice?.onRetry).toBeDefined()
|
||||
|
||||
act(() => notice?.onRetry?.())
|
||||
await waitFor(() => expect(after.result.current.outbox).toHaveLength(0))
|
||||
expect(sentIds()).toHaveLength(3)
|
||||
expect(sentIds()[2]).not.toBe(keptId)
|
||||
})
|
||||
|
||||
it('stays in the chat, not the composer, when a relaunch resends it on its own', async () => {
|
||||
// Quit mid-send two days ago: the first message was on its way, the second queued behind it.
|
||||
const old = Date.now() - 2 * DAY
|
||||
const inFlight = {
|
||||
...createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: `${old}-${'1'.repeat(32)}`,
|
||||
sessionId: SESSION,
|
||||
text: 'first',
|
||||
attachments: [],
|
||||
queuedAt: old
|
||||
}),
|
||||
state: 'dispatching' as const,
|
||||
lastAttemptAt: old
|
||||
}
|
||||
writeOutbox(SESSION, [inFlight])
|
||||
mocks.call.mockResolvedValue(expired())
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: SESSION,
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
submissions: [],
|
||||
composerScopeKey: 'pane-1'
|
||||
})
|
||||
)
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalled(), { timeout: 5000 })
|
||||
await waitFor(() =>
|
||||
expect(result.current.outbox[0]?.lastFailure).toMatchObject({
|
||||
code: 'agent_session_operation_expired'
|
||||
})
|
||||
)
|
||||
await settle()
|
||||
expect(readNativeChatDraftCache('pane-1')).toBe('')
|
||||
expect(result.current.outbox).toMatchObject([
|
||||
{ clientMessageId: inFlight.clientMessageId, state: 'queued' }
|
||||
])
|
||||
expect(noticeFor(result.current, inFlight.clientMessageId)?.text).toBe(EXPIRED_WORDS)
|
||||
expect(sentIds()).toEqual([inFlight.clientMessageId])
|
||||
})
|
||||
})
|
||||
+33
-27
@@ -2,18 +2,22 @@ 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 {
|
||||
STRUCTURED_AGENT_SESSION_SEND_UNCONFIRMED_WORDS,
|
||||
structuredAgentSessionEntryOutlivedHostWindow
|
||||
} from '../../../../shared/structured-agent-session-send-settlement'
|
||||
import {
|
||||
commitStructuredAgentSessionOutbox,
|
||||
getStructuredAgentSessionOutbox
|
||||
} from './structured-agent-session-outbox-storage'
|
||||
import { settleStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-dispatch'
|
||||
|
||||
const UNCONFIRMED_PROBE_BASE_DELAY_MS = 1_000
|
||||
/** No attempt ceiling: a transport outage outlives any fixed budget, and giving up
|
||||
* restores the wedge this fixes. Growth caps the rate at one status query per 16s.
|
||||
* A refusal still ends probing until a manual Retry, because the entry leaves `unconfirmed`. */
|
||||
/** No attempt ceiling: a transport outage outlives any fixed budget, and the host's answer is what
|
||||
* ends it. Growth caps the rate at one resend per 16s. */
|
||||
const UNCONFIRMED_PROBE_MAX_DELAY_MS = 16_000
|
||||
|
||||
/** Re-queues the entry holding the outbox in `unconfirmed`, with backoff, until the journal answers it. */
|
||||
/** Re-queues the entry holding the outbox in `unconfirmed`, with backoff, until the host answers. */
|
||||
export function useStructuredAgentSessionOutboxUnconfirmedProbe(args: {
|
||||
sessionId: string
|
||||
outbox: readonly StructuredAgentSessionOutboxEntry[]
|
||||
@@ -26,22 +30,14 @@ export function useStructuredAgentSessionOutboxUnconfirmedProbe(args: {
|
||||
probeAttemptsRef.current = { id: null, attempts: 0 }
|
||||
}, [owner.ownerChange, owner.targetKey, sessionId])
|
||||
|
||||
// A transport-side unknown may never have reached the host, and nothing else
|
||||
// moves it out of `unconfirmed`, so one wedges the whole FIFO queue. Re-issuing
|
||||
// the same envelope without `retryUnknown` is idempotent: the operation ledger
|
||||
// replays a recorded outcome, or the host performs a genuine first delivery.
|
||||
// A host-confirmed unknown stays parked until the user explicitly asks Retry
|
||||
// to replay the same operation.
|
||||
// The first `unconfirmed` entry is the one holding the queue, at whatever index it sits: an
|
||||
// unconfirmed tail behind an admitted head would otherwise wedge until the head cleared,
|
||||
// which is the wedge this probe exists to prevent.
|
||||
// A send with no answer may never have reached the host, and nothing else moves it out of
|
||||
// `unconfirmed`, so one would wedge the whole FIFO queue. The same id again is idempotent: the
|
||||
// host replays a recorded answer or performs a genuine first delivery. The first `unconfirmed`
|
||||
// entry is the one holding the queue, at whatever index it sits.
|
||||
const blocker = outbox.find((entry) => entry.state === 'unconfirmed')
|
||||
// Depend on primitives: `submissions` is rebuilt on every streaming batch, so an
|
||||
// array-identity dep would reset the backoff forever while the agent is working.
|
||||
// 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.
|
||||
// Primitives only: `submissions` is rebuilt on every streaming batch, and an array-identity dep
|
||||
// would reset the backoff forever while the agent is working.
|
||||
const probeId =
|
||||
blocker &&
|
||||
blocker.sessionId === sessionId &&
|
||||
@@ -56,15 +52,25 @@ export function useStructuredAgentSessionOutboxUnconfirmedProbe(args: {
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
probeAttemptsRef.current = { id: probeId, attempts: attempts + 1 }
|
||||
const next = getStructuredAgentSessionOutbox(sessionId).map((entry) => {
|
||||
if (entry.clientMessageId !== probeId) {
|
||||
return entry
|
||||
}
|
||||
// A saved failure would hold it for a Retry instead of resending it.
|
||||
const { lastFailure: _probed, ...probed } = entry
|
||||
return { ...probed, state: 'queued' as const }
|
||||
})
|
||||
commitStructuredAgentSessionOutbox(sessionId, next)
|
||||
const current = getStructuredAgentSessionOutbox(sessionId)
|
||||
const entry = current.find((candidate) => candidate.clientMessageId === probeId)
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
// Past the host's window no answer can settle it, and the person checks the chat instead.
|
||||
if (structuredAgentSessionEntryOutlivedHostWindow(entry, Date.now())) {
|
||||
settleStructuredAgentSessionOutboxEntry(sessionId, probeId, {
|
||||
kind: 'returned',
|
||||
words: [...STRUCTURED_AGENT_SESSION_SEND_UNCONFIRMED_WORDS]
|
||||
})
|
||||
return
|
||||
}
|
||||
commitStructuredAgentSessionOutbox(
|
||||
sessionId,
|
||||
current.map((candidate) =>
|
||||
candidate === entry ? { ...candidate, state: 'queued' as const } : candidate
|
||||
)
|
||||
)
|
||||
},
|
||||
Math.min(UNCONFIRMED_PROBE_BASE_DELAY_MS * 2 ** attempts, UNCONFIRMED_PROBE_MAX_DELAY_MS)
|
||||
)
|
||||
|
||||
+41
-34
@@ -1,5 +1,6 @@
|
||||
// What a Stop does to the messages this client sent that the host does not hold yet: nothing may go
|
||||
// out after the Stop, and nothing the host already holds or already refused changes here.
|
||||
// out after the Stop. One that never went out comes back to the composer; one on its way is stamped
|
||||
// with the Stop's own id and never sent again; one the host holds is the journal's to settle.
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
@@ -9,7 +10,7 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session-jo
|
||||
import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
import {
|
||||
hasUnsentStructuredAgentSessionOutboxEntry,
|
||||
withdrawUnsentStructuredAgentSessionOutboxEntries
|
||||
stopStructuredAgentSessionOutbox
|
||||
} from '../../../../shared/structured-agent-session-outbox-stop-withdrawal'
|
||||
|
||||
type SendRequest = { body?: { blocks?: { text?: string }[] } }
|
||||
@@ -24,6 +25,11 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
|
||||
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
|
||||
import { readOutbox } from './structured-agent-session-outbox-storage'
|
||||
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
|
||||
import {
|
||||
clearNativeChatDraftCacheForTests,
|
||||
readNativeChatDraftCache
|
||||
} from './native-chat-draft-cache'
|
||||
|
||||
// One object: a target rebuilt each render reads as a new owner, which re-sends what is on its way.
|
||||
const TARGET = { kind: 'local' } as const
|
||||
@@ -57,8 +63,7 @@ function entry(
|
||||
previewUris: [],
|
||||
state,
|
||||
queuedAt: 1,
|
||||
lastAttemptAt: null,
|
||||
retryAfterUnknownSubmittedAt: null
|
||||
lastAttemptAt: state === 'queued' ? null : 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +72,7 @@ afterEach(cleanup)
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
clearNativeChatDraftCacheForTests()
|
||||
let uuid = 0
|
||||
vi.spyOn(globalThis.crypto, 'randomUUID').mockImplementation(() => {
|
||||
uuid += 1
|
||||
@@ -93,7 +99,11 @@ describe('a Stop withdrawing what the host does not hold', () => {
|
||||
act(() => expect(result.current.send('second')).toBe(true))
|
||||
const firstId = result.current.outbox[0]!.clientMessageId
|
||||
|
||||
act(() => result.current.withdrawUnsent())
|
||||
act(() => result.current.stop('stop-1'))
|
||||
// The one that never left is back in the composer.
|
||||
expect(readNativeChatDraftCache(structuredAgentSessionDraftScopeKey('session-1'))).toBe(
|
||||
'second'
|
||||
)
|
||||
await act(async () =>
|
||||
reply.resolve({
|
||||
ok: true,
|
||||
@@ -111,40 +121,36 @@ describe('a Stop withdrawing what the host does not hold', () => {
|
||||
expect(sentTexts()).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('leaves what the journal holds to the host, and a refused message to its Retry', () => {
|
||||
it('leaves what the journal holds to the host, and stamps what is on its way', () => {
|
||||
const entries = [
|
||||
entry('held', 'dispatching'),
|
||||
entry('refused', 'rejected'),
|
||||
entry('in-doubt', 'unconfirmed'),
|
||||
entry('local', 'queued')
|
||||
]
|
||||
|
||||
const stopped = stopStructuredAgentSessionOutbox(entries, [pending('held')], null, 'stop-1')
|
||||
expect(
|
||||
withdrawUnsentStructuredAgentSessionOutboxEntries(entries, [pending('held')], null).map(
|
||||
(candidate) => candidate.clientMessageId
|
||||
)
|
||||
).toEqual(['held', 'refused'])
|
||||
stopped.entries.map((candidate) => [candidate.clientMessageId, candidate.stoppedBy])
|
||||
).toEqual([
|
||||
['held', undefined],
|
||||
['in-doubt', { operationId: 'stop-1' }]
|
||||
])
|
||||
expect(stopped.withdrawn.map((candidate) => candidate.clientMessageId)).toEqual(['local'])
|
||||
})
|
||||
|
||||
it('leaves every message that waits on its Retry, not only a refused one', () => {
|
||||
const entries = [
|
||||
{ ...entry('blocked', 'queued'), lastFailure: { kind: 'failed' as const } },
|
||||
{ ...entry('retried-in-doubt', 'unconfirmed'), retryAfterUnknownSubmittedAt: 10 },
|
||||
entry('probed-in-doubt', 'unconfirmed'),
|
||||
entry('local', 'queued')
|
||||
]
|
||||
|
||||
expect(
|
||||
withdrawUnsentStructuredAgentSessionOutboxEntries(entries, [], null).map(
|
||||
(candidate) => candidate.clientMessageId
|
||||
)
|
||||
).toEqual(['blocked', 'retried-in-doubt'])
|
||||
expect(hasUnsentStructuredAgentSessionOutboxEntry(entries.slice(0, 2), [])).toBe(false)
|
||||
expect(hasUnsentStructuredAgentSessionOutboxEntry(entries, [])).toBe(true)
|
||||
it('stamps the send in flight even before its first answer, and never a second time', () => {
|
||||
const inFlight = { ...entry('in-flight', 'dispatching'), lastAttemptAt: null }
|
||||
const stopped = stopStructuredAgentSessionOutbox([inFlight], [], 'in-flight', 'stop-1')
|
||||
expect(stopped.entries[0]?.stoppedBy).toEqual({ operationId: 'stop-1' })
|
||||
// A later Stop leaves it with the first: its answer is what it waits for.
|
||||
const again = stopStructuredAgentSessionOutbox(stopped.entries, [], null, 'stop-2')
|
||||
expect(again.entries[0]?.stoppedBy).toEqual({ operationId: 'stop-1' })
|
||||
expect(hasUnsentStructuredAgentSessionOutboxEntry(stopped.entries, [])).toBe(false)
|
||||
expect(hasUnsentStructuredAgentSessionOutboxEntry([inFlight], [])).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a message whose send failed for its Retry', async () => {
|
||||
mocks.call.mockRejectedValue(new Error('the host refused the frame'))
|
||||
it('keeps a message with no answer, stamped, and never sends it again', async () => {
|
||||
mocks.call.mockRejectedValue(new Error('socket closed'))
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
@@ -154,13 +160,14 @@ describe('a Stop withdrawing what the host does not hold', () => {
|
||||
})
|
||||
)
|
||||
act(() => expect(result.current.send('first')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox[0]?.lastFailure).toEqual({ kind: 'failed' }))
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed'))
|
||||
|
||||
act(() => result.current.withdrawUnsent())
|
||||
act(() => result.current.stop('stop-1'))
|
||||
// Past the resend's first delay: nothing goes again.
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 1300)))
|
||||
|
||||
expect(result.current.outbox.map((candidate) => candidate.body.blocks)).toEqual([
|
||||
[{ type: 'text', text: 'first' }]
|
||||
])
|
||||
expect(readOutbox('session-1')).toHaveLength(1)
|
||||
expect(mocks.call).toHaveBeenCalledOnce()
|
||||
expect(readOutbox('session-1')).toMatchObject([{ stoppedBy: { operationId: 'stop-1' } }])
|
||||
expect(readNativeChatDraftCache(structuredAgentSessionDraftScopeKey('session-1'))).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
+11
-9
@@ -26,6 +26,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
}))
|
||||
|
||||
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
|
||||
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
|
||||
import { writeOutbox } from './structured-agent-session-outbox-storage'
|
||||
|
||||
// Why: every hook here shares the session outbox store; one left mounted would drain the next test's.
|
||||
@@ -71,6 +72,8 @@ function seed(clientMessageId: string, queued: boolean): void {
|
||||
])
|
||||
}
|
||||
|
||||
const SCOPE = structuredAgentSessionDraftScopeKey('session-1')
|
||||
|
||||
function renderOutbox() {
|
||||
type Props = { submissions: AgentJournalSubmission[] }
|
||||
const initialProps: Props = { submissions: [] }
|
||||
@@ -81,7 +84,6 @@ function renderOutbox() {
|
||||
target: TARGET,
|
||||
fence: 1,
|
||||
submissions: props.submissions,
|
||||
composerScopeKey: 'scope',
|
||||
queueDelivery: QUEUEING
|
||||
}),
|
||||
{ initialProps }
|
||||
@@ -105,7 +107,7 @@ describe('an outbox entry the host handed off as a queued draft', () => {
|
||||
seed('draft', true)
|
||||
const view = renderOutbox()
|
||||
act(() => {
|
||||
view.result.current.withdrawUnsent()
|
||||
view.result.current.stop('stop-1')
|
||||
})
|
||||
expect(view.result.current.outbox).toHaveLength(1)
|
||||
view.rerender({
|
||||
@@ -115,10 +117,10 @@ describe('an outbox entry the host handed off as a queued draft', () => {
|
||||
]
|
||||
})
|
||||
await waitFor(() => expect(view.result.current.outbox).toHaveLength(0))
|
||||
expect(readNativeChatDraftCache('scope')).toBe('')
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('')
|
||||
})
|
||||
|
||||
it('lost answer, then refused: the returned card carries it, with no Retry row here', async () => {
|
||||
it('lost answer, then refused: the returned card carries it, with nothing handed back here', async () => {
|
||||
seed('draft', true)
|
||||
const view = renderOutbox()
|
||||
// No published list yet: the link alone settles it, whichever effect runs first.
|
||||
@@ -132,7 +134,7 @@ describe('an outbox entry the host handed off as a queued draft', () => {
|
||||
]
|
||||
})
|
||||
await waitFor(() => expect(view.result.current.outbox).toHaveLength(0))
|
||||
expect(readNativeChatDraftCache('scope')).toBe('')
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('')
|
||||
})
|
||||
|
||||
it('a withdrawn immediate send still comes back to the composer', async () => {
|
||||
@@ -140,7 +142,7 @@ describe('an outbox entry the host handed off as a queued draft', () => {
|
||||
const view = renderOutbox()
|
||||
view.rerender({ submissions: [submission('plain', WITHDRAWN)] })
|
||||
await waitFor(() => expect(view.result.current.outbox).toHaveLength(0))
|
||||
expect(readNativeChatDraftCache('scope')).toBe('follow-up')
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('follow-up')
|
||||
})
|
||||
|
||||
it('a withdrawn hand-off is never restored, even one under the id of the entry', async () => {
|
||||
@@ -148,7 +150,7 @@ describe('an outbox entry the host handed off as a queued draft', () => {
|
||||
const view = renderOutbox()
|
||||
view.rerender({ submissions: [submission('draft', { ...WITHDRAWN, queuedMessageId: 'q' })] })
|
||||
await waitFor(() => expect(view.result.current.outbox).toHaveLength(0))
|
||||
expect(readNativeChatDraftCache('scope')).toBe('')
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('')
|
||||
})
|
||||
|
||||
it('a replayed send answered with the hand-off settles as the host holding it', async () => {
|
||||
@@ -180,7 +182,7 @@ describe('an outbox entry the host handed off as a queued draft', () => {
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('a replay of a deleted card is answered spent: it leaves with no restore and no Retry', async () => {
|
||||
it('a replay of a deleted card is answered spent: it leaves with nothing handed back', async () => {
|
||||
mocks.call.mockImplementationOnce(async (_target, _method, params) => ({
|
||||
ok: true,
|
||||
replayed: true,
|
||||
@@ -199,7 +201,7 @@ describe('an outbox entry the host handed off as a queued draft', () => {
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 20)))
|
||||
expect(view.result.current.outbox).toEqual([])
|
||||
expect(view.result.current.error).toBeNull()
|
||||
expect(readNativeChatDraftCache('scope')).toBe('')
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('')
|
||||
act(() => {
|
||||
expect(view.result.current.send('next')).toBe(true)
|
||||
})
|
||||
|
||||
+16
-20
@@ -30,6 +30,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
|
||||
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
|
||||
import { readOutbox } from './structured-agent-session-outbox-storage'
|
||||
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
|
||||
|
||||
// Why: every hook here shares the session outbox store; one left mounted would drain the next test's.
|
||||
afterEach(cleanup)
|
||||
@@ -188,7 +189,6 @@ describe('outbox queue delivery selection', () => {
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
submissions: [],
|
||||
composerScopeKey: 'stop-scope',
|
||||
queueDelivery: { capability: 'supported' as const, enabled: true },
|
||||
queuedMessageIds: props.queuedMessageIds
|
||||
}),
|
||||
@@ -199,29 +199,27 @@ describe('outbox queue delivery selection', () => {
|
||||
// A second send waits behind single-flight: the Stop still owns ITS text locally.
|
||||
expect(view.result.current.send('never left')).toBe(true)
|
||||
act(() => {
|
||||
view.result.current.withdrawUnsent()
|
||||
view.result.current.stop('stop-1')
|
||||
})
|
||||
// The unissued entry came back to the composer; the issued one stayed put.
|
||||
expect(readNativeChatDraftCache('stop-scope')).toBe('never left')
|
||||
// The issued one waits for its answer, marked: only the user's Retry sends it again.
|
||||
expect(view.result.current.outbox.map((entry) => [entry.state, entry.outlivedStop])).toEqual([
|
||||
['dispatching', true]
|
||||
])
|
||||
expect(readNativeChatDraftCache(STOP_SCOPE)).toBe('never left')
|
||||
// The issued one waits for its answer, stamped with the Stop: nothing sends it again.
|
||||
expect(
|
||||
view.result.current.outbox.map((entry) => [entry.state, entry.stoppedBy?.operationId])
|
||||
).toEqual([['dispatching', 'stop-1']])
|
||||
// The host publishes the issued send as a card: retired, still nothing restored.
|
||||
const entryId = mocks.call.mock.calls[0]?.[2]?.envelope.clientOperationId
|
||||
view.rerender({ queuedMessageIds: [entryId ?? ''] })
|
||||
await waitFor(() => expect(view.result.current.outbox).toHaveLength(0))
|
||||
expect(readNativeChatDraftCache('stop-scope')).toBe('never left')
|
||||
expect(readNativeChatDraftCache(STOP_SCOPE)).toBe('never left')
|
||||
})
|
||||
|
||||
it('while the capability is unknown, an attempted queue send replays what it sent', async () => {
|
||||
const view = await attemptedQueueSend()
|
||||
view.rerender({ capability: 'unknown' })
|
||||
const id = view.result.current.outbox[0]?.clientMessageId ?? ''
|
||||
act(() => {
|
||||
view.result.current.retry(id)
|
||||
})
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
// The same id goes again on its own, with no answer to the first.
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2), { timeout: 3000 })
|
||||
const params = mocks.call.mock.calls[1]?.[2]
|
||||
expect(params?.envelope.clientOperationId).toBe(id)
|
||||
expect(params?.delivery).toBe('queue-if-active')
|
||||
@@ -272,16 +270,13 @@ describe('outbox queue delivery selection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('a host known not to queue gets the Retry without `delivery`; the entry keeps what it sent', async () => {
|
||||
it('a host known not to queue gets the resend without `delivery`; the entry keeps what it sent', async () => {
|
||||
// Such a host rejects the strict field before its operation ledger, so a replay carrying it
|
||||
// again could only fail the same way.
|
||||
const view = await attemptedQueueSend()
|
||||
view.rerender({ capability: 'unsupported' })
|
||||
const id = view.result.current.outbox[0]?.clientMessageId ?? ''
|
||||
act(() => {
|
||||
view.result.current.retry(id)
|
||||
})
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2), { timeout: 3000 })
|
||||
const params = mocks.call.mock.calls[1]?.[2]
|
||||
expect(params?.envelope.clientOperationId).toBe(id)
|
||||
expect(params && 'delivery' in params).toBe(false)
|
||||
@@ -297,11 +292,12 @@ describe('outbox queue delivery selection', () => {
|
||||
})
|
||||
|
||||
const SUPPORTED: StructuredAgentSessionQueueCapability = 'supported'
|
||||
const STOP_SCOPE = structuredAgentSessionDraftScopeKey('session-1')
|
||||
|
||||
/** A queue send whose first attempt failed and now waits on the user's Retry. */
|
||||
/** A queue send whose first attempt got no answer, so it goes again under its id. */
|
||||
async function attemptedQueueSend() {
|
||||
mocks.call.mockImplementationOnce(async () => {
|
||||
throw new Error('invalid_argument: Unrecognized key: "delivery"')
|
||||
throw new Error('socket closed')
|
||||
})
|
||||
const view = renderHook(
|
||||
(props: { capability: StructuredAgentSessionQueueCapability }) =>
|
||||
@@ -315,7 +311,7 @@ async function attemptedQueueSend() {
|
||||
{ initialProps: { capability: SUPPORTED } }
|
||||
)
|
||||
expect(view.result.current.send('follow-up')).toBe(true)
|
||||
await waitFor(() => expect(view.result.current.outbox[0]?.lastFailure).toBeDefined())
|
||||
await waitFor(() => expect(view.result.current.outbox[0]?.state).toBe('unconfirmed'))
|
||||
expect(mocks.call.mock.calls[0]?.[2]?.delivery).toBe('queue-if-active')
|
||||
mocks.call.mockImplementation(() => new Promise(() => {}))
|
||||
return view
|
||||
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
// Every desktop chat send ends one of three ways, decided by what the host said: the host's row
|
||||
// holds it (it leaves the outbox), the host proved it has none (its text goes back to the
|
||||
// conversation's draft with the reason said once), or no answer yet (the same id goes again, so
|
||||
// the host's once-per-id record can't deliver it twice).
|
||||
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
|
||||
import { AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
|
||||
type SentParams = { envelope: { clientOperationId: string }; body: { blocks: { text?: string }[] } }
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
call: vi.fn<(target: unknown, method: string, params: SentParams) => Promise<unknown>>()
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.call
|
||||
}))
|
||||
|
||||
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
|
||||
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
|
||||
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
|
||||
import { getStructuredAgentSessionOutbox } from './structured-agent-session-outbox-storage'
|
||||
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
|
||||
import {
|
||||
clearNativeChatDraftCacheForTests,
|
||||
readNativeChatDraftCache,
|
||||
writeNativeChatDraftCache
|
||||
} from './native-chat-draft-cache'
|
||||
import { resetStructuredAgentSessionChatLinesForTests } from './structured-agent-session-returned-send'
|
||||
|
||||
const TARGET = { kind: 'local' } as const
|
||||
const SCOPE = structuredAgentSessionDraftScopeKey('session-1')
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
clearNativeChatDraftCacheForTests()
|
||||
resetStructuredAgentSessionChatLinesForTests()
|
||||
setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setLocalRuntimeCapabilitiesForTests(null)
|
||||
})
|
||||
|
||||
function sentIds(): string[] {
|
||||
return mocks.call.mock.calls.map((call) => call[2].envelope.clientOperationId)
|
||||
}
|
||||
|
||||
function sentTexts(): (string | undefined)[] {
|
||||
return mocks.call.mock.calls.map((call) => call[2].body.blocks[0]?.text)
|
||||
}
|
||||
|
||||
function submission(
|
||||
clientMessageId: string,
|
||||
dispatchState: AgentJournalSubmission['dispatchState'],
|
||||
patch: Partial<AgentJournalSubmission> = {}
|
||||
): AgentJournalSubmission {
|
||||
return {
|
||||
clientMessageId,
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState,
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
submittedAt: 10,
|
||||
resolvedAt: dispatchState === 'pending' ? null : 10,
|
||||
...patch
|
||||
}
|
||||
}
|
||||
|
||||
function answer(
|
||||
params: SentParams,
|
||||
dispatchState: AgentJournalSubmission['dispatchState'],
|
||||
patch = {}
|
||||
) {
|
||||
const id = params.envelope.clientOperationId
|
||||
return {
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-1', sequence: 10 },
|
||||
value: { clientMessageId: id, submission: submission(id, dispatchState, patch) }
|
||||
}
|
||||
}
|
||||
|
||||
function transportError(code: string, message: string): RuntimeRpcCallError {
|
||||
return new RuntimeRpcCallError({ id: 'rpc-1', ok: false, error: { code, message } })
|
||||
}
|
||||
|
||||
function mount(submissions: AgentJournalSubmission[] = []) {
|
||||
return renderHook(
|
||||
(props: { submissions: AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: TARGET,
|
||||
fence: 1,
|
||||
submissions: props.submissions,
|
||||
journalCursor: { epoch: 'epoch-1', sequence: 1 }
|
||||
}),
|
||||
{ initialProps: { submissions } }
|
||||
)
|
||||
}
|
||||
|
||||
describe('case 3: no answer yet', () => {
|
||||
it('a lost answer goes again under the same id, and the host replay settles it: one message', async () => {
|
||||
let calls = 0
|
||||
mocks.call.mockImplementation(async (_target, _method, params) => {
|
||||
calls += 1
|
||||
if (calls === 1) {
|
||||
throw transportError(
|
||||
'runtime_timeout',
|
||||
'Timed out waiting for the remote Orca runtime to respond.'
|
||||
)
|
||||
}
|
||||
return answer(params, 'accepted')
|
||||
})
|
||||
const { result } = mount()
|
||||
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed'))
|
||||
// No answer is no failure: nothing on the chat line, nothing handed back.
|
||||
expect(result.current.error).toBeNull()
|
||||
await waitFor(() => expect(result.current.outbox).toEqual([]), { timeout: 3000 })
|
||||
|
||||
expect(sentIds()).toHaveLength(2)
|
||||
expect(new Set(sentIds()).size).toBe(1)
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('')
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('held through an outage: sending, resent under its id until the host answers, then gone', async () => {
|
||||
let calls = 0
|
||||
mocks.call.mockImplementation(async (_target, _method, params) => {
|
||||
calls += 1
|
||||
if (calls <= 2) {
|
||||
throw transportError(
|
||||
'remote_runtime_unavailable',
|
||||
'Could not connect to the remote Orca runtime.'
|
||||
)
|
||||
}
|
||||
return answer(params, 'accepted')
|
||||
})
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
try {
|
||||
const { result } = mount()
|
||||
act(() => expect(result.current.send('during the outage')).toBe(true))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
// Never "not sent": the message stays, with its text, under the same id.
|
||||
expect(result.current.outbox).toMatchObject([{ state: 'unconfirmed' }])
|
||||
expect(result.current.error).toBeNull()
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
})
|
||||
expect(result.current.outbox).toEqual([])
|
||||
expect(sentIds()).toHaveLength(3)
|
||||
expect(new Set(sentIds()).size).toBe(1)
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('on a host whose answers prove nothing, a refused resend goes again instead of coming back', async () => {
|
||||
setLocalRuntimeCapabilitiesForTests([])
|
||||
let calls = 0
|
||||
mocks.call.mockImplementation(async (_target, _method, params) => {
|
||||
calls += 1
|
||||
if (calls === 1) {
|
||||
throw new Error('socket closed')
|
||||
}
|
||||
if (calls === 2) {
|
||||
return { ok: false, refusal: { code: 'agent_session_journal_unreadable', message: 'x' } }
|
||||
}
|
||||
return answer(params, 'accepted')
|
||||
})
|
||||
const { result } = mount()
|
||||
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox).toEqual([]), { timeout: 6000 })
|
||||
// An older host can refuse a resent id before looking it up: only the next resend can tell.
|
||||
expect(sentIds()).toHaveLength(3)
|
||||
expect(new Set(sentIds()).size).toBe(1)
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('')
|
||||
}, 10000)
|
||||
|
||||
it('on a host whose answers prove, the same refused resend comes back to the draft', async () => {
|
||||
let calls = 0
|
||||
mocks.call.mockImplementation(async () => {
|
||||
calls += 1
|
||||
if (calls === 1) {
|
||||
throw new Error('socket closed')
|
||||
}
|
||||
return { ok: false, refusal: { code: 'agent_session_journal_unreadable', message: 'x' } }
|
||||
})
|
||||
const { result } = mount()
|
||||
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox).toEqual([]), { timeout: 3000 })
|
||||
expect(sentIds()).toHaveLength(2)
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('hello')
|
||||
expect(result.current.error).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('case 2: the host proved no record', () => {
|
||||
it("goes back to the conversation's draft after what is typed, says why once, and never goes again", async () => {
|
||||
mocks.call.mockResolvedValue({
|
||||
ok: false,
|
||||
refusal: { code: 'agent_session_operation_capacity', message: 'busy' }
|
||||
})
|
||||
writeNativeChatDraftCache(SCOPE, 'typed meanwhile')
|
||||
const { result } = mount()
|
||||
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox).toEqual([]))
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('typed meanwhile\n\nhello')
|
||||
expect(result.current.error).not.toBeNull()
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 1300)))
|
||||
expect(mocks.call).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('still lands in the draft when the answer comes after the chat view unmounted', async () => {
|
||||
const reply = Promise.withResolvers<unknown>()
|
||||
mocks.call.mockImplementation(() => reply.promise)
|
||||
const view = mount()
|
||||
act(() => expect(view.result.current.send('closed before the answer')).toBe(true))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
|
||||
|
||||
view.unmount()
|
||||
await act(async () => {
|
||||
reply.resolve({
|
||||
ok: false,
|
||||
refusal: { code: 'agent_session_checkpoint_stale', message: 'stale' }
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => expect(readNativeChatDraftCache(SCOPE)).toBe('closed before the answer'))
|
||||
expect(getStructuredAgentSessionOutbox('session-1')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('case 1: the host has a record', () => {
|
||||
// Addendum 2: a head the host restarted under is the host's row, never a parked barrier.
|
||||
it('a head answered recovered-unknown leaves, and the next send goes out with no user action', async () => {
|
||||
mocks.call.mockImplementation(async (_target, _method, params) =>
|
||||
params.body.blocks[0]?.text === 'first'
|
||||
? answer(params, 'unknown', { recovered: true, reason: 'host restarted' })
|
||||
: answer(params, 'accepted')
|
||||
)
|
||||
const { result } = mount()
|
||||
|
||||
act(() => expect(result.current.send('first')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox).toEqual([]))
|
||||
act(() => expect(result.current.send('second')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox).toEqual([]))
|
||||
|
||||
expect(sentTexts()).toEqual(['first', 'second'])
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('')
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('a head the journal shows recovered-unknown leaves, and the one behind it goes out', async () => {
|
||||
mocks.call.mockImplementation(async (_target, _method, params) =>
|
||||
params.body.blocks[0]?.text === 'first' ? new Promise(() => {}) : answer(params, 'accepted')
|
||||
)
|
||||
const view = mount()
|
||||
act(() => expect(view.result.current.send('first')).toBe(true))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
|
||||
act(() => expect(view.result.current.send('second')).toBe(true))
|
||||
const firstId = sentIds()[0]!
|
||||
|
||||
view.rerender({
|
||||
submissions: [
|
||||
submission(firstId, 'unknown', { reason: 'host_restarted_before_acknowledgement' })
|
||||
]
|
||||
})
|
||||
|
||||
await waitFor(() => expect(sentTexts()).toEqual(['first', 'second']))
|
||||
await waitFor(() => expect(view.result.current.outbox).toEqual([]))
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('')
|
||||
})
|
||||
})
|
||||
-187
@@ -1,187 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
// A queued follow-up whose answer is out survives a Stop, since the host may hold it as a paused
|
||||
// card. From then on only the user's Retry sends it: the unconfirmed probe resending it onto the
|
||||
// now-idle session would start a turn the user just stopped.
|
||||
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
|
||||
type SentParams = { envelope: { clientOperationId: string }; delivery?: string }
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
call: vi.fn<(target: unknown, method: string, params: SentParams) => Promise<unknown>>()
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.call
|
||||
}))
|
||||
|
||||
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
|
||||
import { readOutbox, writeOutbox } from './structured-agent-session-outbox-storage'
|
||||
import {
|
||||
clearNativeChatDraftCacheForTests,
|
||||
readNativeChatDraftCache
|
||||
} from './native-chat-draft-cache'
|
||||
|
||||
// Why: every hook here shares the session outbox store; one left mounted would drain the next test's.
|
||||
afterEach(cleanup)
|
||||
|
||||
const TARGET = { kind: 'local' } as const
|
||||
const REMOTE = { kind: 'environment', environmentId: 'env-1' } as const
|
||||
const NOT_ATTACHED: { fence: number | null } = { fence: null }
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
clearNativeChatDraftCacheForTests()
|
||||
mocks.call.mockReset()
|
||||
mocks.call.mockImplementation(() => new Promise(() => {}))
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('a Stop with a queued send in doubt', () => {
|
||||
it('a remote send probed back to queued after a lost answer: Stop keeps it for Retry', async () => {
|
||||
// Lost answer, a re-probe that failed (capability unknown), and the probe flipped the entry
|
||||
// back to `queued`; the Stop lands before the drain sends it again.
|
||||
writeOutbox('session-1', [
|
||||
{
|
||||
...createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: 'probed',
|
||||
sessionId: 'session-1',
|
||||
text: 'follow-up',
|
||||
attachments: [],
|
||||
queuedAt: 1
|
||||
}),
|
||||
sentDelivery: 'queue-if-active',
|
||||
lastAttemptAt: 5,
|
||||
state: 'queued'
|
||||
}
|
||||
])
|
||||
const view = renderHook(
|
||||
(props: { fence: number | null }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: REMOTE,
|
||||
fence: props.fence,
|
||||
submissions: [],
|
||||
composerScopeKey: 'scope',
|
||||
queueDelivery: { capability: 'unknown', enabled: true }
|
||||
}),
|
||||
{ initialProps: NOT_ATTACHED }
|
||||
)
|
||||
act(() => {
|
||||
view.result.current.withdrawUnsent()
|
||||
})
|
||||
// The host may hold it as a paused card: the composer gets nothing.
|
||||
expect(readNativeChatDraftCache('scope')).toBe('')
|
||||
// Marked, state untouched: the mark alone holds it.
|
||||
expect(view.result.current.outbox.map((entry) => [entry.state, entry.outlivedStop])).toEqual([
|
||||
['queued', true]
|
||||
])
|
||||
view.rerender({ fence: 1 })
|
||||
// Past the probe's longest backoff several times over.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
expect(readOutbox('session-1').map((entry) => entry.clientMessageId)).toEqual(['probed'])
|
||||
|
||||
act(() => {
|
||||
view.result.current.retry('probed')
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
// The same operation with what it first sent, though the capability is still unknown.
|
||||
expect(mocks.call.mock.calls.map((call) => call[2].envelope.clientOperationId)).toEqual([
|
||||
'probed'
|
||||
])
|
||||
expect(mocks.call.mock.calls[0]?.[2].delivery).toBe('queue-if-active')
|
||||
})
|
||||
|
||||
it('never resends one a Stop found in flight whose answer later comes back unknown', async () => {
|
||||
const answer = Promise.withResolvers<unknown>()
|
||||
mocks.call.mockImplementationOnce(() => answer.promise)
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: TARGET,
|
||||
fence: 1,
|
||||
submissions: [],
|
||||
composerScopeKey: 'scope',
|
||||
queueDelivery: { capability: 'supported' as const, enabled: true }
|
||||
})
|
||||
)
|
||||
act(() => {
|
||||
result.current.send('follow-up')
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
expect(mocks.call).toHaveBeenCalledTimes(1)
|
||||
const id = mocks.call.mock.calls[0]?.[2].envelope.clientOperationId ?? ''
|
||||
act(() => {
|
||||
result.current.withdrawUnsent()
|
||||
})
|
||||
// The transport loses the answer: delivery unknown, after the Stop.
|
||||
await act(async () => {
|
||||
answer.reject(new Error('connection closed'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
expect(result.current.outbox.map((entry) => entry.state)).toEqual(['unconfirmed'])
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
expect(mocks.call).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
result.current.retry(id)
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
expect(mocks.call.mock.calls.map((call) => call[2].envelope.clientOperationId)).toEqual([
|
||||
id,
|
||||
id
|
||||
])
|
||||
})
|
||||
|
||||
it('the drain never sends one a Stop outlived, even after a reload left it queued', async () => {
|
||||
writeOutbox('session-1', [
|
||||
{
|
||||
...createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: 'outlived',
|
||||
sessionId: 'session-1',
|
||||
text: 'follow-up',
|
||||
attachments: [],
|
||||
queuedAt: 1
|
||||
}),
|
||||
sentDelivery: 'queue-if-active',
|
||||
lastAttemptAt: 5,
|
||||
outlivedStop: true,
|
||||
state: 'queued'
|
||||
}
|
||||
])
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: TARGET,
|
||||
fence: 1,
|
||||
submissions: [],
|
||||
queueDelivery: { capability: 'supported', enabled: true }
|
||||
})
|
||||
)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
// Held by the mark alone; its state, and so its notice, stays what its answer made it.
|
||||
expect(result.current.outbox.map((entry) => entry.state)).toEqual(['queued'])
|
||||
})
|
||||
})
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
// A send already on its way when the user presses Stop is stamped with the Stop's own id and never
|
||||
// sent again: a resend onto the session the user stopped would start a turn they just stopped. Its
|
||||
// own answer, its journal row, or the Stop's answer read through the journal settles it.
|
||||
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentJournalCursor,
|
||||
AgentJournalSubmission
|
||||
} from '../../../../shared/agent-session-journal-types'
|
||||
import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
|
||||
type SentParams = { envelope: { clientOperationId: string }; delivery?: string }
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
call: vi.fn<(target: unknown, method: string, params: SentParams) => Promise<unknown>>()
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.call
|
||||
}))
|
||||
|
||||
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
|
||||
import { readOutbox, writeOutbox } from './structured-agent-session-outbox-storage'
|
||||
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
|
||||
import {
|
||||
clearNativeChatDraftCacheForTests,
|
||||
readNativeChatDraftCache
|
||||
} from './native-chat-draft-cache'
|
||||
import { resetStructuredAgentSessionChatLinesForTests } from './structured-agent-session-returned-send'
|
||||
|
||||
// Why: every hook here shares the session outbox store; one left mounted would drain the next test's.
|
||||
afterEach(cleanup)
|
||||
|
||||
const TARGET = { kind: 'local' } as const
|
||||
const REMOTE = { kind: 'environment', environmentId: 'env-1' } as const
|
||||
const SCOPE = structuredAgentSessionDraftScopeKey('session-1')
|
||||
const CHECK_THE_CHAT =
|
||||
"Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed."
|
||||
|
||||
type Props = {
|
||||
fence: number | null
|
||||
submissions: AgentJournalSubmission[]
|
||||
journalCursor: AgentJournalCursor | null
|
||||
queuedMessageIds?: string[]
|
||||
}
|
||||
|
||||
function accepted(clientMessageId: string): AgentJournalSubmission {
|
||||
return {
|
||||
clientMessageId,
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'accepted',
|
||||
providerItemId: 'provider-1',
|
||||
reason: null,
|
||||
submittedAt: 10,
|
||||
resolvedAt: 10
|
||||
}
|
||||
}
|
||||
|
||||
function mount(initialProps: Props, target: typeof TARGET | typeof REMOTE = TARGET, queue = false) {
|
||||
return renderHook(
|
||||
(props: Props) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target,
|
||||
fence: props.fence,
|
||||
submissions: props.submissions,
|
||||
journalCursor: props.journalCursor,
|
||||
...(props.queuedMessageIds ? { queuedMessageIds: props.queuedMessageIds } : {}),
|
||||
queueDelivery: queue
|
||||
? { capability: 'supported', enabled: true }
|
||||
: { capability: 'unsupported', enabled: false }
|
||||
}),
|
||||
{ initialProps }
|
||||
)
|
||||
}
|
||||
|
||||
function seedAttempted(patch: Record<string, unknown> = {}): void {
|
||||
writeOutbox('session-1', [
|
||||
{
|
||||
...createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: 'out',
|
||||
sessionId: 'session-1',
|
||||
text: 'follow-up',
|
||||
attachments: [],
|
||||
queuedAt: 1
|
||||
}),
|
||||
lastAttemptAt: 5,
|
||||
state: 'unconfirmed',
|
||||
...patch
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
clearNativeChatDraftCacheForTests()
|
||||
resetStructuredAgentSessionChatLinesForTests()
|
||||
mocks.call.mockReset()
|
||||
mocks.call.mockImplementation(() => new Promise(() => {}))
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('a send a Stop outran', () => {
|
||||
it('is never sent again, then comes back silently once the journal is read through the Stop', async () => {
|
||||
// Lost answer; the resend flipped it back to queued; the Stop lands before the drain sends it.
|
||||
seedAttempted({ state: 'queued', sentDelivery: 'queue-if-active' })
|
||||
const view = mount({ fence: null, submissions: [], journalCursor: null }, REMOTE, true)
|
||||
act(() => view.result.current.stop('stop-1'))
|
||||
// The host may hold it as a paused card: the composer gets nothing yet.
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('')
|
||||
view.rerender({ fence: 1, submissions: [], journalCursor: { epoch: 'e', sequence: 3 } })
|
||||
// Past the resend's longest backoff several times over.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
|
||||
act(() =>
|
||||
view.result.current.recordStopAnswer('stop-1', {
|
||||
kind: 'answered',
|
||||
cursor: { epoch: 'e', sequence: 7 }
|
||||
})
|
||||
)
|
||||
// Read through the Stop, but no draft list yet: a queued draft is no journal row.
|
||||
view.rerender({ fence: 1, submissions: [], journalCursor: { epoch: 'e', sequence: 7 } })
|
||||
expect(view.result.current.outbox).toHaveLength(1)
|
||||
view.rerender({
|
||||
fence: 1,
|
||||
submissions: [],
|
||||
journalCursor: { epoch: 'e', sequence: 7 },
|
||||
queuedMessageIds: []
|
||||
})
|
||||
expect(view.result.current.outbox).toEqual([])
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('follow-up')
|
||||
// The user stopped it: nothing failed, so nothing is said.
|
||||
expect(view.result.current.error).toBeNull()
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('in flight at the Stop, its answer lost: never resent; its row once the Stop answers drops it', async () => {
|
||||
const answer = Promise.withResolvers<unknown>()
|
||||
mocks.call.mockImplementationOnce(() => answer.promise)
|
||||
const view = mount({ fence: 1, submissions: [], journalCursor: { epoch: 'e', sequence: 1 } })
|
||||
act(() => {
|
||||
view.result.current.send('follow-up')
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
expect(mocks.call).toHaveBeenCalledTimes(1)
|
||||
const id = mocks.call.mock.calls[0]?.[2].envelope.clientOperationId ?? ''
|
||||
act(() => view.result.current.stop('stop-1'))
|
||||
await act(async () => {
|
||||
answer.reject(new Error('connection closed'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
// No answer leaves it waiting for the Stop's; the call count stays one.
|
||||
expect(mocks.call).toHaveBeenCalledTimes(1)
|
||||
expect(readOutbox('session-1')).toMatchObject([
|
||||
{ clientMessageId: id, stoppedBy: { operationId: 'stop-1' } }
|
||||
])
|
||||
|
||||
act(() =>
|
||||
view.result.current.recordStopAnswer('stop-1', {
|
||||
kind: 'answered',
|
||||
cursor: { epoch: 'e', sequence: 5 }
|
||||
})
|
||||
)
|
||||
view.rerender({
|
||||
fence: 1,
|
||||
submissions: [accepted(id)],
|
||||
journalCursor: { epoch: 'e', sequence: 5 }
|
||||
})
|
||||
expect(view.result.current.outbox).toEqual([])
|
||||
// The host has it: the row shows it, and nothing is handed back.
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('')
|
||||
expect(mocks.call).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('in flight at the Stop, its answer lost, no row by the Stop: back in the draft silently', async () => {
|
||||
mocks.call.mockRejectedValueOnce(new Error('connection closed'))
|
||||
const view = mount({ fence: 1, submissions: [], journalCursor: { epoch: 'e', sequence: 1 } })
|
||||
act(() => {
|
||||
view.result.current.send('follow-up')
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
act(() => view.result.current.stop('stop-1'))
|
||||
act(() =>
|
||||
view.result.current.recordStopAnswer('stop-1', {
|
||||
kind: 'answered',
|
||||
cursor: { epoch: 'e', sequence: 5 }
|
||||
})
|
||||
)
|
||||
view.rerender({ fence: 1, submissions: [], journalCursor: { epoch: 'e', sequence: 5 } })
|
||||
expect(view.result.current.outbox).toEqual([])
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('follow-up')
|
||||
expect(view.result.current.error).toBeNull()
|
||||
expect(mocks.call).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a refused Stop hands it back to check the chat, once the journal has loaded with no row', async () => {
|
||||
seedAttempted()
|
||||
const view = mount({ fence: 1, submissions: [], journalCursor: null })
|
||||
act(() => view.result.current.stop('stop-1'))
|
||||
act(() => view.result.current.recordStopAnswer('stop-1', { kind: 'unanswerable' }))
|
||||
// Not loaded: nothing can say yet.
|
||||
expect(view.result.current.outbox).toHaveLength(1)
|
||||
view.rerender({ fence: 1, submissions: [], journalCursor: { epoch: 'e', sequence: 2 } })
|
||||
expect(view.result.current.outbox).toEqual([])
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('follow-up')
|
||||
expect(view.result.current.error).toBe(CHECK_THE_CHAT)
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a message an older build held for a Retry', () => {
|
||||
it.each([
|
||||
['outlived by a Stop', { outlivedStop: true, state: 'queued' }],
|
||||
['rejected', { state: 'rejected', lastFailure: { kind: 'rejected', reason: null } }],
|
||||
['held with its failure', { state: 'queued', lastFailure: { kind: 'failed' } }]
|
||||
])('one %s is never sent, and settles once the journal loads', async (_label, saved) => {
|
||||
seedAttempted(saved)
|
||||
const view = mount({ fence: 1, submissions: [], journalCursor: null })
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
expect(view.result.current.outbox).toMatchObject([{ legacyUnsettled: true }])
|
||||
|
||||
// No row: the person was told it did not go, so it comes back, with words to check the chat.
|
||||
view.rerender({ fence: 1, submissions: [], journalCursor: { epoch: 'e', sequence: 2 } })
|
||||
expect(view.result.current.outbox).toEqual([])
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('follow-up')
|
||||
expect(view.result.current.error).toBe(CHECK_THE_CHAT)
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('one the journal holds a row for is dropped, with nothing handed back', async () => {
|
||||
seedAttempted({ state: 'rejected' })
|
||||
const view = mount({ fence: 1, submissions: [], journalCursor: null })
|
||||
view.rerender({
|
||||
fence: 1,
|
||||
submissions: [{ ...accepted('out'), dispatchState: 'rejected', reason: 'not_delivered' }],
|
||||
journalCursor: { epoch: 'e', sequence: 2 }
|
||||
})
|
||||
expect(view.result.current.outbox).toEqual([])
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('')
|
||||
expect(view.result.current.error).toBeNull()
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
+90
-250
@@ -7,6 +7,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire'
|
||||
import { enqueueStructuredAgentSessionLaunchPrompt } from './structured-agent-session-outbox-storage'
|
||||
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
|
||||
import {
|
||||
clearNativeChatDraftCacheForTests,
|
||||
readNativeChatDraftCache
|
||||
} from './native-chat-draft-cache'
|
||||
import { resetStructuredAgentSessionChatLinesForTests } from './structured-agent-session-returned-send'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
call: vi.fn()
|
||||
@@ -132,6 +138,8 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
clearNativeChatDraftCacheForTests()
|
||||
resetStructuredAgentSessionChatLinesForTests()
|
||||
randomUuidSequence = 0
|
||||
vi.spyOn(globalThis.crypto, 'randomUUID').mockImplementation(() => {
|
||||
randomUuidSequence += 1
|
||||
@@ -235,14 +243,16 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
|
||||
})
|
||||
|
||||
it.each(['agent_session_operation_conflict', 'agent_session_operation_expired'] as const)(
|
||||
'rotates a send operation after %s',
|
||||
async (code) => {
|
||||
// oxlint-disable-next-line no-restricted-properties -- stubbing the global the generator reads, to pin ids in this test
|
||||
vi.mocked(globalThis.crypto.randomUUID)
|
||||
.mockReturnValueOnce('11111111-1111-4111-8111-111111111111')
|
||||
.mockReturnValueOnce('22222222-2222-4222-8222-222222222222')
|
||||
mocks.call.mockResolvedValueOnce(refusedResult(code)).mockResolvedValueOnce(acceptedResult(1))
|
||||
it.each([
|
||||
['agent_session_operation_conflict', 'Your message was not sent.'],
|
||||
[
|
||||
'agent_session_operation_expired',
|
||||
"Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed."
|
||||
]
|
||||
] as const)(
|
||||
'gives a first attempt the host refused with %s back to the chat draft, once',
|
||||
async (code, words) => {
|
||||
mocks.call.mockResolvedValueOnce(refusedResult(code))
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
@@ -253,18 +263,13 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
)
|
||||
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected'))
|
||||
const firstId = (mocks.call.mock.calls[0]![2] as { envelope: { clientOperationId: string } })
|
||||
.envelope.clientOperationId
|
||||
const retryId = result.current.outbox[0]!.clientMessageId
|
||||
expect(retryId).not.toBe(firstId)
|
||||
|
||||
act(() => result.current.retry(retryId))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
expect(
|
||||
(mocks.call.mock.calls[1]![2] as { envelope: { clientOperationId: string } }).envelope
|
||||
.clientOperationId
|
||||
).toBe(retryId)
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
|
||||
expect(readNativeChatDraftCache(structuredAgentSessionDraftScopeKey('session-1'))).toBe(
|
||||
'hello'
|
||||
)
|
||||
expect(result.current.error).toBe(words)
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 50)))
|
||||
expect(mocks.call).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -328,8 +333,8 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1))
|
||||
const id = result.current.outbox[0]!.clientMessageId
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed'))
|
||||
// The Retry row already says delivery is unconfirmed; nothing repeats it.
|
||||
expect([result.current.error, result.current.outbox[0]?.lastFailure]).toEqual([null, undefined])
|
||||
// No answer is no failure: nothing on the chat line, and the text stays on its bubble.
|
||||
expect(result.current.error).toBeNull()
|
||||
|
||||
rerender({
|
||||
submissions: [
|
||||
@@ -456,63 +461,18 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
expect(mocks.call).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('drains a head the host refuses to redeliver so the queue behind it advances', async () => {
|
||||
// The guard refuses a retry it cannot prove is a first delivery. That must
|
||||
// not leave a Retry that does nothing in front of a wedged queue: the entry
|
||||
// leaves the outbox, the user is told to check the chat before sending it again, and the
|
||||
// message queued behind it goes out.
|
||||
// The second send never settles, so the refusal's error is still on screen
|
||||
// when the queue behind it advances.
|
||||
it('lets a head the host answers with a row in doubt leave, so the queue behind it advances', async () => {
|
||||
// The host has a record of it, so the row shows it from here: nothing waits on the user.
|
||||
mocks.call.mockImplementation(async (_target, _method, params) => {
|
||||
const request = params as {
|
||||
envelope: { clientOperationId: string }
|
||||
body: { blocks: { text?: string }[] }
|
||||
}
|
||||
if (request.body.blocks[0]?.text === 'second') {
|
||||
return new Promise(() => {})
|
||||
return acceptedResultFor(request.envelope.clientOperationId, 11)
|
||||
}
|
||||
return unknownResultFor(request.envelope.clientOperationId, 10)
|
||||
})
|
||||
const { result, rerender } = renderHook(
|
||||
({ submissions }: { submissions: readonly AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
submissions
|
||||
}),
|
||||
{ initialProps: { submissions: [] as readonly AgentJournalSubmission[] } }
|
||||
)
|
||||
|
||||
act(() => expect(result.current.send('first')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed'))
|
||||
const firstId = result.current.outbox[0]!.clientMessageId
|
||||
rerender({ submissions: [unknownResultFor(firstId, 10).value.submission] })
|
||||
|
||||
act(() => expect(result.current.send('second')).toBe(true))
|
||||
expect(result.current.outbox).toHaveLength(2)
|
||||
|
||||
act(() => result.current.retry(firstId))
|
||||
await waitFor(() =>
|
||||
expect(result.current.outbox.some((entry) => entry.clientMessageId === firstId)).toBe(false)
|
||||
)
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
})
|
||||
|
||||
const sent = mocks.call.mock.calls.map(
|
||||
(call) => (call[2] as { body?: { blocks?: { text?: string }[] } })?.body?.blocks?.[0]?.text
|
||||
)
|
||||
expect(sent).toContain('second')
|
||||
expect(result.current.error).toBe(
|
||||
"Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed."
|
||||
)
|
||||
})
|
||||
|
||||
it('retains a send operation after a pending-admission refusal', async () => {
|
||||
mocks.call
|
||||
.mockResolvedValueOnce(refusedResult('agent_session_checkpoint_stale'))
|
||||
.mockResolvedValueOnce(acceptedResult(1))
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
@@ -522,21 +482,19 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
})
|
||||
)
|
||||
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('queued'))
|
||||
const firstId = (mocks.call.mock.calls[0]![2] as { envelope: { clientOperationId: string } })
|
||||
.envelope.clientOperationId
|
||||
expect(result.current.outbox[0]?.clientMessageId).toBe(firstId)
|
||||
act(() => expect(result.current.send('first')).toBe(true))
|
||||
act(() => expect(result.current.send('second')).toBe(true))
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
|
||||
|
||||
act(() => result.current.retry(firstId))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
expect(
|
||||
(mocks.call.mock.calls[1]![2] as { envelope: { clientOperationId: string } }).envelope
|
||||
.clientOperationId
|
||||
).toBe(firstId)
|
||||
const sent = mocks.call.mock.calls.map(
|
||||
(call) => (call[2] as { body?: { blocks?: { text?: string }[] } })?.body?.blocks?.[0]?.text
|
||||
)
|
||||
expect(sent).toEqual(['first', 'second'])
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(readNativeChatDraftCache(structuredAgentSessionDraftScopeKey('session-1'))).toBe('')
|
||||
})
|
||||
|
||||
it('stops on a host that could not restart the agent and keeps its refusal on the message', async () => {
|
||||
it('gives a message back to the draft when the host could not restart the agent, saying why once', async () => {
|
||||
const message = "Claude couldn't restart: Not logged in. Please run /login."
|
||||
mocks.call.mockResolvedValue({
|
||||
ok: false,
|
||||
@@ -552,29 +510,17 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
)
|
||||
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
await waitFor(() =>
|
||||
expect(result.current.outbox[0]?.lastFailure).toEqual({
|
||||
kind: 'refused',
|
||||
code: 'agent_session_owner_restart_failed'
|
||||
})
|
||||
)
|
||||
expect(result.current.error).toBeNull()
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
|
||||
expect(readNativeChatDraftCache(structuredAgentSessionDraftScopeKey('session-1'))).toBe('hello')
|
||||
expect(result.current.error).not.toBeNull()
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 50)))
|
||||
|
||||
// Never sent again on its own: sending it again is the user's new message.
|
||||
expect(mocks.call).toHaveBeenCalledOnce()
|
||||
expect(result.current.outbox).toHaveLength(1)
|
||||
// Never sent, and never re-sent on its own: it waits for Retry and holds nothing up.
|
||||
expect(result.current.outbox[0]?.state).toBe('rejected')
|
||||
// Settled, not pending: the refused id never ran, so a Retry is a new operation.
|
||||
const sentId: unknown = mocks.call.mock.calls[0]![2].envelope.clientOperationId
|
||||
const retryId = result.current.outbox[0]!.clientMessageId
|
||||
expect(retryId).not.toBe(sentId)
|
||||
|
||||
// A manual Retry sends again, which is what asks the host for another restart.
|
||||
act(() => result.current.retry(retryId))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
const retriedId: unknown = mocks.call.mock.calls[1]![2].envelope.clientOperationId
|
||||
expect(retriedId).toBe(retryId)
|
||||
// The next send clears the line.
|
||||
mocks.call.mockResolvedValue(acceptedResult(1))
|
||||
act(() => expect(result.current.send('again')).toBe(true))
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('persists and dispatches an attachment-only structured send', async () => {
|
||||
@@ -604,91 +550,17 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('retries an unknown head and advances a queued tail', async () => {
|
||||
// oxlint-disable-next-line no-restricted-properties -- stubbing the global the generator reads, to pin ids in this test
|
||||
vi.mocked(globalThis.crypto.randomUUID)
|
||||
.mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')
|
||||
.mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb')
|
||||
it('lets a head the journal rejects leave without a resend, so the queued tail advances', async () => {
|
||||
mocks.call
|
||||
.mockImplementationOnce(async (_target, _method, params) => {
|
||||
const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope
|
||||
.clientOperationId
|
||||
return unknownResultFor(clientMessageId, 10)
|
||||
})
|
||||
.mockImplementationOnce(async (_target, _method, params) => {
|
||||
const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope
|
||||
.clientOperationId
|
||||
return acceptedResultFor(clientMessageId, 11)
|
||||
})
|
||||
.mockImplementationOnce(async (_target, _method, params) => {
|
||||
const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope
|
||||
.clientOperationId
|
||||
return acceptedResultFor(clientMessageId, 12)
|
||||
})
|
||||
const { result, rerender } = renderHook(
|
||||
({ submissions }: { submissions: readonly AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
submissions
|
||||
}),
|
||||
{ initialProps: { submissions: [] as readonly AgentJournalSubmission[] } }
|
||||
)
|
||||
|
||||
act(() => {
|
||||
expect(result.current.send('first')).toBe(true)
|
||||
})
|
||||
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed'))
|
||||
const firstId = result.current.outbox[0]!.clientMessageId
|
||||
rerender({
|
||||
submissions: [
|
||||
{
|
||||
clientMessageId: firstId,
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'unknown',
|
||||
providerItemId: null,
|
||||
reason: 'socket closed',
|
||||
submittedAt: 10,
|
||||
resolvedAt: 10
|
||||
}
|
||||
]
|
||||
})
|
||||
act(() => {
|
||||
expect(result.current.send('second')).toBe(true)
|
||||
})
|
||||
expect(result.current.outbox).toHaveLength(2)
|
||||
|
||||
act(() => result.current.retry(firstId))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(3))
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
|
||||
const retryParams = mocks.call.mock.calls[1]?.[2] as { retryUnknown?: true } | undefined
|
||||
expect(retryParams?.retryUnknown).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rotates a history-rejected unknown head so the queued tail can advance', async () => {
|
||||
// oxlint-disable-next-line no-restricted-properties -- stubbing the global the generator reads, to pin ids in this test
|
||||
vi.mocked(globalThis.crypto.randomUUID)
|
||||
.mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')
|
||||
.mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb')
|
||||
.mockReturnValueOnce('cccccccc-cccc-4ccc-8ccc-cccccccccccc')
|
||||
mocks.call
|
||||
.mockImplementationOnce(async (_target, _method, params) => {
|
||||
const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope
|
||||
.clientOperationId
|
||||
return unknownResultFor(clientMessageId, 10)
|
||||
})
|
||||
.mockImplementationOnce(async (_target, _method, params) => {
|
||||
const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope
|
||||
.clientOperationId
|
||||
return acceptedResultFor(clientMessageId, 11)
|
||||
})
|
||||
.mockImplementationOnce(async (_target, _method, params) => {
|
||||
const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope
|
||||
.clientOperationId
|
||||
return acceptedResultFor(clientMessageId, 12)
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new Error('socket closed')
|
||||
})
|
||||
.mockImplementation(async (_target, _method, params) =>
|
||||
acceptedResultFor(
|
||||
(params as { envelope: { clientOperationId: string } }).envelope.clientOperationId,
|
||||
11
|
||||
)
|
||||
)
|
||||
const { result, rerender } = renderHook(
|
||||
({ submissions }: { submissions: readonly AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
@@ -719,20 +591,17 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
]
|
||||
})
|
||||
|
||||
act(() => result.current.retry(firstId))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(3))
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
|
||||
const retryParams = mocks.call.mock.calls[1]?.[2] as
|
||||
| { envelope: { clientOperationId: string } }
|
||||
| undefined
|
||||
expect(retryParams?.envelope.clientOperationId).not.toBe(firstId)
|
||||
const ids = mocks.call.mock.calls.map(
|
||||
(call) => (call[2] as { envelope: { clientOperationId: string } }).envelope.clientOperationId
|
||||
)
|
||||
// The first went once; its row says it was not sent, and its text is not handed back.
|
||||
expect(ids.filter((id) => id === firstId)).toHaveLength(1)
|
||||
expect(ids).toHaveLength(2)
|
||||
expect(readNativeChatDraftCache(structuredAgentSessionDraftScopeKey('session-1'))).toBe('')
|
||||
})
|
||||
|
||||
it('rotates the id after a refused write and delivers the message exactly once', async () => {
|
||||
// oxlint-disable-next-line no-restricted-properties -- stubbing the global the generator reads, to pin ids in this test
|
||||
vi.mocked(globalThis.crypto.randomUUID)
|
||||
.mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')
|
||||
.mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb')
|
||||
it('leaves a send the host recorded and rejected to its row: no resend, nothing handed back', async () => {
|
||||
const writeFailed = (clientMessageId: string) => ({
|
||||
clientMessageId,
|
||||
fence: 1,
|
||||
@@ -743,66 +612,37 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
submittedAt: 10,
|
||||
resolvedAt: 10
|
||||
})
|
||||
mocks.call
|
||||
.mockImplementationOnce(async (_target, _method, params) => ({
|
||||
mocks.call.mockImplementation(async (_target, _method, params) => {
|
||||
const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope
|
||||
.clientOperationId
|
||||
return {
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-1', sequence: 10 },
|
||||
value: {
|
||||
clientMessageId: (params as { envelope: { clientOperationId: string } }).envelope
|
||||
.clientOperationId,
|
||||
submission: writeFailed(
|
||||
(params as { envelope: { clientOperationId: string } }).envelope.clientOperationId
|
||||
)
|
||||
}
|
||||
}))
|
||||
.mockImplementationOnce(async (_target, _method, params) =>
|
||||
acceptedResultFor(
|
||||
(params as { envelope: { clientOperationId: string } }).envelope.clientOperationId,
|
||||
11
|
||||
)
|
||||
)
|
||||
const { result } = renderHook(
|
||||
({ submissions }: { submissions: readonly AgentJournalSubmission[] }) =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
submissions
|
||||
}),
|
||||
{ initialProps: { submissions: [] as readonly AgentJournalSubmission[] } }
|
||||
value: { clientMessageId, submission: writeFailed(clientMessageId) }
|
||||
}
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
submissions: []
|
||||
})
|
||||
)
|
||||
|
||||
act(() => expect(result.current.send('first')).toBe(true))
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
|
||||
const firstId = mocks.call.mock.calls[0]![2].envelope.clientOperationId as string
|
||||
|
||||
// A refused write is answered, not doubted: the entry parks with its rejection rather than
|
||||
// under the "delivery is unconfirmed" banner. The disposition tests pin its words.
|
||||
await waitFor(() => expect(result.current.outbox[0]?.lastFailure?.kind).toBe('rejected'))
|
||||
expect(result.current.outbox[0]?.state).toBe('rejected')
|
||||
|
||||
// Retry immediately, before the journal subscription can publish the rejected row.
|
||||
act(() => result.current.retry(firstId))
|
||||
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
|
||||
|
||||
// Exactly one further delivery, under a new id, and with no `retryUnknown`:
|
||||
// this is a first delivery of a new message, so it cannot duplicate.
|
||||
expect(mocks.call).toHaveBeenCalledTimes(2)
|
||||
const retryParams = mocks.call.mock.calls[1]?.[2] as {
|
||||
envelope: { clientOperationId: string }
|
||||
retryUnknown?: true
|
||||
}
|
||||
expect(retryParams.envelope.clientOperationId).not.toBe(firstId)
|
||||
expect(retryParams.retryUnknown).toBeUndefined()
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 50)))
|
||||
expect(mocks.call).toHaveBeenCalledOnce()
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(readNativeChatDraftCache(structuredAgentSessionDraftScopeKey('session-1'))).toBe('')
|
||||
})
|
||||
|
||||
it('loads the new session outbox when a pane switches sessions', async () => {
|
||||
mocks.call.mockImplementationOnce(async (_target, _method, params) => {
|
||||
const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope
|
||||
.clientOperationId
|
||||
return unknownResultFor(clientMessageId, 10)
|
||||
mocks.call.mockImplementationOnce(async () => {
|
||||
throw new Error('socket closed')
|
||||
})
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
@@ -832,7 +672,7 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a session error on switch and does not resurrect it on return', async () => {
|
||||
it("keeps a chat's line to that chat: another session never shows it", async () => {
|
||||
const redispatch = deferred<ReturnType<typeof acceptedResult>>()
|
||||
mocks.call
|
||||
.mockResolvedValueOnce(refusedResult('agent_session_checkpoint_stale'))
|
||||
@@ -849,15 +689,15 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
)
|
||||
|
||||
act(() => expect(result.current.send('hello')).toBe(true))
|
||||
const refused = { kind: 'refused', code: 'agent_session_checkpoint_stale' }
|
||||
await waitFor(() => expect(result.current.outbox[0]?.lastFailure).toEqual(refused))
|
||||
expect(result.current.error).toBeNull()
|
||||
await waitFor(() => expect(result.current.error).not.toBeNull())
|
||||
const line = result.current.error
|
||||
|
||||
rerender({ sessionId: 'session-2' })
|
||||
expect(result.current.error).toBeNull()
|
||||
|
||||
// Its text is still in that chat's draft, so the line still says why.
|
||||
rerender({ sessionId: 'session-1' })
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(result.current.error).toBe(line)
|
||||
})
|
||||
|
||||
it('invalidates an old dispatch before it settles during a session switch', async () => {
|
||||
|
||||
@@ -6,17 +6,19 @@ import {
|
||||
useState,
|
||||
useSyncExternalStore
|
||||
} from 'react'
|
||||
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
|
||||
import type {
|
||||
AgentJournalCursor,
|
||||
AgentJournalSubmission
|
||||
} from '../../../../shared/agent-session-journal-types'
|
||||
import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation'
|
||||
import { admitStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-admission'
|
||||
import { STRUCTURED_AGENT_SESSION_OUTBOX_NOT_SAVED } from '../../../../shared/structured-agent-session-send-failure-words'
|
||||
import {
|
||||
admitStructuredAgentSessionOutboxEntry,
|
||||
structuredAgentSessionEntryHeldForRetry
|
||||
} from '../../../../shared/structured-agent-session-outbox-admission'
|
||||
import {
|
||||
journalAnswersInFlightSend,
|
||||
STRUCTURED_AGENT_SESSION_OUTBOX_NOT_SAVED,
|
||||
type StructuredAgentSessionSendDisposition
|
||||
} from '../../../../shared/structured-agent-session-send-disposition'
|
||||
applyStructuredAgentSessionSendSettlement,
|
||||
settleStructuredAgentSessionEntryFromJournal,
|
||||
type StructuredAgentSessionSettledOutbox
|
||||
} from '../../../../shared/structured-agent-session-send-settlement'
|
||||
import { stopStructuredAgentSessionOutbox } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
import {
|
||||
appendStructuredAgentSessionOutboxMessage,
|
||||
@@ -35,19 +37,15 @@ import { getStructuredAgentLaunchPromptDispatch } from '@/lib/structured-agent-s
|
||||
import { useStructuredAgentSessionOutboxOwnerChange } from '@/runtime/structured-agent-session-accepted-send-capability'
|
||||
import { useStructuredAgentSessionOutboxUnconfirmedProbe } from './use-structured-agent-session-outbox-unconfirmed-probe'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { useStructuredAgentSessionWithdrawnRestore } from './structured-agent-session-withdrawn-message-restore'
|
||||
import { useStructuredAgentSessionOutboxOwnership } from './use-structured-agent-session-outbox-ownership'
|
||||
import {
|
||||
handedOffQueuedMessageIds,
|
||||
reconcileStructuredAgentSessionOutboxWithQueue
|
||||
} from '../../../../shared/structured-agent-session-draft-hand-off'
|
||||
returnStructuredAgentSessionMessage,
|
||||
setStructuredAgentSessionChatLine,
|
||||
useStructuredAgentSessionChatLine
|
||||
} from './structured-agent-session-returned-send'
|
||||
import {
|
||||
structuredAgentSessionEntryAttempt,
|
||||
type StructuredAgentSessionQueueDelivery
|
||||
} from '../../../../shared/structured-agent-session-outbox-delivery'
|
||||
import { retryStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-retry'
|
||||
import { useStructuredAgentSessionOutboxFailedHere } from './use-structured-agent-session-outbox-failed-here'
|
||||
import { agentSessionWriteNoticeText } from './agent-session-write-notice-text'
|
||||
|
||||
const NO_QUEUE_DELIVERY: StructuredAgentSessionQueueDelivery = {
|
||||
capability: 'unsupported',
|
||||
@@ -58,24 +56,30 @@ export function structuredSessionOperationId(): string {
|
||||
return createStructuredAgentSessionOperationId(createBrowserUuid)
|
||||
}
|
||||
|
||||
/** What a Stop's request got back, for the sends it outran. */
|
||||
export type StructuredAgentSessionStopAnswer =
|
||||
/** The host ran it: where its journal stood. */
|
||||
| { kind: 'answered'; cursor: AgentJournalCursor }
|
||||
/** The host refused it, or it can't be sent again without stopping something newer. */
|
||||
| { kind: 'unanswerable' }
|
||||
|
||||
export function useStructuredAgentSessionOutbox(args: {
|
||||
sessionId: string
|
||||
target: RuntimeClientTarget
|
||||
fence: number | null
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
/** The composer that gets back what a Stop withdrew from this client's outbox. */
|
||||
composerScopeKey?: string
|
||||
/** How far the journal has been read; null until a page has loaded. */
|
||||
journalCursor?: AgentJournalCursor | null
|
||||
/** The host's queued-messages capability and the user's setting; a send stamped
|
||||
* `delivery: 'queue-if-active'` is held as a draft only while the agent is working. */
|
||||
queueDelivery?: StructuredAgentSessionQueueDelivery
|
||||
/** Ids of the host's published drafts. A queued send whose acknowledgement was lost keeps
|
||||
* its entry here under the draft's own id; once the host visibly holds the draft, the
|
||||
* entry retires so the same text can never come back twice. */
|
||||
/** Ids of the host's published drafts, or undefined before it publishes a list. An entry under
|
||||
* one of these ids belongs to the host: its card carries the text. */
|
||||
queuedMessageIds?: readonly string[]
|
||||
}) {
|
||||
const {
|
||||
composerScopeKey,
|
||||
fence,
|
||||
journalCursor = null,
|
||||
queueDelivery = NO_QUEUE_DELIVERY,
|
||||
queuedMessageIds,
|
||||
sessionId,
|
||||
@@ -83,9 +87,8 @@ export function useStructuredAgentSessionOutbox(args: {
|
||||
target
|
||||
} = args
|
||||
const { capability: queueCapability, enabled: queueEnabled } = queueDelivery
|
||||
// What resends and drops a send in flight besides a Retry or a new send; see the hook.
|
||||
// What resends and drops a send in flight besides a new send; see the hook.
|
||||
const owner = useStructuredAgentSessionOutboxOwnerChange(target, fence)
|
||||
const restoreWithdrawn = useStructuredAgentSessionWithdrawnRestore(sessionId, composerScopeKey)
|
||||
// The outbox lives in the session's store, shared with every other writer; this view holds it
|
||||
// open and drains it. Loading maps what a previous owner left mid-send.
|
||||
const load = useCallback(
|
||||
@@ -106,16 +109,11 @@ export function useStructuredAgentSessionOutbox(args: {
|
||||
// "which entry" must never disagree: the journal can settle the tail while the head moves.
|
||||
const inFlightIdRef = useRef<string | null>(null)
|
||||
const dispatchGenerationRef = useRef(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { failedHere, recordFailures, forget } =
|
||||
useStructuredAgentSessionOutboxFailedHere(sessionId)
|
||||
const [errorSession, setErrorSession] = useState(sessionId)
|
||||
// Render-time reset (react.dev: adjusting state when a prop changes), so the
|
||||
// old session's banner neither flashes for a frame nor resurrects on return.
|
||||
if (errorSession !== sessionId) {
|
||||
setErrorSession(sessionId)
|
||||
setError(null)
|
||||
}
|
||||
const submissionsRef = useRef(submissions)
|
||||
useLayoutEffect(() => {
|
||||
submissionsRef.current = submissions
|
||||
}, [submissions])
|
||||
const error = useStructuredAgentSessionChatLine(sessionId)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
dispatchGenerationRef.current += 1
|
||||
@@ -136,62 +134,69 @@ export function useStructuredAgentSessionOutbox(args: {
|
||||
}
|
||||
}, [owner.fenceRef, owner.ownerChange, sessionId, target])
|
||||
|
||||
useEffect(() => {
|
||||
const current = getStructuredAgentSessionOutbox(sessionId)
|
||||
const hostOwns = new Set([
|
||||
...submissions
|
||||
.filter(
|
||||
(submission) =>
|
||||
submission.dispatchState === 'pending' || submission.dispatchState === 'accepted'
|
||||
)
|
||||
.map((submission) => submission.clientMessageId),
|
||||
...handedOffQueuedMessageIds(submissions)
|
||||
])
|
||||
const next = reconcileStructuredAgentSessionOutboxWithQueue(current, submissions)
|
||||
const admittedInFlight = journalAnswersInFlightSend(submissions, inFlightIdRef.current)
|
||||
if (
|
||||
admittedInFlight ||
|
||||
next.some((entry, index) => entry !== current[index]) ||
|
||||
next.length !== current.length
|
||||
) {
|
||||
restoreWithdrawn.byHost(current, submissions)
|
||||
commitStructuredAgentSessionOutbox(sessionId, next)
|
||||
}
|
||||
// Keyed on the entry actually in flight, which is no longer always the head: the journal
|
||||
// owning it outranks a send promise that has not settled, so release single-flight and make
|
||||
// that promise a no-op. Keying on the head would discard the tail's unsettled send instead,
|
||||
// and with it a refusal only that send can report.
|
||||
if (admittedInFlight) {
|
||||
dispatchGenerationRef.current += 1
|
||||
inFlightIdRef.current = null
|
||||
}
|
||||
if (
|
||||
current.some(
|
||||
(entry) =>
|
||||
(entry.state === 'unconfirmed' || structuredAgentSessionEntryHeldForRetry(entry)) &&
|
||||
hostOwns.has(entry.clientMessageId)
|
||||
)
|
||||
) {
|
||||
setError(null)
|
||||
}
|
||||
}, [restoreWithdrawn, sessionId, submissions])
|
||||
|
||||
// The one place that owns the refs, the React state and the storage write.
|
||||
const applyDisposition = useCallback(
|
||||
(disposition: StructuredAgentSessionSendDisposition): void => {
|
||||
// Released here rather than in a `.finally`: the state write below is what re-runs the
|
||||
// drain, so a later microtask would leave the queue with no trigger to move on.
|
||||
inFlightIdRef.current = null
|
||||
setError(disposition.error ? agentSessionWriteNoticeText(disposition.error) : null)
|
||||
recordFailures(getStructuredAgentSessionOutbox(sessionId), disposition.entries)
|
||||
commitStructuredAgentSessionOutbox(sessionId, disposition.entries)
|
||||
},
|
||||
[recordFailures, sessionId]
|
||||
)
|
||||
|
||||
const [drains, setDrains] = useState(0)
|
||||
const drainAgain = useCallback(() => setDrains((count) => count + 1), [])
|
||||
|
||||
// Everything the journal settles, every time it moves: a row, a published card, the Stop's
|
||||
// answer read through, or an entry an older build left waiting for a Retry.
|
||||
useEffect(() => {
|
||||
const reading = {
|
||||
submissions,
|
||||
cursor: journalCursor,
|
||||
inFlightClientMessageId: inFlightIdRef.current,
|
||||
queuedMessageIds: queuedMessageIds ?? null
|
||||
}
|
||||
const current = getStructuredAgentSessionOutbox(sessionId)
|
||||
let entries = current
|
||||
const returned: NonNullable<StructuredAgentSessionSettledOutbox['returned']>[] = []
|
||||
for (const entry of current) {
|
||||
const settlement = settleStructuredAgentSessionEntryFromJournal(entry, reading)
|
||||
if (settlement) {
|
||||
const next = applyStructuredAgentSessionSendSettlement(
|
||||
entries,
|
||||
entry.clientMessageId,
|
||||
settlement
|
||||
)
|
||||
entries = next.entries
|
||||
if (next.returned) {
|
||||
returned.push(next.returned)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entries !== current) {
|
||||
// Each returned message goes to the draft before the outbox that drops it is saved.
|
||||
for (const back of returned) {
|
||||
returnStructuredAgentSessionMessage(back.entry)
|
||||
if (back.words) {
|
||||
setStructuredAgentSessionChatLine(sessionId, back.words)
|
||||
}
|
||||
}
|
||||
commitStructuredAgentSessionOutbox(sessionId, entries)
|
||||
}
|
||||
// The host holding the send in flight outranks a reply that has not come: release
|
||||
// single-flight and void that reply, keyed on the entry actually in flight.
|
||||
const inFlight = inFlightIdRef.current
|
||||
if (
|
||||
inFlight !== null &&
|
||||
(submissions.some(
|
||||
(submission) =>
|
||||
submission.clientMessageId === inFlight || submission.queuedMessageId === inFlight
|
||||
) ||
|
||||
queuedMessageIds?.includes(inFlight) === true)
|
||||
) {
|
||||
dispatchGenerationRef.current += 1
|
||||
inFlightIdRef.current = null
|
||||
// Nothing above may have written the outbox, so the drain is told to look again.
|
||||
drainAgain()
|
||||
}
|
||||
}, [drainAgain, journalCursor, queuedMessageIds, sessionId, submissions])
|
||||
|
||||
const journalHasRow = useCallback(
|
||||
(clientMessageId: string) =>
|
||||
submissionsRef.current.some((submission) => submission.clientMessageId === clientMessageId),
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// The shared outbox, not this render's copy: an effect earlier in this commit (an owner
|
||||
// change's requeue, a reconcile) or a launch settlement may have written a newer one.
|
||||
@@ -200,9 +205,9 @@ export function useStructuredAgentSessionOutbox(args: {
|
||||
if (!head || head.sessionId !== sessionId) {
|
||||
return
|
||||
}
|
||||
// A launch settlement dispatches outside this hook's single-flight, so while its send is up
|
||||
// nothing else may go out beside it and race it for the host's arrival order. Its writes land
|
||||
// in the shared outbox, but it releases its marker after the last one, so drain again then.
|
||||
// A launch settlement sends through the same sender outside this hook's single-flight, so
|
||||
// while its send is up nothing else may go out beside it and race it for the host's arrival
|
||||
// order. Drain again once it settles.
|
||||
const launching = current.find((entry) => entry.source === 'launch')
|
||||
const launchDispatch = launching
|
||||
? getStructuredAgentLaunchPromptDispatch(
|
||||
@@ -234,19 +239,16 @@ export function useStructuredAgentSessionOutbox(args: {
|
||||
dispatchGeneration: dispatchGenerationRef.current,
|
||||
dispatchGenerationRef,
|
||||
inFlightIdRef,
|
||||
setError,
|
||||
applyDisposition,
|
||||
createOperationId: structuredSessionOperationId
|
||||
journalHasRow
|
||||
})
|
||||
if (!dispatch.started) {
|
||||
// A launch settlement already owns this entry's send.
|
||||
void dispatch.promise.then(drainAgain, drainAgain)
|
||||
}
|
||||
// Whatever settles it writes the outbox, which runs this again; an unsaved stage or a voided
|
||||
// answer writes nothing, so drain again when it ends either way.
|
||||
void dispatch.promise.then(drainAgain, drainAgain)
|
||||
}, [
|
||||
applyDisposition,
|
||||
drainAgain,
|
||||
drains,
|
||||
fence,
|
||||
journalHasRow,
|
||||
outbox,
|
||||
queueCapability,
|
||||
queueEnabled,
|
||||
@@ -268,41 +270,67 @@ export function useStructuredAgentSessionOutbox(args: {
|
||||
}
|
||||
// Whether it asks to be queued is decided when it first goes out.
|
||||
if (!appendStructuredAgentSessionOutboxMessage(sessionId, text, attachments)) {
|
||||
setError(agentSessionWriteNoticeText(STRUCTURED_AGENT_SESSION_OUTBOX_NOT_SAVED))
|
||||
setStructuredAgentSessionChatLine(sessionId, STRUCTURED_AGENT_SESSION_OUTBOX_NOT_SAVED)
|
||||
return false
|
||||
}
|
||||
setError(null)
|
||||
setStructuredAgentSessionChatLine(sessionId, null)
|
||||
return true
|
||||
},
|
||||
[sessionId]
|
||||
)
|
||||
|
||||
const { withdrawUnsent } = useStructuredAgentSessionOutboxOwnership({
|
||||
sessionId,
|
||||
submissions,
|
||||
queuedMessageIds,
|
||||
inFlightIdRef,
|
||||
dispatchGenerationRef,
|
||||
restoreWithdrawn
|
||||
})
|
||||
/** A Stop's local step, before its request: what never went out comes back to the composer, and
|
||||
* what is on its way is stamped with the Stop's id so nothing sends it again. */
|
||||
const stop = useCallback(
|
||||
(stopOperationId: string): void => {
|
||||
const current = getStructuredAgentSessionOutbox(sessionId)
|
||||
const next = stopStructuredAgentSessionOutbox(
|
||||
current,
|
||||
submissionsRef.current,
|
||||
inFlightIdRef.current,
|
||||
stopOperationId
|
||||
)
|
||||
if (next.withdrawn.length === 0 && next.entries.every((entry, i) => entry === current[i])) {
|
||||
return
|
||||
}
|
||||
for (const entry of next.withdrawn) {
|
||||
returnStructuredAgentSessionMessage(entry)
|
||||
}
|
||||
commitStructuredAgentSessionOutbox(sessionId, next.entries)
|
||||
},
|
||||
[sessionId]
|
||||
)
|
||||
|
||||
/** Records the Stop's answer on the sends it outran; the journal settles them from there. */
|
||||
const recordStopAnswer = useCallback(
|
||||
(stopOperationId: string, answer: StructuredAgentSessionStopAnswer): void => {
|
||||
const current = getStructuredAgentSessionOutbox(sessionId)
|
||||
let changed = false
|
||||
const next = current.map((entry) => {
|
||||
if (entry.stoppedBy?.operationId !== stopOperationId || entry.stoppedBy.cursor) {
|
||||
return entry
|
||||
}
|
||||
changed = true
|
||||
return {
|
||||
...entry,
|
||||
stoppedBy:
|
||||
answer.kind === 'answered'
|
||||
? { operationId: stopOperationId, cursor: answer.cursor }
|
||||
: { operationId: stopOperationId, unanswerable: true as const }
|
||||
}
|
||||
})
|
||||
if (changed) {
|
||||
commitStructuredAgentSessionOutbox(sessionId, next)
|
||||
}
|
||||
},
|
||||
[sessionId]
|
||||
)
|
||||
|
||||
const retry = (clientMessageId: string): void => {
|
||||
setError(null)
|
||||
forget(clientMessageId)
|
||||
retryStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId,
|
||||
sessionId,
|
||||
submissions,
|
||||
setError,
|
||||
createOperationId: structuredSessionOperationId
|
||||
})
|
||||
}
|
||||
return {
|
||||
outbox,
|
||||
error,
|
||||
failedHere,
|
||||
send,
|
||||
retry,
|
||||
withdrawUnsent
|
||||
stop,
|
||||
recordStopAnswer
|
||||
}
|
||||
}
|
||||
|
||||
+24
-17
@@ -14,7 +14,8 @@ import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/struc
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
call: vi.fn(),
|
||||
withdrawUnsent: vi.fn(),
|
||||
stopOutbox: vi.fn(),
|
||||
recordStopAnswer: vi.fn(),
|
||||
operations: 0
|
||||
}))
|
||||
let items: AgentJournalRenderItem[] = []
|
||||
@@ -42,8 +43,8 @@ vi.mock('./use-structured-agent-session-outbox', () => ({
|
||||
outbox,
|
||||
error: null,
|
||||
send: vi.fn(),
|
||||
retry: vi.fn(),
|
||||
withdrawUnsent: mocks.withdrawUnsent
|
||||
stop: mocks.stopOutbox,
|
||||
recordStopAnswer: mocks.recordStopAnswer
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -66,8 +67,7 @@ function entry(
|
||||
previewUris: [],
|
||||
state,
|
||||
queuedAt: 1,
|
||||
lastAttemptAt: null,
|
||||
retryAfterUnknownSubmittedAt: null
|
||||
lastAttemptAt: null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,15 +160,18 @@ describe('Stop against a host that stops the conversation', () => {
|
||||
await result.current.stop()
|
||||
})
|
||||
|
||||
expect(mocks.withdrawUnsent).toHaveBeenCalledOnce()
|
||||
// Withdrawn first, so the drain has nothing left to send after the Stop.
|
||||
expect(mocks.stopOutbox).toHaveBeenCalledOnce()
|
||||
// The outbox is stopped first, so the drain has nothing left to send after the Stop, and its
|
||||
// sends are stamped with the very id the Stop goes out under.
|
||||
const cancelCall = mocks.call.mock.calls.findIndex(
|
||||
([, method]) => method === 'agentSession.cancel'
|
||||
)
|
||||
expect(mocks.withdrawUnsent.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
expect(mocks.stopOutbox.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.call.mock.invocationCallOrder[cancelCall] ?? 0
|
||||
)
|
||||
expect(cancels()).toEqual([expect.not.objectContaining({ turnId: expect.anything() })])
|
||||
const stopId = mocks.stopOutbox.mock.calls[0]?.[0]
|
||||
expect(mocks.call.mock.calls[cancelCall]?.[2].envelope.clientOperationId).toBe(stopId)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -301,7 +304,11 @@ describe('Stop against a host that stops the conversation', () => {
|
||||
await Promise.all([first, second])
|
||||
})
|
||||
|
||||
expect(await second).toEqual({ cancelled: true })
|
||||
// The second Stop has its own id, so the host runs it rather than replaying the first.
|
||||
const ids = cancelOperationIds()
|
||||
expect(ids).toHaveLength(2)
|
||||
expect(new Set(ids).size).toBe(2)
|
||||
expect(mocks.recordStopAnswer).toHaveBeenCalledWith(ids[1], expect.anything())
|
||||
})
|
||||
|
||||
it('does not let a lost stop of every background task swallow the next one', async () => {
|
||||
@@ -387,18 +394,18 @@ describe('Stop against a host that stops the conversation', () => {
|
||||
|
||||
it('is hidden at rest, and with only a message that will not run', () => {
|
||||
expect(render().result.current.canStop).toBe(false)
|
||||
outbox = [entry('rejected')]
|
||||
outbox = [{ ...entry('queued'), legacyUnsettled: true }]
|
||||
submissions = [submission({ dispatchState: 'accepted', resolvedAt: 2 })]
|
||||
expect(render().result.current.canStop).toBe(false)
|
||||
})
|
||||
|
||||
it('is hidden with only a message that waits on its Retry', () => {
|
||||
// A send that failed waits, with its saved failure, until the user retries it.
|
||||
outbox = [{ ...entry('queued'), lastFailure: { kind: 'failed' } }]
|
||||
it('is hidden with only a message an earlier Stop already outran', () => {
|
||||
// A send a Stop stamped never goes again: there is nothing more to stop.
|
||||
outbox = [{ ...entry('unconfirmed'), lastAttemptAt: 2, stoppedBy: { operationId: 'stop-0' } }]
|
||||
expect(render().result.current.canStop).toBe(false)
|
||||
|
||||
// A send the host restarted under is parked for the user, and the chat reads idle.
|
||||
outbox = [{ ...entry('unconfirmed'), retryAfterUnknownSubmittedAt: -1 }]
|
||||
// A send the host restarted under is the host's row now, and the chat reads idle.
|
||||
outbox = []
|
||||
submissions = [
|
||||
submission({
|
||||
dispatchState: 'unknown',
|
||||
@@ -526,7 +533,7 @@ describe.each([
|
||||
await result.current.stop()
|
||||
})
|
||||
expect(cancels()).toEqual([])
|
||||
expect(mocks.withdrawUnsent).not.toHaveBeenCalled()
|
||||
expect(mocks.stopOutbox).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -539,6 +546,6 @@ describe.each([
|
||||
await result.current.stop()
|
||||
})
|
||||
expect(cancels()).toEqual([expect.objectContaining({ turnId: 'provider-turn' })])
|
||||
expect(mocks.withdrawUnsent).not.toHaveBeenCalled()
|
||||
expect(mocks.stopOutbox).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
+4
-4
@@ -56,8 +56,8 @@ vi.mock('./use-structured-agent-session-outbox', () => ({
|
||||
outbox: outboxEntries,
|
||||
error: null,
|
||||
send: vi.fn(),
|
||||
retry: vi.fn(),
|
||||
withdrawUnsent: vi.fn()
|
||||
stop: vi.fn(),
|
||||
recordStopAnswer: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
clearNativeChatDraftCacheForTests,
|
||||
readNativeChatDraftCache
|
||||
} from './native-chat-draft-cache'
|
||||
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
|
||||
import { useStructuredAgentSession } from './use-structured-agent-session'
|
||||
|
||||
const RUNNING_TURN: AgentJournalRenderItem = {
|
||||
@@ -99,7 +100,6 @@ function render(queueFollowUps?: boolean) {
|
||||
agent: 'claude',
|
||||
target: { kind: 'local' },
|
||||
isVisible: true,
|
||||
composerScopeKey: 'scope-1',
|
||||
...(queueFollowUps === undefined ? {} : { queueFollowUps })
|
||||
})
|
||||
)
|
||||
@@ -171,7 +171,7 @@ describe('against a capable host', () => {
|
||||
expect(result.current.queuedMessages.cards).toMatchObject([
|
||||
{ messageId: 'draft-1', hold: 'paused' }
|
||||
])
|
||||
expect(readNativeChatDraftCache('scope-1')).toBe('')
|
||||
expect(readNativeChatDraftCache(structuredAgentSessionDraftScopeKey('session-1'))).toBe('')
|
||||
})
|
||||
|
||||
it("/clear is exactly today's command — drafts are the host's to carry", async () => {
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
useStructuredAgentSessionHostStopsConversation
|
||||
} from '@/runtime/structured-agent-session-host-capability'
|
||||
import { hasUnsentStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal'
|
||||
import { useStructuredAgentSessionConversationStop } from './use-structured-agent-session-conversation-stop'
|
||||
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
|
||||
import {
|
||||
legacyAgentSessionSelectedOptionId,
|
||||
type AgentSessionPromptResponse
|
||||
@@ -54,14 +56,11 @@ export function useStructuredAgentSession(args: {
|
||||
providerStarting?: boolean
|
||||
/** This view started the session; only then does the stored selection name what it runs. */
|
||||
launch?: StructuredAgentSessionLaunchView
|
||||
/** The composer Edit copies a card's text into, and that gets back unsent outbox text. */
|
||||
composerScopeKey?: string
|
||||
/** The chat-wide "queue follow-ups" setting; off keeps mid-turn sends immediate. */
|
||||
queueFollowUps?: boolean
|
||||
}) {
|
||||
const {
|
||||
agent,
|
||||
composerScopeKey,
|
||||
isVisible,
|
||||
launch,
|
||||
providerStarting = false,
|
||||
@@ -78,6 +77,7 @@ export function useStructuredAgentSession(args: {
|
||||
loadOlder,
|
||||
mutate,
|
||||
write,
|
||||
writeAs,
|
||||
providerVisible
|
||||
} = useStructuredAgentSessionTransport({
|
||||
sessionId,
|
||||
@@ -121,9 +121,19 @@ export function useStructuredAgentSession(args: {
|
||||
target,
|
||||
fence: transportState.fence,
|
||||
submissions: transportState.submissions,
|
||||
composerScopeKey,
|
||||
journalCursor: transportEnabled ? state.cursor : null,
|
||||
queueDelivery: { capability: queueCapability, enabled: queueFollowUps },
|
||||
queuedMessageIds
|
||||
// Absent until the host publishes a list: only a list says it holds no draft under an id.
|
||||
queuedMessageIds:
|
||||
transportEnabled && state.queuedMessages !== undefined ? queuedMessageIds : undefined
|
||||
})
|
||||
const stopConversation = useStructuredAgentSessionConversationStop({
|
||||
outbox: outboxController.outbox,
|
||||
submissions: transportState.submissions,
|
||||
attached: transportState.fence !== null,
|
||||
writeAs,
|
||||
stopOutbox: outboxController.stop,
|
||||
recordStopAnswer: outboxController.recordStopAnswer
|
||||
})
|
||||
|
||||
const threadGoal = useStructuredAgentSessionThreadGoal({
|
||||
@@ -175,7 +185,7 @@ export function useStructuredAgentSession(args: {
|
||||
queuePause: transportState.queuePause,
|
||||
submissions: transportState.submissions,
|
||||
hasPendingPrompt: prompts.length > 0,
|
||||
composerScopeKey,
|
||||
composerScopeKey: structuredAgentSessionDraftScopeKey(sessionId),
|
||||
mutate
|
||||
})
|
||||
return {
|
||||
@@ -219,7 +229,6 @@ export function useStructuredAgentSession(args: {
|
||||
loadOlder,
|
||||
prompts,
|
||||
outbox,
|
||||
failedHere: outboxController.failedHere,
|
||||
/** The journal's rows for sent messages, which carry a rejected message's whole fact. */
|
||||
submissions: transportState.submissions,
|
||||
// A message typed during a command queues behind it on the host.
|
||||
@@ -227,7 +236,6 @@ export function useStructuredAgentSession(args: {
|
||||
// Legacy: an older host refuses sends while a command runs; removable once those hosts age out.
|
||||
(!commandPending.current || hostStatesTurnScopes(transportState.journalItems)) &&
|
||||
outboxController.send(...input),
|
||||
retry: outboxController.retry,
|
||||
isWorking: transportState.isWorking,
|
||||
workingStartedAt: transportState.turnTiming.workingStartedAt,
|
||||
settledTurns: transportState.turnTiming.settledTurns,
|
||||
@@ -237,11 +245,10 @@ export function useStructuredAgentSession(args: {
|
||||
canStop,
|
||||
stop: () => {
|
||||
if (stopsConversation) {
|
||||
// Unsent text this client still owns goes back to its composer — a local move.
|
||||
// Host-held drafts are never withdrawn by a Stop: the host pauses them and
|
||||
// they stay visible as cards, on every device, until the user acts on one.
|
||||
outboxController.withdrawUnsent()
|
||||
return mutate('agentSession.cancel', 'agentSession.cancel', {})
|
||||
// Unsent text this client still owns goes back to its composer — a local move — and
|
||||
// a send on its way is stamped with this Stop's id. Host-held drafts are never withdrawn
|
||||
// by a Stop: the host pauses them and they stay visible as cards until the user acts.
|
||||
return stopConversation().then(() => null)
|
||||
}
|
||||
return transportState.turnId
|
||||
? mutate('agentSession.cancel', 'agentSession.cancel', { turnId: transportState.turnId })
|
||||
|
||||
@@ -17407,11 +17407,6 @@
|
||||
},
|
||||
"native": {
|
||||
"chat": {
|
||||
"NativeChatStructuredSession": {
|
||||
"1f772bb5d0": "Message delivery is unconfirmed.",
|
||||
"93ef441197": "Message was not sent.",
|
||||
"a5e7f14068": "Retry"
|
||||
},
|
||||
"NativeChatLaunchRetry": {
|
||||
"failed": "Chat could not be started.",
|
||||
"unknown": "Chat connection could not be confirmed.",
|
||||
@@ -18062,6 +18057,7 @@
|
||||
"messageNotSent": "Message not sent",
|
||||
"deliveryUnconfirmed": "Delivery unconfirmed — check chat before retrying",
|
||||
"messageSending": "Sending…",
|
||||
"messageNotConfirmed": "Not confirmed. Send it again if the agent didn't answer it.",
|
||||
"queuedMessages": {
|
||||
"behindReturnedHold": "Waiting — a message ahead needs attention",
|
||||
"awaitingAnswerHold": "Waiting for your answer",
|
||||
|
||||
@@ -15207,6 +15207,7 @@
|
||||
"messageNotSent": "Mensaje no enviado",
|
||||
"deliveryUnconfirmed": "Entrega sin confirmar — revisa el chat antes de reintentar",
|
||||
"messageSending": "Enviando…",
|
||||
"messageNotConfirmed": "Sin confirmar. Vuelve a enviarlo si el agente no lo respondió.",
|
||||
"subagents": {
|
||||
"unnamed": "Subagente"
|
||||
},
|
||||
|
||||
@@ -17280,11 +17280,6 @@
|
||||
},
|
||||
"native": {
|
||||
"chat": {
|
||||
"NativeChatStructuredSession": {
|
||||
"1f772bb5d0": "La livraison du message n'est pas confirmée.",
|
||||
"93ef441197": "Le message n'a pas été envoyé.",
|
||||
"a5e7f14068": "Réessayer"
|
||||
},
|
||||
"NativeChatLaunchRetry": {
|
||||
"failed": "Le chat n'a pas pu démarrer.",
|
||||
"unknown": "La connexion au chat n'a pas pu être confirmée.",
|
||||
@@ -17895,6 +17890,7 @@
|
||||
"messageNotSent": "Message non envoyé",
|
||||
"deliveryUnconfirmed": "Livraison non confirmée — vérifiez le chat avant de réessayer",
|
||||
"messageSending": "Envoi…",
|
||||
"messageNotConfirmed": "Non confirmé. Renvoyez-le si l'agent n'y a pas répondu.",
|
||||
"queuedMessages": {
|
||||
"alreadySent": "Ce message a déjà été envoyé.",
|
||||
"editAlreadySent": "Déjà envoyé — votre texte reste dans la zone de saisie.",
|
||||
|
||||
@@ -17189,11 +17189,6 @@
|
||||
},
|
||||
"native": {
|
||||
"chat": {
|
||||
"NativeChatStructuredSession": {
|
||||
"1f772bb5d0": "メッセージの配信は未確認です。",
|
||||
"93ef441197": "メッセージは送信されませんでした。",
|
||||
"a5e7f14068": "再試行"
|
||||
},
|
||||
"NativeChatLaunchRetry": {
|
||||
"failed": "チャットを開始できませんでした。",
|
||||
"unknown": "チャット接続が確認できませんでした。",
|
||||
@@ -17832,6 +17827,7 @@
|
||||
"messageNotSent": "メッセージは送信されませんでした",
|
||||
"deliveryUnconfirmed": "配信を確認できません — 再試行する前にチャットを確認してください",
|
||||
"messageSending": "送信中…",
|
||||
"messageNotConfirmed": "未確認です。Agent が応答していない場合は、もう一度送信してください。",
|
||||
"queuedMessages": {
|
||||
"alreadySent": "このメッセージはすでに送信されています。",
|
||||
"editAlreadySent": "すでに送信されています — テキストは入力欄に残っています。",
|
||||
|
||||
@@ -17189,11 +17189,6 @@
|
||||
},
|
||||
"native": {
|
||||
"chat": {
|
||||
"NativeChatStructuredSession": {
|
||||
"1f772bb5d0": "메시지 전달이 확인되지 않았습니다.",
|
||||
"93ef441197": "메시지가 전송되지 않았습니다.",
|
||||
"a5e7f14068": "다시 시도"
|
||||
},
|
||||
"NativeChatLaunchRetry": {
|
||||
"failed": "채팅을 시작할 수 없습니다.",
|
||||
"unknown": "채팅 연결을 확인할 수 없습니다.",
|
||||
@@ -17832,6 +17827,7 @@
|
||||
"messageNotSent": "메시지가 전송되지 않음",
|
||||
"deliveryUnconfirmed": "전달 확인 안 됨 — 다시 시도하기 전에 채팅을 확인하세요",
|
||||
"messageSending": "전송 중…",
|
||||
"messageNotConfirmed": "확인되지 않았습니다. 에이전트가 답하지 않았다면 다시 보내세요.",
|
||||
"queuedMessages": {
|
||||
"alreadySent": "이 메시지는 이미 전송되었습니다.",
|
||||
"editAlreadySent": "이미 전송되었습니다 — 텍스트는 입력창에 남아 있습니다.",
|
||||
|
||||
@@ -17241,11 +17241,6 @@
|
||||
},
|
||||
"native": {
|
||||
"chat": {
|
||||
"NativeChatStructuredSession": {
|
||||
"1f772bb5d0": "消息传递未经确认。",
|
||||
"93ef441197": "消息未发送。",
|
||||
"a5e7f14068": "重试"
|
||||
},
|
||||
"NativeChatLaunchRetry": {
|
||||
"failed": "无法开始聊天。",
|
||||
"unknown": "无法确认聊天连接。",
|
||||
@@ -17849,6 +17844,7 @@
|
||||
"messageNotSent": "消息未发送",
|
||||
"deliveryUnconfirmed": "无法确认送达 — 重试前请检查聊天",
|
||||
"messageSending": "正在发送…",
|
||||
"messageNotConfirmed": "未确认。如果智能体没有回复,请重新发送。",
|
||||
"queuedMessages": {
|
||||
"alreadySent": "此消息已发送。",
|
||||
"editAlreadySent": "已发送 — 你的文本仍在输入框中。",
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
enqueueStructuredAgentSessionLaunchPrompt,
|
||||
mutateStructuredAgentSessionLaunchPrompt
|
||||
getStructuredAgentSessionOutbox
|
||||
} from '@/components/native-chat/structured-agent-session-outbox-storage'
|
||||
import { structuredAgentSessionDraftScopeKey } from '@/components/native-chat/native-chat-composer-draft-store'
|
||||
import { readNativeChatDraftCache } from '@/components/native-chat/native-chat-draft-cache'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ call: vi.fn() }))
|
||||
|
||||
@@ -62,32 +64,30 @@ describe('settleStructuredAgentLaunchPrompt', () => {
|
||||
expect(persisted).toMatchObject([{ state: 'dispatching' }])
|
||||
})
|
||||
|
||||
it('drops the previous attempt failure when the launch path sends the message again', async () => {
|
||||
// The launch prompt goes through the outbox's one sender and settlement: a first attempt the
|
||||
// host refused comes back to the chat's draft, never a Retry row.
|
||||
it('gives a first launch prompt the host refused back to the chat draft, with no resend', async () => {
|
||||
const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this')
|
||||
mutateStructuredAgentSessionLaunchPrompt(
|
||||
'session-1',
|
||||
stagedEntry!.clientMessageId,
|
||||
(entry) => ({
|
||||
...entry,
|
||||
lastFailure: { kind: 'refused', code: 'agent_session_operation_capacity' }
|
||||
})
|
||||
)
|
||||
const onPromptDelivered = vi.fn()
|
||||
mocks.call.mockResolvedValue({
|
||||
ok: false,
|
||||
refusal: { code: 'agent_session_checkpoint_stale', message: 'stale' }
|
||||
})
|
||||
|
||||
await settleStructuredAgentLaunchPrompt({
|
||||
launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }),
|
||||
target: { kind: 'local' },
|
||||
options: { prompt: 'review this' },
|
||||
stagedEntry
|
||||
})
|
||||
await expect(
|
||||
settleStructuredAgentLaunchPrompt({
|
||||
launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }),
|
||||
target: { kind: 'local' },
|
||||
options: { prompt: 'review this', onPromptDelivered },
|
||||
stagedEntry
|
||||
})
|
||||
).resolves.toEqual({ delivered: false, failureNotified: false })
|
||||
|
||||
const persisted: unknown = JSON.parse(localStorage.getItem(localStorage.key(0)!) ?? '[]')
|
||||
expect(persisted).toHaveLength(1)
|
||||
expect(persisted).not.toContainEqual(
|
||||
expect.objectContaining({ lastFailure: expect.anything() })
|
||||
expect(onPromptDelivered).not.toHaveBeenCalled()
|
||||
expect(mocks.call).toHaveBeenCalledOnce()
|
||||
expect(getStructuredAgentSessionOutbox('session-1')).toEqual([])
|
||||
expect(readNativeChatDraftCache(structuredAgentSessionDraftScopeKey('session-1'))).toBe(
|
||||
'review this'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
import type {
|
||||
AgentSessionMutationResult,
|
||||
AgentSessionSendResult
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import {
|
||||
requeueStructuredAgentSessionSendRefusal,
|
||||
stageStructuredAgentSessionOutboxEntryForSend,
|
||||
structuredAgentSessionSendRequest,
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from '../../../shared/structured-agent-session-outbox'
|
||||
import { agentSessionRefusalFailure } from '../../../shared/agent-session-write-failure'
|
||||
import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation'
|
||||
import {
|
||||
mutateStructuredAgentSessionLaunchPrompt,
|
||||
type StructuredAgentSessionLaunchPromptMutation
|
||||
} from '@/components/native-chat/structured-agent-session-outbox-storage'
|
||||
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
|
||||
import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox'
|
||||
import { getStructuredAgentSessionOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage'
|
||||
import { sendStructuredAgentSessionOutboxEntry } from '@/components/native-chat/structured-agent-session-outbox-dispatch'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-client-target'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
|
||||
export type StructuredPromptDeliveryResult = {
|
||||
delivered: boolean
|
||||
@@ -83,73 +68,26 @@ export function shareStructuredAgentLaunchPromptDispatch(
|
||||
return { promise, started: true }
|
||||
}
|
||||
|
||||
function mutateEntry(
|
||||
entry: StructuredAgentSessionOutboxEntry,
|
||||
update: StructuredAgentSessionLaunchPromptMutation,
|
||||
options: { onlyIfSaved?: boolean } = {}
|
||||
): boolean {
|
||||
return mutateStructuredAgentSessionLaunchPrompt(
|
||||
entry.sessionId,
|
||||
entry.clientMessageId,
|
||||
update,
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
/** The launch prompt goes out through the outbox's own sender and settlement, like any send. */
|
||||
async function dispatchStructuredLaunchPrompt(
|
||||
entry: StructuredAgentSessionOutboxEntry,
|
||||
staged: StructuredAgentSessionOutboxEntry,
|
||||
receipt: LaunchReceipt,
|
||||
target: RuntimeClientTarget
|
||||
): Promise<boolean> {
|
||||
// Why: an unsaved stage must leave the entry queued; a held 'dispatching' copy is never drained.
|
||||
if (
|
||||
!mutateEntry(
|
||||
entry,
|
||||
(current) => stageStructuredAgentSessionOutboxEntryForSend(current, Date.now()),
|
||||
{ onlyIfSaved: true }
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const result = await callStructuredAgentSession<
|
||||
AgentSessionMutationResult<AgentSessionSendResult>
|
||||
>(target, 'agentSession.send', structuredAgentSessionSendRequest(entry, receipt.fence))
|
||||
if (!result.ok) {
|
||||
mutateEntry(entry, (current) =>
|
||||
requeueStructuredAgentSessionSendRefusal(
|
||||
current,
|
||||
agentSessionRefusalFailure(result.refusal),
|
||||
() => createStructuredAgentSessionOperationId(createBrowserUuid),
|
||||
entry.lastAttemptAt !== null
|
||||
)
|
||||
)
|
||||
return false
|
||||
}
|
||||
if ('queued' in result.value) {
|
||||
// The host holds the draft; the outbox entry is spent.
|
||||
mutateEntry(entry, () => null)
|
||||
return true
|
||||
}
|
||||
const dispatchState = result.value.submission.dispatchState
|
||||
mutateEntry(entry, (current) =>
|
||||
dispatchState === 'accepted'
|
||||
? null
|
||||
: {
|
||||
...current,
|
||||
state:
|
||||
dispatchState === 'unknown'
|
||||
? 'unconfirmed'
|
||||
: dispatchState === 'pending'
|
||||
? 'dispatching'
|
||||
: 'queued'
|
||||
}
|
||||
)
|
||||
return dispatchState === 'accepted' || dispatchState === 'pending'
|
||||
} catch {
|
||||
mutateEntry(entry, (current) => ({ ...current, state: 'unconfirmed' }))
|
||||
const entries = getStructuredAgentSessionOutbox(staged.sessionId)
|
||||
const entry = entries.find((candidate) => candidate.clientMessageId === staged.clientMessageId)
|
||||
// Gone or already out: whatever settled or sent it owns it.
|
||||
if (!entry || entry.state !== 'queued') {
|
||||
return false
|
||||
}
|
||||
const settlement = await sendStructuredAgentSessionOutboxEntry({
|
||||
next: entry,
|
||||
entries,
|
||||
target,
|
||||
fence: receipt.fence,
|
||||
isCurrent: () => true
|
||||
})
|
||||
return settlement?.kind === 'recorded' || settlement?.kind === 'pending'
|
||||
}
|
||||
|
||||
export function settleStructuredAgentLaunchPrompt(args: {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import {
|
||||
AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY,
|
||||
type RuntimeCapability
|
||||
} from '../../../shared/protocol-version'
|
||||
import type { RuntimeClientTarget } from './runtime-client-target'
|
||||
@@ -109,3 +110,33 @@ export function useStructuredAgentSessionHostQueuesMessagesState(
|
||||
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY
|
||||
)
|
||||
}
|
||||
|
||||
const ANSWERS_PROVE_PROBE_TIMEOUT_MS = 5_000
|
||||
|
||||
/** Whether every refusal the session's host returns to a send proves the id has no record there.
|
||||
* False until the host has said so, for a failed probe, and for one that does not answer in time:
|
||||
* an older host proves nothing, so the send only goes again. */
|
||||
export async function structuredAgentSessionHostAnswersProve(
|
||||
target: RuntimeClientTarget
|
||||
): Promise<boolean> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timedOut = new Promise<false>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), ANSWERS_PROVE_PROBE_TIMEOUT_MS)
|
||||
})
|
||||
const probe = (async (): Promise<boolean> => {
|
||||
if (target.kind === 'local') {
|
||||
const capabilities = await ensureLocalRuntimeCapabilities()
|
||||
return capabilities?.includes(AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY) === true
|
||||
}
|
||||
return runtimeEnvironmentSupportsCapability(
|
||||
target.environmentId,
|
||||
AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY,
|
||||
ANSWERS_PROVE_PROBE_TIMEOUT_MS
|
||||
)
|
||||
})().catch(() => false)
|
||||
try {
|
||||
return await Promise.race([probe, timedOut])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from './store-test-helpers'
|
||||
import {
|
||||
readNativeChatComposerDraft,
|
||||
structuredAgentSessionDraftScopeKey,
|
||||
updateNativeChatComposerDraft
|
||||
} from '@/components/native-chat/native-chat-composer-draft-store'
|
||||
|
||||
@@ -189,8 +190,7 @@ describe('nativeChatLaunchDraftByTabId teardown', () => {
|
||||
expect(readNativeChatComposerDraft(`${TAB2}:leaf-a`).text).toBe('kept')
|
||||
})
|
||||
|
||||
it('closing a structured chat tab drops its unsent composer draft', () => {
|
||||
const store = createTestStore()
|
||||
function seedStructuredChat(store: ReturnType<typeof createTestStore>) {
|
||||
const chat = makeUnifiedTab({
|
||||
id: 'chat-tab',
|
||||
entityId: 'session-1',
|
||||
@@ -199,7 +199,12 @@ describe('nativeChatLaunchDraftByTabId teardown', () => {
|
||||
groupId: 'group-1'
|
||||
})
|
||||
seedStore(store, {
|
||||
worktreesByRepo: { repo1: [makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' })] },
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }),
|
||||
makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' })
|
||||
]
|
||||
},
|
||||
unifiedTabsByWorktree: { [WT1]: [chat] },
|
||||
groupsByWorktree: {
|
||||
[WT1]: [
|
||||
@@ -212,10 +217,33 @@ describe('nativeChatLaunchDraftByTabId teardown', () => {
|
||||
]
|
||||
}
|
||||
})
|
||||
updateNativeChatComposerDraft('chat-tab:pane', { text: 'unsent' }, 'immediate')
|
||||
return chat
|
||||
}
|
||||
|
||||
it('closing a structured chat tab keeps its conversation’s unsent draft', () => {
|
||||
const store = createTestStore()
|
||||
const chat = seedStructuredChat(store)
|
||||
const conversation = structuredAgentSessionDraftScopeKey('session-1')
|
||||
updateNativeChatComposerDraft(conversation, { text: 'unsent' }, 'immediate')
|
||||
|
||||
store.getState().closeUnifiedTab(chat.id)
|
||||
|
||||
expect(readNativeChatComposerDraft('chat-tab:pane').text).toBe('')
|
||||
expect(store.getState().unifiedTabsByWorktree[WT1]).toEqual([])
|
||||
expect(readNativeChatComposerDraft(conversation).text).toBe('unsent')
|
||||
})
|
||||
|
||||
it('removing a worktree drops its structured chats’ conversation drafts only', async () => {
|
||||
const store = createTestStore()
|
||||
seedStructuredChat(store)
|
||||
const removed = structuredAgentSessionDraftScopeKey('session-1')
|
||||
const other = structuredAgentSessionDraftScopeKey('session-2')
|
||||
updateNativeChatComposerDraft(removed, { text: 'unsent' }, 'immediate')
|
||||
updateNativeChatComposerDraft(other, { text: 'kept' }, 'immediate')
|
||||
|
||||
const result = await store.getState().removeWorktree({ id: WT1, executionHostId: null })
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(readNativeChatComposerDraft(removed).text).toBe('')
|
||||
expect(readNativeChatComposerDraft(other).text).toBe('kept')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
structuredAgentSessionFocusOwner,
|
||||
structuredAgentSessionTargetForTab
|
||||
} from '@/runtime/structured-agent-session-owner'
|
||||
import { deleteNativeChatComposerDraftsForTab } from '@/components/native-chat/native-chat-composer-draft-store'
|
||||
|
||||
export function createTabsCloseActions(
|
||||
set: TabsSliceSet,
|
||||
@@ -74,7 +73,7 @@ export function createTabsCloseActions(
|
||||
console.warn('[structured-agent-session] close found no owning host', tab.entityId)
|
||||
}
|
||||
get().clearNativeChatLaunchDraft(structuredAgentSessionTabId(tab.entityId))
|
||||
deleteNativeChatComposerDraftsForTab(tab.id)
|
||||
// The unsent draft stays: it belongs to the conversation, which can be reopened from history.
|
||||
}
|
||||
// Why: on closing the active tab, walk the MRU stack to the previously-active tab; pickNextActiveTab falls back to the neighbor.
|
||||
const nextActiveTabId =
|
||||
|
||||
+12
-2
@@ -11,7 +11,11 @@ import { disposeRemovedWorktreeParkedTerminalWatchers } from '../../../../compon
|
||||
import { detachedHeadAutoDerivedDisplayNames } from '../metadata/detached-head-display-name'
|
||||
import { applyRemoveWorktreeSuccessState } from './remove-worktree-store-cleanup'
|
||||
import { purgeOrphanedRuntimeSshProjects } from './orphaned-runtime-ssh-project-purge'
|
||||
import { deleteNativeChatComposerDraftsForTab } from '@/components/native-chat/native-chat-composer-draft-store'
|
||||
import {
|
||||
deleteNativeChatComposerDraft,
|
||||
deleteNativeChatComposerDraftsForTab,
|
||||
structuredAgentSessionDraftScopeKey
|
||||
} from '@/components/native-chat/native-chat-composer-draft-store'
|
||||
|
||||
/**
|
||||
* Renderer-side teardown after the backend removal succeeded.
|
||||
@@ -45,8 +49,10 @@ export async function tearDownRemovedWorktreeRendererState(args: {
|
||||
)
|
||||
)
|
||||
}
|
||||
const structuredSessionIds: string[] = []
|
||||
for (const tab of get().unifiedTabsByWorktree[worktreeId] ?? []) {
|
||||
if (tab.contentType === 'agent-session') {
|
||||
structuredSessionIds.push(tab.entityId)
|
||||
get().closeUnifiedTab(tab.id, {
|
||||
preserveWorktreeSelection: true,
|
||||
recordInteraction: false
|
||||
@@ -80,7 +86,11 @@ export async function tearDownRemovedWorktreeRendererState(args: {
|
||||
detachedHeadAutoDerivedDisplayNames.delete(worktreeId)
|
||||
forgetForegroundTerminalTabs(tabIds)
|
||||
forgetAgentStartupDeliveriesForTabs(tabIds)
|
||||
// Structured chats were closed above, which deleted their drafts; terminal tabs skip closeTab.
|
||||
// Why: closing a structured chat keeps its conversation's draft, and terminal tabs skip
|
||||
// closeTab, so both die with their worktree here.
|
||||
for (const sessionId of structuredSessionIds) {
|
||||
deleteNativeChatComposerDraft(structuredAgentSessionDraftScopeKey(sessionId))
|
||||
}
|
||||
for (const tabId of tabIds) {
|
||||
deleteNativeChatComposerDraftsForTab(tabId)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
DISPATCH_REJECTED_QUEUE_FULL,
|
||||
DISPATCH_REJECTED_WRITE_FAILED
|
||||
} from './structured-agent-session-dispatch-rejection'
|
||||
import { structuredAgentSessionRejectionParts } from './structured-agent-session-send-disposition'
|
||||
import { structuredAgentSessionRejectionParts } from './structured-agent-session-send-failure-words'
|
||||
|
||||
const WRITES: AgentSessionWriteKind[] = [
|
||||
'read-history',
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionOwnerVerdict } from './agent-session-wire'
|
||||
import { agentSessionOwnerVerdictAllowsFreshOperationId } from './agent-session-refusal-retry'
|
||||
import { parseAgentSessionWriteFailure } from './agent-session-write-failure'
|
||||
import {
|
||||
createStructuredAgentSessionOutboxEntry,
|
||||
requeueStructuredAgentSessionSendRefusal
|
||||
} from './structured-agent-session-outbox'
|
||||
import {
|
||||
admitStructuredAgentSessionOutboxEntry,
|
||||
structuredAgentSessionEntryHeldForRetry
|
||||
} from './structured-agent-session-outbox-admission'
|
||||
import { disposeStructuredAgentSessionSendResult } from './structured-agent-session-send-disposition'
|
||||
|
||||
const entry = createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: 'message-1',
|
||||
sessionId: 'session-1',
|
||||
text: 'hello',
|
||||
attachments: [],
|
||||
queuedAt: 1
|
||||
})
|
||||
|
||||
/** A refused send as a reloaded entry holds it: through the saved shape and back. */
|
||||
function storedOwnershipRefusal(ownerVerdict: AgentSessionOwnerVerdict | undefined) {
|
||||
const failure = parseAgentSessionWriteFailure(
|
||||
JSON.parse(
|
||||
JSON.stringify({
|
||||
kind: 'refused',
|
||||
code: 'agent_session_ownership_unknown',
|
||||
details: { reason: 'ownerUnproven', ...(ownerVerdict ? { ownerVerdict } : {}) }
|
||||
})
|
||||
)
|
||||
)
|
||||
if (failure?.kind !== 'refused') {
|
||||
throw new Error('the saved refusal did not read back')
|
||||
}
|
||||
return failure
|
||||
}
|
||||
|
||||
function retried(ownerVerdict: AgentSessionOwnerVerdict | undefined) {
|
||||
return requeueStructuredAgentSessionSendRefusal(
|
||||
entry,
|
||||
storedOwnershipRefusal(ownerVerdict),
|
||||
() => 'message-2'
|
||||
)
|
||||
}
|
||||
|
||||
describe("the owner's verdict decides a retry's operation id only as a floor", () => {
|
||||
it('lets a retry use a new id once the stored verdict proves nothing runs', () => {
|
||||
expect(agentSessionOwnerVerdictAllowsFreshOperationId('exited')).toBe(true)
|
||||
// A new id only: nothing recorded the message, so it stays queued at the head.
|
||||
expect(retried('exited')).toMatchObject({ clientMessageId: 'message-2', state: 'queued' })
|
||||
})
|
||||
|
||||
it('holds the message for its Retry after an exited refusal, and sends later ones past it', () => {
|
||||
const later = createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: 'message-3',
|
||||
sessionId: 'session-1',
|
||||
text: 'after',
|
||||
attachments: [],
|
||||
queuedAt: 2
|
||||
})
|
||||
const disposition = disposeStructuredAgentSessionSendResult({
|
||||
entries: [entry, later],
|
||||
entry,
|
||||
result: {
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'agent_session_ownership_unknown',
|
||||
message: 'x',
|
||||
details: { reason: 'ownerUnproven', ownerVerdict: 'exited' }
|
||||
}
|
||||
},
|
||||
createOperationId: () => 'message-2'
|
||||
})
|
||||
expect(disposition.entries[0]).toMatchObject({ clientMessageId: 'message-2', state: 'queued' })
|
||||
expect(structuredAgentSessionEntryHeldForRetry(disposition.entries[0]!)).toBe(true)
|
||||
expect(admitStructuredAgentSessionOutboxEntry(disposition.entries)).toMatchObject({
|
||||
state: 'dispatch',
|
||||
entry: { clientMessageId: 'message-3' }
|
||||
})
|
||||
})
|
||||
|
||||
it.each<AgentSessionOwnerVerdict | undefined>(['unverifiable', 'live', undefined])(
|
||||
'keeps the id for a stored %s verdict, whose operation may still land',
|
||||
(stored) => {
|
||||
expect(agentSessionOwnerVerdictAllowsFreshOperationId(stored)).toBe(false)
|
||||
expect(retried(stored)).toMatchObject({ clientMessageId: 'message-1', state: 'queued' })
|
||||
}
|
||||
)
|
||||
|
||||
it.each<AgentSessionOwnerVerdict | undefined>(['unverifiable', 'live', undefined])(
|
||||
'moves off a stored %s verdict only when the current lease proves the owner exited',
|
||||
(stored) => {
|
||||
expect(agentSessionOwnerVerdictAllowsFreshOperationId(stored, 'exited')).toBe(true)
|
||||
expect(agentSessionOwnerVerdictAllowsFreshOperationId(stored, 'unverifiable')).toBe(false)
|
||||
expect(agentSessionOwnerVerdictAllowsFreshOperationId(stored, 'live')).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('never lowers a stored exited', () => {
|
||||
expect(agentSessionOwnerVerdictAllowsFreshOperationId('exited', 'live')).toBe(true)
|
||||
expect(agentSessionOwnerVerdictAllowsFreshOperationId('exited', 'unverifiable')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentSessionOwnerVerdict, AgentSessionWireRefusalCode } from './agent-session-wire'
|
||||
import type { AgentSessionWireRefusalCode } from './agent-session-wire'
|
||||
|
||||
export type AgentSessionRefusalOperationState = 'settled-rejected' | 'pending-admission' | 'unknown'
|
||||
|
||||
@@ -6,7 +6,7 @@ export function agentSessionRefusalOperationState(
|
||||
code: AgentSessionWireRefusalCode
|
||||
): AgentSessionRefusalOperationState {
|
||||
switch (code) {
|
||||
// The host tried to restart the owner for this send and could not; a Retry tries again.
|
||||
// The host tried to restart the owner for this send and could not.
|
||||
case 'agent_session_owner_restart_failed':
|
||||
case 'agent_session_operation_conflict':
|
||||
case 'agent_session_operation_expired':
|
||||
@@ -28,16 +28,3 @@ export function agentSessionRefusalOperationState(
|
||||
return 'pending-admission'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a verdict lets a retry use a new operation id. A stored `exited` is final: nothing runs
|
||||
* the refused operation, so a new id cannot collide with it. Any other stored verdict is a floor:
|
||||
* the first operation may still land, so only a verdict re-derived from the current lease can prove
|
||||
* `exited`, and nothing lowers a stored `exited`.
|
||||
*/
|
||||
export function agentSessionOwnerVerdictAllowsFreshOperationId(
|
||||
stored: AgentSessionOwnerVerdict | undefined,
|
||||
current?: AgentSessionOwnerVerdict
|
||||
): boolean {
|
||||
return stored === 'exited' || current === 'exited'
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export function agentSessionWriteKindForMethod(
|
||||
type DurableRefusalFacts = {
|
||||
agent_session_operation_invalid: { rewindReason?: AgentSessionRewindReason }
|
||||
agent_session_operation_unknown: { rewindReason?: AgentSessionRewindReason }
|
||||
/** A snapshot from when it was refused; see `agentSessionOwnerVerdictAllowsFreshOperationId`. */
|
||||
/** A snapshot of the owner's state from when it was refused. */
|
||||
agent_session_ownership_unknown: { ownerVerdict?: AgentSessionOwnerVerdict }
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,7 @@ import {
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from './structured-agent-session-outbox'
|
||||
import { reconcileStructuredAgentSessionOutboxWithQueue } from './structured-agent-session-draft-hand-off'
|
||||
import {
|
||||
disposeStructuredAgentSessionSendResult,
|
||||
journalAnswersInFlightSend
|
||||
} from './structured-agent-session-send-disposition'
|
||||
import { settleStructuredAgentSessionSendAnswer } from './structured-agent-session-send-settlement'
|
||||
import { hasUnsentStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-stop-withdrawal'
|
||||
|
||||
const entry: StructuredAgentSessionOutboxEntry = {
|
||||
@@ -48,25 +45,26 @@ describe('a queued draft handed off under a fresh submission id', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('answers the send in flight and leaves nothing a Stop would withdraw', () => {
|
||||
expect(journalAnswersInFlightSend([handOff('pending')], 'draft')).toBe(true)
|
||||
expect(journalAnswersInFlightSend([handOff('pending')], null)).toBe(false)
|
||||
it('leaves nothing a Stop would withdraw', () => {
|
||||
expect(hasUnsentStructuredAgentSessionOutboxEntry([entry], [handOff('pending')])).toBe(false)
|
||||
})
|
||||
|
||||
it('settles a replayed send answered with the hand-off, with no notice', () => {
|
||||
const disposition = disposeStructuredAgentSessionSendResult({
|
||||
entries: [entry],
|
||||
entry,
|
||||
createOperationId: () => 'rotated',
|
||||
result: {
|
||||
ok: true,
|
||||
replayed: true,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-1', sequence: 10 },
|
||||
value: { clientMessageId: 'hand-off', submission: handOff('rejected') }
|
||||
}
|
||||
})
|
||||
expect(disposition).toEqual({ entries: [], error: null })
|
||||
it("settles a replayed send answered with the hand-off as the host's, with no words", () => {
|
||||
expect(
|
||||
settleStructuredAgentSessionSendAnswer(
|
||||
{
|
||||
kind: 'result',
|
||||
result: {
|
||||
ok: true,
|
||||
replayed: true,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-1', sequence: 10 },
|
||||
value: { clientMessageId: 'hand-off', submission: handOff('rejected') }
|
||||
}
|
||||
},
|
||||
'draft',
|
||||
{ firstAttempt: false, answersProve: true, journalHasRow: false }
|
||||
)
|
||||
).toEqual({ kind: 'recorded' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ import { isQueuedAgentJournalSubmission } from './agent-session-queued-submissio
|
||||
import { collapseProviderRetryRuns } from './native-chat-provider-retry-runs'
|
||||
import type { NativeChatMessage } from './native-chat-types'
|
||||
import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox'
|
||||
import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-session-outbox-admission'
|
||||
import { reconcileStructuredAgentSessionOutboxWithQueue } from './structured-agent-session-draft-hand-off'
|
||||
import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection'
|
||||
import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection'
|
||||
@@ -104,9 +103,6 @@ export function projectStructuredAgentSessionMessages(
|
||||
source: 'transcript',
|
||||
timestamp: entry.queuedAt,
|
||||
blocks: entry.body.blocks,
|
||||
...(entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry)
|
||||
? { unsent: true as const }
|
||||
: {}),
|
||||
// A send the journal recorded before refusing it keeps its place there.
|
||||
...(recorded ? { journalPosition: agentJournalItemPosition(recorded) } : {})
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// Which outbox entry goes out next, and which ones wait for the user.
|
||||
// Which outbox entry goes out next.
|
||||
|
||||
import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox'
|
||||
|
||||
/** A send the user was told did not go through, with a Retry: only that Retry sends it again. Read
|
||||
* from the saved failure, so a relaunch holds it exactly as the refusal did. */
|
||||
export function structuredAgentSessionEntryHeldForRetry(
|
||||
/** Whether nothing but an answer already owed settles this entry, so it never goes out again: a
|
||||
* send a Stop outran, or one an older build held for a Retry. */
|
||||
export function structuredAgentSessionEntryAwaitsSettlement(
|
||||
entry: StructuredAgentSessionOutboxEntry
|
||||
): boolean {
|
||||
return entry.state === 'queued' && entry.lastFailure !== undefined
|
||||
return entry.stoppedBy !== undefined || entry.legacyUnsettled === true
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionOutboxAdmission =
|
||||
@@ -16,29 +16,25 @@ export type StructuredAgentSessionOutboxAdmission =
|
||||
| { state: 'idle'; entry: null }
|
||||
|
||||
/**
|
||||
* What the queue does next. The drain and the Retry affordance both read it, so neither can
|
||||
* disagree with the other about which entry is holding the queue.
|
||||
* What the queue does next. The drain and the delivery notices both read it.
|
||||
*
|
||||
* A `dispatching` entry is not a barrier: the host appended its journal row inside the
|
||||
* per-session serialize chain before dispatching, so nothing behind it can overtake it, and
|
||||
* waiting for its echo costs delivery of everything queued behind it. An `unconfirmed` entry
|
||||
* is a barrier — sending past it would reorder around a message that may yet land. One the
|
||||
* user was told did not go through is not: it lands only by its own Retry, so what the user
|
||||
* sends after it goes out as they send it.
|
||||
* per-session serialize chain before dispatching, so nothing behind it can overtake it. An
|
||||
* `unconfirmed` entry is, while it is sent again: sending past it would reorder around a message
|
||||
* that may yet land, and its resend settles it. One that never goes out again holds nothing up.
|
||||
*/
|
||||
export function admitStructuredAgentSessionOutboxEntry(
|
||||
entries: readonly StructuredAgentSessionOutboxEntry[]
|
||||
): StructuredAgentSessionOutboxAdmission {
|
||||
for (const entry of entries) {
|
||||
if (entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry)) {
|
||||
if (structuredAgentSessionEntryAwaitsSettlement(entry)) {
|
||||
continue
|
||||
}
|
||||
if (entry.state === 'unconfirmed') {
|
||||
return { state: 'blocked', entry }
|
||||
}
|
||||
// A queue send a Stop outlived goes again only on the user's Retry.
|
||||
if (entry.state === 'queued') {
|
||||
return { state: entry.outlivedStop === true ? 'blocked' : 'dispatch', entry }
|
||||
return { state: 'dispatch', entry }
|
||||
}
|
||||
}
|
||||
return { state: 'idle', entry: null }
|
||||
|
||||
@@ -54,12 +54,8 @@ export function structuredAgentSessionEntryAttempt(
|
||||
/** The queue fields a stored entry carries, read back from storage. */
|
||||
export function parseStructuredAgentSessionOutboxQueueFields(entry: {
|
||||
sentDelivery?: unknown
|
||||
outlivedStop?: unknown
|
||||
}): Pick<StructuredAgentSessionOutboxEntry, 'sentDelivery' | 'outlivedStop'> {
|
||||
return {
|
||||
...(entry.sentDelivery === 'queue-if-active' || entry.sentDelivery === null
|
||||
? { sentDelivery: entry.sentDelivery }
|
||||
: {}),
|
||||
...(entry.outlivedStop === true ? { outlivedStop: true as const } : {})
|
||||
}
|
||||
}): Pick<StructuredAgentSessionOutboxEntry, 'sentDelivery'> {
|
||||
return entry.sentDelivery === 'queue-if-active' || entry.sentDelivery === null
|
||||
? { sentDelivery: entry.sentDelivery }
|
||||
: {}
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentJournalSubmission } from './agent-session-journal-types'
|
||||
import {
|
||||
createStructuredAgentSessionOutboxEntry,
|
||||
reconcileStructuredAgentSessionOutbox,
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from './structured-agent-session-outbox'
|
||||
import {
|
||||
admitStructuredAgentSessionOutboxEntry,
|
||||
structuredAgentSessionEntryHeldForRetry
|
||||
} from './structured-agent-session-outbox-admission'
|
||||
|
||||
function entry(
|
||||
clientMessageId: string,
|
||||
patch: Partial<StructuredAgentSessionOutboxEntry> = {}
|
||||
): StructuredAgentSessionOutboxEntry {
|
||||
return {
|
||||
...createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId,
|
||||
sessionId: 'session-1',
|
||||
text: clientMessageId,
|
||||
attachments: [],
|
||||
queuedAt: 1
|
||||
}),
|
||||
...patch
|
||||
}
|
||||
}
|
||||
|
||||
const REFUSED = { kind: 'refused', code: 'agent_session_journal_unreadable' } as const
|
||||
|
||||
describe('a message held for its Retry', () => {
|
||||
it('is one the user was told did not go through, and still queued', () => {
|
||||
expect(structuredAgentSessionEntryHeldForRetry(entry('a', { lastFailure: REFUSED }))).toBe(true)
|
||||
expect(
|
||||
structuredAgentSessionEntryHeldForRetry(entry('a', { lastFailure: { kind: 'failed' } }))
|
||||
).toBe(true)
|
||||
expect(structuredAgentSessionEntryHeldForRetry(entry('a'))).toBe(false)
|
||||
expect(
|
||||
structuredAgentSessionEntryHeldForRetry(
|
||||
entry('a', { state: 'rejected', lastFailure: { kind: 'rejected', reason: null } })
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('is passed over by the drain, which still stops on a message in doubt', () => {
|
||||
const held = entry('held', { lastFailure: REFUSED })
|
||||
expect(admitStructuredAgentSessionOutboxEntry([held])).toEqual({ state: 'idle', entry: null })
|
||||
expect(admitStructuredAgentSessionOutboxEntry([held, entry('next')])).toMatchObject({
|
||||
state: 'dispatch',
|
||||
entry: { clientMessageId: 'next' }
|
||||
})
|
||||
expect(
|
||||
admitStructuredAgentSessionOutboxEntry([
|
||||
held,
|
||||
entry('doubt', { state: 'unconfirmed' }),
|
||||
entry('next')
|
||||
])
|
||||
).toMatchObject({ state: 'blocked', entry: { clientMessageId: 'doubt' } })
|
||||
})
|
||||
|
||||
it('is released once the host shows it has the message after all', () => {
|
||||
const submission: AgentJournalSubmission = {
|
||||
clientMessageId: 'held',
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'pending',
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
submittedAt: 1,
|
||||
resolvedAt: null
|
||||
}
|
||||
const [landed] = reconcileStructuredAgentSessionOutbox(
|
||||
[entry('held', { lastFailure: REFUSED })],
|
||||
[submission]
|
||||
)
|
||||
expect(landed?.state).toBe('dispatching')
|
||||
expect(landed?.lastFailure).toBeUndefined()
|
||||
})
|
||||
|
||||
// Any fresh word on where a message stands supersedes an earlier attempt's failure.
|
||||
it('is in doubt, and holds the queue, once the host says it cannot tell whether it landed', () => {
|
||||
const submission: AgentJournalSubmission = {
|
||||
clientMessageId: 'held',
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'unknown',
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
submittedAt: 5,
|
||||
resolvedAt: null
|
||||
}
|
||||
const reconciled = reconcileStructuredAgentSessionOutbox(
|
||||
[entry('held', { lastAttemptAt: 2, lastFailure: REFUSED }), entry('next')],
|
||||
[submission]
|
||||
)
|
||||
expect(reconciled[0]).toMatchObject({ state: 'unconfirmed' })
|
||||
expect(reconciled[0]?.lastFailure).toBeUndefined()
|
||||
expect(admitStructuredAgentSessionOutboxEntry(reconciled)).toMatchObject({
|
||||
state: 'blocked',
|
||||
entry: { clientMessageId: 'held' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,81 +1,62 @@
|
||||
import type { AgentJournalSubmission } from './agent-session-journal-types'
|
||||
import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox'
|
||||
import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-session-outbox-admission'
|
||||
import { structuredAgentSessionEntryAwaitsSettlement } from './structured-agent-session-outbox-admission'
|
||||
import { handedOffQueuedMessageIds } from './structured-agent-session-draft-hand-off'
|
||||
|
||||
/** Whether only the user's Retry sends this entry again: a rejected one, one whose send failed or
|
||||
* was refused, one a Stop outlived, or one in doubt the unconfirmed probe leaves alone.
|
||||
* `NativeChatDeliveryRetry` offers it. */
|
||||
function awaitsStructuredAgentSessionRetry(entry: StructuredAgentSessionOutboxEntry): boolean {
|
||||
return (
|
||||
entry.state === 'rejected' ||
|
||||
structuredAgentSessionEntryHeldForRetry(entry) ||
|
||||
entry.outlivedStop === true ||
|
||||
(entry.state === 'unconfirmed' && entry.retryAfterUnknownSubmittedAt !== null)
|
||||
)
|
||||
}
|
||||
|
||||
function unsentStructuredAgentSessionOutboxEntry(
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
): (entry: StructuredAgentSessionOutboxEntry) => boolean {
|
||||
function hostHeldIds(submissions: readonly AgentJournalSubmission[]): Set<string> {
|
||||
const held = handedOffQueuedMessageIds(submissions)
|
||||
for (const submission of submissions) {
|
||||
held.add(submission.clientMessageId)
|
||||
}
|
||||
return (entry) => !held.has(entry.clientMessageId) && !awaitsStructuredAgentSessionRetry(entry)
|
||||
return held
|
||||
}
|
||||
|
||||
/** A queue send that has gone out at least once and was not refused, in whatever state it now
|
||||
* waits: the host may hold it as a paused draft, so a local restore too would put the same text
|
||||
* in two places. Read from what went on the wire (`sentDelivery`). */
|
||||
function attemptedQueueSend(entry: StructuredAgentSessionOutboxEntry): boolean {
|
||||
return (
|
||||
entry.sentDelivery === 'queue-if-active' &&
|
||||
entry.lastAttemptAt !== null &&
|
||||
entry.state !== 'rejected'
|
||||
)
|
||||
}
|
||||
|
||||
/** An attempted queue send a Stop keeps is marked, and its state is left to its answer: nothing
|
||||
* but the user's Retry sends it again (the drain holds a marked `queued` entry, and the probe
|
||||
* skips the mark), since a resend onto the session the user just stopped would start a turn if
|
||||
* the host never got it. */
|
||||
function markedOutlivingStop(
|
||||
entry: StructuredAgentSessionOutboxEntry
|
||||
): StructuredAgentSessionOutboxEntry {
|
||||
return attemptedQueueSend(entry) && entry.outlivedStop !== true
|
||||
? { ...entry, outlivedStop: true }
|
||||
: entry
|
||||
/** Whether this entry would still go out on its own: the host holds no row for it and nothing
|
||||
* already owed settles it. */
|
||||
function goesOutOnItsOwn(
|
||||
held: ReadonlySet<string>
|
||||
): (entry: StructuredAgentSessionOutboxEntry) => boolean {
|
||||
return (entry) =>
|
||||
!held.has(entry.clientMessageId) && !structuredAgentSessionEntryAwaitsSettlement(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a Stop leaves in the outbox: nothing the journal does not already hold may go out after it,
|
||||
* so every such entry goes, as a message the host withdraws leaves the chat. The send on its way
|
||||
* stays: it reaches the host ahead of the Stop, and it comes back from the host's answer, since
|
||||
* the agent may already have it. One waiting on Retry keeps it, and so does an issued queue send
|
||||
* that has gone out (a queued receipt or hand-off retires it against the published card), and it
|
||||
* waits for the user's Retry from then on.
|
||||
* What a Stop does to the outbox, before its request: nothing the journal does not already hold may
|
||||
* go out after it.
|
||||
* - A message that never went out is withdrawn here; its text goes back to the composer.
|
||||
* - One already on its way is stamped with the Stop's own id and never sent again: a resend onto the
|
||||
* session the user stopped could start a turn. Its own answer, its journal row or the Stop's
|
||||
* answer settles it (structured-agent-session-send-settlement).
|
||||
* - One the host holds a row for is the journal's to settle.
|
||||
*/
|
||||
export function withdrawUnsentStructuredAgentSessionOutboxEntries(
|
||||
export function stopStructuredAgentSessionOutbox(
|
||||
entries: readonly StructuredAgentSessionOutboxEntry[],
|
||||
submissions: readonly AgentJournalSubmission[],
|
||||
inFlightClientMessageId: string | null
|
||||
): StructuredAgentSessionOutboxEntry[] {
|
||||
const unsent = unsentStructuredAgentSessionOutboxEntry(submissions)
|
||||
return entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.clientMessageId === inFlightClientMessageId ||
|
||||
!unsent(entry) ||
|
||||
attemptedQueueSend(entry)
|
||||
)
|
||||
.map(markedOutlivingStop)
|
||||
inFlightClientMessageId: string | null,
|
||||
stopOperationId: string
|
||||
): {
|
||||
entries: StructuredAgentSessionOutboxEntry[]
|
||||
withdrawn: StructuredAgentSessionOutboxEntry[]
|
||||
} {
|
||||
const goesOut = goesOutOnItsOwn(hostHeldIds(submissions))
|
||||
const kept: StructuredAgentSessionOutboxEntry[] = []
|
||||
const withdrawn: StructuredAgentSessionOutboxEntry[] = []
|
||||
for (const entry of entries) {
|
||||
if (!goesOut(entry)) {
|
||||
kept.push(entry)
|
||||
} else if (entry.lastAttemptAt === null && entry.clientMessageId !== inFlightClientMessageId) {
|
||||
withdrawn.push(entry)
|
||||
} else {
|
||||
kept.push({ ...entry, stoppedBy: { operationId: stopOperationId } })
|
||||
}
|
||||
}
|
||||
return { entries: kept, withdrawn }
|
||||
}
|
||||
|
||||
/** Whether a Stop has something here to withdraw: a message that would still go out on its own. */
|
||||
/** Whether a Stop has something here to withdraw or stamp: a message that would still go out. */
|
||||
export function hasUnsentStructuredAgentSessionOutboxEntry(
|
||||
entries: readonly StructuredAgentSessionOutboxEntry[],
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
): boolean {
|
||||
return entries.some(unsentStructuredAgentSessionOutboxEntry(submissions))
|
||||
return entries.some(goesOutOnItsOwn(hostHeldIds(submissions)))
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
// Which send in doubt Orca resends on its own under the same id, without the user's Retry.
|
||||
// Which send in doubt Orca resends on its own under the same id.
|
||||
|
||||
import type { AgentJournalSubmission } from './agent-session-journal-types'
|
||||
import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox'
|
||||
import { structuredAgentSessionEntryAwaitsSettlement } from './structured-agent-session-outbox-admission'
|
||||
|
||||
/** 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. */
|
||||
/** Whether the unconfirmed probe resends this entry: in doubt, not outrun 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 &&
|
||||
!structuredAgentSessionEntryAwaitsSettlement(entry) &&
|
||||
!submissions.some((submission) => submission.clientMessageId === entry.clientMessageId)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,13 +10,15 @@ import {
|
||||
stageStructuredAgentSessionOutboxEntryForSend,
|
||||
structuredAgentSessionSendMutation,
|
||||
structuredAgentSessionSendRequest,
|
||||
type StructuredAgentSessionOutboxEntry,
|
||||
type StructuredAgentSessionOutboxState
|
||||
} from './structured-agent-session-outbox'
|
||||
import { admitStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-admission'
|
||||
import { structuredAgentSessionEntryAttempt } from './structured-agent-session-outbox-delivery'
|
||||
import { disposeStructuredAgentSessionSendResult } from './structured-agent-session-send-disposition'
|
||||
import { withdrawUnsentStructuredAgentSessionOutboxEntries } from './structured-agent-session-outbox-stop-withdrawal'
|
||||
import {
|
||||
applyStructuredAgentSessionSendSettlement,
|
||||
settleStructuredAgentSessionSendAnswer
|
||||
} from './structured-agent-session-send-settlement'
|
||||
import { stopStructuredAgentSessionOutbox } from './structured-agent-session-outbox-stop-withdrawal'
|
||||
|
||||
function entry(sentDelivery?: 'queue-if-active') {
|
||||
return {
|
||||
@@ -75,7 +77,7 @@ describe('outbox queue delivery', () => {
|
||||
expect(foreign !== null && 'sentDelivery' in foreign).toBe(false)
|
||||
})
|
||||
|
||||
it('Stop keeps every queue send that has gone out, in any state, and marks it for Retry', () => {
|
||||
it('Stop stamps every send that has gone out, in any state, and never admits it again', () => {
|
||||
// An attempted queue send may already be a host-held draft: withdrawing it locally too would
|
||||
// put the same text in the composer AND on a card. Read from what went on the wire.
|
||||
const at = (
|
||||
@@ -93,7 +95,7 @@ describe('outbox queue delivery', () => {
|
||||
...(sent !== undefined ? { lastAttemptAt: 2, sentDelivery: sent } : {}),
|
||||
state
|
||||
})
|
||||
const next = withdrawUnsentStructuredAgentSessionOutboxEntries(
|
||||
const next = stopStructuredAgentSessionOutbox(
|
||||
[
|
||||
at('never-left', 'queued'),
|
||||
// No in-flight id: a `pending` answer freed single-flight before its journal row landed.
|
||||
@@ -104,50 +106,54 @@ describe('outbox queue delivery', () => {
|
||||
at('sent-plain', 'dispatching', null)
|
||||
],
|
||||
[],
|
||||
null
|
||||
null,
|
||||
'stop-1'
|
||||
)
|
||||
// Its state is left to its answer: only the mark holds it back.
|
||||
expect(next.map((entry) => [entry.clientMessageId, entry.state, entry.outlivedStop])).toEqual([
|
||||
['in-flight', 'dispatching', true],
|
||||
['in-doubt', 'unconfirmed', true],
|
||||
['probed', 'queued', true]
|
||||
expect(next.withdrawn.map((entry) => entry.clientMessageId)).toEqual(['never-left'])
|
||||
// Its state is left to its answer: only the stamp holds it back.
|
||||
expect(
|
||||
next.entries.map((entry) => [
|
||||
entry.clientMessageId,
|
||||
entry.state,
|
||||
entry.stoppedBy?.operationId
|
||||
])
|
||||
).toEqual([
|
||||
['in-flight', 'dispatching', 'stop-1'],
|
||||
['in-doubt', 'unconfirmed', 'stop-1'],
|
||||
['probed', 'queued', 'stop-1'],
|
||||
['sent-plain', 'dispatching', 'stop-1']
|
||||
])
|
||||
// The drain never admits the marked one it would otherwise send.
|
||||
expect(admitStructuredAgentSessionOutboxEntry(next.slice(2))).toEqual({
|
||||
state: 'blocked',
|
||||
entry: next[2]
|
||||
expect(admitStructuredAgentSessionOutboxEntry(next.entries)).toEqual({
|
||||
state: 'idle',
|
||||
entry: null
|
||||
})
|
||||
})
|
||||
|
||||
it('Stop during a first queue attempt, then a settled refusal: rotated and rejected, as without it', () => {
|
||||
const operations = ['rotated-1', 'rotated-2']
|
||||
it('Stop during a first queue attempt, then a refusal: its own answer returns it, as without it', () => {
|
||||
const attempt = structuredAgentSessionEntryAttempt(entry(), {
|
||||
capability: 'supported',
|
||||
enabled: true
|
||||
})
|
||||
const staged = stageStructuredAgentSessionOutboxEntryForSend(attempt.stored, 10)
|
||||
const refusal = {
|
||||
ok: false as const,
|
||||
refusal: {
|
||||
code: 'agent_session_operation_invalid' as const,
|
||||
message: 'The message queue is full.'
|
||||
}
|
||||
const settlement = settleStructuredAgentSessionSendAnswer(
|
||||
{
|
||||
kind: 'result',
|
||||
result: {
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'agent_session_operation_invalid',
|
||||
message: 'The message queue is full.'
|
||||
}
|
||||
}
|
||||
},
|
||||
'client-1',
|
||||
{ firstAttempt: true, answersProve: false, journalHasRow: false }
|
||||
)
|
||||
const stopped = stopStructuredAgentSessionOutbox([staged], [], 'client-1', 'stop-1').entries
|
||||
for (const entries of [stopped, [staged]]) {
|
||||
const settled = applyStructuredAgentSessionSendSettlement(entries, 'client-1', settlement)
|
||||
expect(settled.entries).toEqual([])
|
||||
expect(settled.returned?.entry.clientMessageId).toBe('client-1')
|
||||
}
|
||||
const answer = (entries: StructuredAgentSessionOutboxEntry[]) =>
|
||||
disposeStructuredAgentSessionSendResult({
|
||||
entries,
|
||||
entry: attempt.wire,
|
||||
result: refusal,
|
||||
createOperationId: () => operations.shift() ?? 'spent'
|
||||
})
|
||||
const stopped = withdrawUnsentStructuredAgentSessionOutboxEntries([staged], [], 'client-1')
|
||||
const withStop = answer(stopped)
|
||||
const withoutStop = answer([staged])
|
||||
expect(
|
||||
withStop.entries.map((candidate) => [candidate.clientMessageId, candidate.state])
|
||||
).toEqual([['rotated-1', 'rejected']])
|
||||
expect(
|
||||
withoutStop.entries.map((candidate) => [candidate.clientMessageId, candidate.state])
|
||||
).toEqual([['rotated-2', 'rejected']])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
import type { AgentSessionFailureFact } from './agent-session-failure'
|
||||
import { readWholeAgentSessionFailureFact } from './agent-session-failure'
|
||||
import type { AgentJournalMessageItem, AgentJournalSubmission } from './agent-session-journal-types'
|
||||
import {
|
||||
parseAgentSessionWriteFailure,
|
||||
type AgentSessionWriteFailure,
|
||||
type AgentSessionWriteRefusal
|
||||
} from './agent-session-write-failure'
|
||||
import {
|
||||
agentSessionOwnerVerdictAllowsFreshOperationId,
|
||||
agentSessionRefusalOperationState
|
||||
} from './agent-session-refusal-retry'
|
||||
import type {
|
||||
AgentJournalCursor,
|
||||
AgentJournalMessageItem,
|
||||
AgentJournalSubmission
|
||||
} from './agent-session-journal-types'
|
||||
import type { AgentSessionWriteFailure } from './agent-session-write-failure'
|
||||
import {
|
||||
structuredAgentSessionMessageSendMutation,
|
||||
type StructuredAgentSessionSendMutation
|
||||
} from './structured-agent-session-send-mutation'
|
||||
import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection'
|
||||
import { parseStructuredAgentSessionOutboxQueueFields } from './structured-agent-session-outbox-delivery'
|
||||
|
||||
/** `rejected`: the host settled the send as not delivered. The drain never sends it again on its
|
||||
* own and nothing queues behind it; only the user's Retry does. */
|
||||
export type StructuredAgentSessionOutboxState =
|
||||
| 'queued'
|
||||
| 'dispatching'
|
||||
| 'unconfirmed'
|
||||
| 'rejected'
|
||||
/** `queued`: waits to go out. `dispatching`: out, or held by the host as a row it has not handed
|
||||
* to the agent yet. `unconfirmed`: no answer yet, so the same id goes again
|
||||
* (structured-agent-session-send-settlement). */
|
||||
export type StructuredAgentSessionOutboxState = 'queued' | 'dispatching' | 'unconfirmed'
|
||||
|
||||
/** The Stop that outran a send already on its way: by its own id, and where the host's journal
|
||||
* stood when it answered. */
|
||||
export type StructuredAgentSessionOutboxStop = {
|
||||
operationId: string
|
||||
cursor?: AgentJournalCursor
|
||||
/** The Stop was refused, or can't be sent again without stopping something newer. */
|
||||
unanswerable?: true
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionOutboxEntry = {
|
||||
clientMessageId: string
|
||||
@@ -33,19 +34,18 @@ export type StructuredAgentSessionOutboxEntry = {
|
||||
state: StructuredAgentSessionOutboxState
|
||||
queuedAt: number
|
||||
lastAttemptAt: number | null
|
||||
retryAfterUnknownSubmittedAt: number | null
|
||||
source?: 'launch'
|
||||
/** A Stop landed after this queue send went out: only the user's Retry sends it again, never the
|
||||
* drain, the unconfirmed probe or an owner change, which would start a turn the user stopped. */
|
||||
outlivedStop?: true
|
||||
/** A Stop was pressed while this send was out. It never goes again, since a resend onto the
|
||||
* session the user stopped could start a turn; its own answer, its journal row or the Stop's
|
||||
* answer settles it. */
|
||||
stoppedBy?: StructuredAgentSessionOutboxStop
|
||||
/** Saved by an older build that held it for a Retry this build no longer has: it never goes out
|
||||
* again on its own (the person was told it did not go), and the journal settles it once loaded. */
|
||||
legacyUnsettled?: true
|
||||
/** Whether the first attempt asked the host to hold it as a draft (`null`: plain); every replay
|
||||
* of this id asks the same (structured-agent-session-outbox-delivery). On a request's own copy,
|
||||
* what that request carries. */
|
||||
sentDelivery?: 'queue-if-active' | null
|
||||
/** Why the last attempt did not go through. Lives on the message so it goes when the message
|
||||
* is sent again or delivered, instead of outliving it as a separate error. On a `queued` entry
|
||||
* it is also the hold (structured-agent-session-outbox-admission). */
|
||||
lastFailure?: StructuredAgentSessionAttemptFailure
|
||||
}
|
||||
|
||||
/** A host's rejection fact as a message keeps it: never its provider detail, whose log text is not
|
||||
@@ -56,7 +56,7 @@ export type StructuredAgentSessionRejectionFact = Pick<
|
||||
'kind' | 'attachment'
|
||||
>
|
||||
|
||||
/** Kept as the fact, not the words: the Retry row chooses those when it shows the entry. */
|
||||
/** Why a write did not go through, kept as the fact; the words are chosen where it is shown. */
|
||||
export type StructuredAgentSessionAttemptFailure =
|
||||
| AgentSessionWriteFailure
|
||||
/** The host recorded the message and the provider turned it down, with the provider's reason. */
|
||||
@@ -83,26 +83,6 @@ export function structuredAgentSessionRejectedFailure(submission: {
|
||||
}
|
||||
}
|
||||
|
||||
function parseStructuredAgentSessionAttemptFailure(
|
||||
value: unknown
|
||||
): StructuredAgentSessionAttemptFailure | undefined {
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'kind' in value &&
|
||||
value.kind === 'rejected' &&
|
||||
'reason' in value
|
||||
) {
|
||||
return value.reason === null || typeof value.reason === 'string'
|
||||
? structuredAgentSessionRejectedFailure({
|
||||
reason: value.reason,
|
||||
rejection: 'rejection' in value ? value.rejection : undefined
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
return parseAgentSessionWriteFailure(value)
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionAttachment = {
|
||||
path: string
|
||||
previewUri: string
|
||||
@@ -136,8 +116,7 @@ export function createStructuredAgentSessionOutboxEntry(args: {
|
||||
previewUris: args.attachments.map((attachment) => attachment.previewUri),
|
||||
state: 'queued',
|
||||
queuedAt: args.queuedAt,
|
||||
lastAttemptAt: null,
|
||||
retryAfterUnknownSubmittedAt: null
|
||||
lastAttemptAt: null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,108 +134,32 @@ export function updateStructuredAgentSessionOutboxEntry(
|
||||
})
|
||||
}
|
||||
|
||||
/** Staged for another attempt; the last attempt's failure no longer describes it. */
|
||||
export function stageStructuredAgentSessionOutboxEntryForSend(
|
||||
{ lastFailure: _sentAgain, ...entry }: StructuredAgentSessionOutboxEntry,
|
||||
entry: StructuredAgentSessionOutboxEntry,
|
||||
now: number
|
||||
): StructuredAgentSessionOutboxEntry {
|
||||
return { ...entry, state: 'dispatching', lastAttemptAt: now }
|
||||
}
|
||||
|
||||
/** The host forgot this message's id, a day after it was made, and refuses it for good: only a new
|
||||
* id sends it. The id was kept because an earlier attempt under it may already be in the chat; a
|
||||
* first attempt's was replaced when it was refused. */
|
||||
export function structuredAgentSessionEntryIdExpired(
|
||||
entry: StructuredAgentSessionOutboxEntry
|
||||
): boolean {
|
||||
return (
|
||||
entry.state === 'queued' &&
|
||||
entry.lastFailure?.kind === 'refused' &&
|
||||
entry.lastFailure.code === 'agent_session_operation_expired'
|
||||
)
|
||||
}
|
||||
|
||||
export function requeueStructuredAgentSessionSendRefusal(
|
||||
entry: StructuredAgentSessionOutboxEntry,
|
||||
refusal: AgentSessionWriteRefusal,
|
||||
createOperationId: () => string,
|
||||
retainOperationId = false
|
||||
): StructuredAgentSessionOutboxEntry {
|
||||
const refusalSettled = agentSessionRefusalOperationState(refusal.code) === 'settled-rejected'
|
||||
// An exited owner runs nothing under the old id, so a new one can't collide; the message still
|
||||
// waits for its Retry, since nothing recorded it.
|
||||
const ownerExited =
|
||||
refusal.code === 'agent_session_ownership_unknown' &&
|
||||
agentSessionOwnerVerdictAllowsFreshOperationId(refusal.details?.ownerVerdict)
|
||||
if (
|
||||
!(refusalSettled || ownerExited) ||
|
||||
retainOperationId ||
|
||||
entry.state === 'unconfirmed' ||
|
||||
entry.retryAfterUnknownSubmittedAt !== null
|
||||
) {
|
||||
return { ...entry, state: 'queued' }
|
||||
}
|
||||
// Only here may the id rotate: an earlier attempt under this id, or one whose delivery was in
|
||||
// doubt, may have landed, so those keep it. Only a settled refusal proves the message never
|
||||
// landed.
|
||||
return {
|
||||
...entry,
|
||||
clientMessageId: createOperationId(),
|
||||
state: refusalSettled ? 'rejected' : 'queued',
|
||||
lastAttemptAt: null,
|
||||
retryAfterUnknownSubmittedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The outbox as the journal reads it: an entry the host holds a row for leaves once that row has
|
||||
* settled (the row shows it from there), and stays out while it is pending. A view's reading; the
|
||||
* outbox hook settles the stored copy (structured-agent-session-send-settlement).
|
||||
*/
|
||||
export function reconcileStructuredAgentSessionOutbox(
|
||||
entries: readonly StructuredAgentSessionOutboxEntry[],
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
): StructuredAgentSessionOutboxEntry[] {
|
||||
const settled = new Map(submissions.map((entry) => [entry.clientMessageId, entry]))
|
||||
const rows = new Map(submissions.map((entry) => [entry.clientMessageId, entry]))
|
||||
return entries.flatMap((entry) => {
|
||||
const submission = settled.get(entry.clientMessageId)
|
||||
if (submission?.dispatchState === 'accepted') {
|
||||
const submission = rows.get(entry.clientMessageId)
|
||||
if (!submission) {
|
||||
return [entry]
|
||||
}
|
||||
if (submission.dispatchState !== 'pending') {
|
||||
return []
|
||||
}
|
||||
if (
|
||||
submission?.dispatchState === 'rejected' &&
|
||||
classifyDispatchRejection(submission).category === 'withdrawn'
|
||||
) {
|
||||
return []
|
||||
}
|
||||
if (submission?.dispatchState === 'pending') {
|
||||
if (entry.state === 'dispatching') {
|
||||
return [entry]
|
||||
}
|
||||
// The host has it, so no failure of an earlier attempt describes it now.
|
||||
const { lastFailure: _landed, ...landed } = entry
|
||||
return [{ ...landed, state: 'dispatching' as const }]
|
||||
}
|
||||
// Accepted, then not delivered — the agent never started, or its start was refused. The text
|
||||
// and why stay here for the user's Retry, and nothing queues behind it. `unconfirmed` is how a
|
||||
// remount reads an entry it left dispatching; the journal has since answered it.
|
||||
if (
|
||||
submission?.dispatchState === 'rejected' &&
|
||||
(entry.state === 'dispatching' || entry.state === 'unconfirmed')
|
||||
) {
|
||||
return [
|
||||
{
|
||||
...entry,
|
||||
state: 'rejected' as const,
|
||||
lastFailure: structuredAgentSessionRejectedFailure(submission)
|
||||
}
|
||||
]
|
||||
}
|
||||
if (
|
||||
submission?.dispatchState === 'unknown' &&
|
||||
entry.retryAfterUnknownSubmittedAt !== -1 &&
|
||||
entry.retryAfterUnknownSubmittedAt !== submission.submittedAt
|
||||
) {
|
||||
// In doubt now, not failed: the probe's resend decides it, as for any unconfirmed send.
|
||||
const { lastFailure: _superseded, ...inDoubt } = entry
|
||||
return [{ ...inDoubt, state: 'unconfirmed' as const }]
|
||||
}
|
||||
return [entry]
|
||||
return entry.state === 'dispatching' ? [entry] : [{ ...entry, state: 'dispatching' as const }]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -278,28 +181,57 @@ export function parseStructuredAgentSessionOutboxEntry(
|
||||
body.role !== 'user' ||
|
||||
!Array.isArray(body.blocks) ||
|
||||
!Array.isArray(entry.previewUris) ||
|
||||
!entry.previewUris.every((uri) => typeof uri === 'string') ||
|
||||
!['queued', 'dispatching', 'unconfirmed', 'rejected'].includes(entry.state ?? '')
|
||||
!entry.previewUris.every((uri) => typeof uri === 'string')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
// A malformed failure is dropped: the row then says only that the message was not sent.
|
||||
const lastFailure = parseStructuredAgentSessionAttemptFailure(entry.lastFailure)
|
||||
const saved: Record<string, unknown> = { ...entry }
|
||||
// `rejected` is an older build's: read as queued and never sent again (below).
|
||||
const state = saved.state === 'rejected' ? 'queued' : saved.state
|
||||
if (state !== 'queued' && state !== 'dispatching' && state !== 'unconfirmed') {
|
||||
return null
|
||||
}
|
||||
// Older builds held these for a Retry: a rejected one, one with a saved failure, one a Stop
|
||||
// outlived. Read as they were left, never sent again.
|
||||
const legacyUnsettled =
|
||||
saved.legacyUnsettled === true ||
|
||||
saved.state === 'rejected' ||
|
||||
(state === 'queued' && saved.lastFailure !== undefined) ||
|
||||
saved.outlivedStop === true
|
||||
const stoppedBy = parseStructuredAgentSessionOutboxStop(saved.stoppedBy)
|
||||
return {
|
||||
clientMessageId: entry.clientMessageId,
|
||||
sessionId,
|
||||
body,
|
||||
previewUris: entry.previewUris,
|
||||
state: entry.state as StructuredAgentSessionOutboxState,
|
||||
state,
|
||||
queuedAt: entry.queuedAt,
|
||||
lastAttemptAt: typeof entry.lastAttemptAt === 'number' ? entry.lastAttemptAt : null,
|
||||
retryAfterUnknownSubmittedAt:
|
||||
typeof entry.retryAfterUnknownSubmittedAt === 'number'
|
||||
? entry.retryAfterUnknownSubmittedAt
|
||||
: null,
|
||||
...(entry.source === 'launch' ? { source: 'launch' as const } : {}),
|
||||
...parseStructuredAgentSessionOutboxQueueFields(entry),
|
||||
...(lastFailure ? { lastFailure } : {})
|
||||
...(stoppedBy ? { stoppedBy } : {}),
|
||||
...(legacyUnsettled ? { legacyUnsettled: true as const } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function parseStructuredAgentSessionOutboxStop(
|
||||
value: unknown
|
||||
): StructuredAgentSessionOutboxStop | undefined {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return undefined
|
||||
}
|
||||
const stop: Record<string, unknown> = { ...value }
|
||||
if (typeof stop.operationId !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
const cursor: Record<string, unknown> | null =
|
||||
typeof stop.cursor === 'object' && stop.cursor !== null ? { ...stop.cursor } : null
|
||||
return {
|
||||
operationId: stop.operationId,
|
||||
...(cursor && typeof cursor.epoch === 'string' && typeof cursor.sequence === 'number'
|
||||
? { cursor: { epoch: cursor.epoch, sequence: cursor.sequence } }
|
||||
: {}),
|
||||
...(stop.unanswerable === true ? { unanswerable: true as const } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,12 +255,3 @@ export function structuredAgentSessionSendRequest(
|
||||
): Record<string, unknown> {
|
||||
return structuredAgentSessionSendMutation(entry, expectedRuntimeFence)
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionSendFailure = 'delivery-unknown' | 'failed'
|
||||
|
||||
export function classifyStructuredAgentSessionSendFailure(
|
||||
error: unknown,
|
||||
isDeliveryUnknown: (error: unknown) => boolean
|
||||
): StructuredAgentSessionSendFailure {
|
||||
return isDeliveryUnknown(error) ? 'delivery-unknown' : 'failed'
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-ses
|
||||
import { agentSessionWriteNotDoneParts } from './agent-session-refusal-notice'
|
||||
import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy'
|
||||
import { structuredAgentSessionRejectedFailure } from './structured-agent-session-outbox'
|
||||
import { structuredAgentSessionAttemptFailureParts } from './structured-agent-session-send-disposition'
|
||||
import { structuredAgentSessionAttemptFailureParts } from './structured-agent-session-send-failure-words'
|
||||
import { isStructuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row-key'
|
||||
|
||||
/** The facts the chat's loaded start-failure rows state. */
|
||||
|
||||
@@ -1,468 +0,0 @@
|
||||
// What a rejection puts on the user's screen.
|
||||
//
|
||||
// The module is pure so this can be asserted directly instead of through the hook,
|
||||
// which is the whole reason it was split out.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionFailureFact } from './agent-session-failure'
|
||||
import type { AgentJournalSubmission } from './agent-session-journal-types'
|
||||
import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire'
|
||||
import {
|
||||
DISPATCH_REJECTED_CANCELLED,
|
||||
DISPATCH_REJECTED_QUEUE_FULL
|
||||
} from './structured-agent-session-dispatch-rejection'
|
||||
import { agentSessionWriteNoticeEnglish } from './agent-session-refusal-notice'
|
||||
import {
|
||||
disposeStructuredAgentSessionSendFailure,
|
||||
disposeStructuredAgentSessionSendResult,
|
||||
structuredAgentSessionAttemptFailureParts
|
||||
} from './structured-agent-session-send-disposition'
|
||||
import {
|
||||
createStructuredAgentSessionOutboxEntry,
|
||||
reconcileStructuredAgentSessionOutbox,
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from './structured-agent-session-outbox'
|
||||
import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-session-outbox-admission'
|
||||
|
||||
const entry: StructuredAgentSessionOutboxEntry = createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: 'client-1',
|
||||
sessionId: 'session-1',
|
||||
text: 'hello',
|
||||
attachments: [],
|
||||
queuedAt: 1
|
||||
})
|
||||
|
||||
function rejectedWith(
|
||||
reason: string | null,
|
||||
extra: { rejection?: AgentSessionFailureFact; replayed?: boolean } = {}
|
||||
): AgentSessionMutationResult<AgentSessionSendResult> {
|
||||
const submission: AgentJournalSubmission = {
|
||||
clientMessageId: 'client-1',
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'rejected',
|
||||
providerItemId: null,
|
||||
reason,
|
||||
submittedAt: 10,
|
||||
resolvedAt: 10,
|
||||
...(extra.rejection ? { rejection: extra.rejection } : {})
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
replayed: extra.replayed ?? false,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-1', sequence: 10 },
|
||||
value: { clientMessageId: 'client-1', submission }
|
||||
}
|
||||
}
|
||||
|
||||
function notice(reason: string | null, rejection?: AgentSessionFailureFact): string | undefined {
|
||||
const disposition = disposeStructuredAgentSessionSendResult({
|
||||
entries: [entry],
|
||||
entry,
|
||||
result: rejectedWith(reason, rejection ? { rejection } : {}),
|
||||
createOperationId: () => 'unused'
|
||||
})
|
||||
// The reason travels with the message it explains, never as a separate error.
|
||||
expect(disposition.error).toBeNull()
|
||||
const failure = disposition.entries[0]?.lastFailure
|
||||
return (
|
||||
failure && agentSessionWriteNoticeEnglish(structuredAgentSessionAttemptFailureParts(failure))
|
||||
)
|
||||
}
|
||||
|
||||
describe('a queued draft answer', () => {
|
||||
it('retires the outbox entry: the host-held draft carries any later refusal', () => {
|
||||
const disposition = disposeStructuredAgentSessionSendResult({
|
||||
entries: [entry],
|
||||
entry,
|
||||
result: {
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-1', sequence: 10 },
|
||||
value: {
|
||||
clientMessageId: 'client-1',
|
||||
queued: { messageId: 'client-1', position: 1, state: 'waiting' }
|
||||
}
|
||||
},
|
||||
createOperationId: () => 'unused'
|
||||
})
|
||||
expect(disposition.entries).toEqual([])
|
||||
expect(disposition.error).toBeNull()
|
||||
})
|
||||
|
||||
it('a withdrawn replay is spent, not unknown', () => {
|
||||
const disposition = disposeStructuredAgentSessionSendResult({
|
||||
entries: [entry],
|
||||
entry,
|
||||
result: {
|
||||
ok: true,
|
||||
replayed: true,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-1', sequence: 10 },
|
||||
value: {
|
||||
clientMessageId: 'client-1',
|
||||
queued: { messageId: 'client-1', position: 1, state: 'withdrawn' }
|
||||
}
|
||||
},
|
||||
createOperationId: () => 'unused'
|
||||
})
|
||||
expect(disposition.entries).toEqual([])
|
||||
expect(disposition.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('what a rejection shows the user', () => {
|
||||
it('removes a queued message the provider confirms Stop cancelled', () => {
|
||||
const result = rejectedWith(DISPATCH_REJECTED_CANCELLED)
|
||||
if (!result.ok || !('submission' in result.value)) {
|
||||
throw new Error('expected rejected submission fixture')
|
||||
}
|
||||
|
||||
expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission])).toEqual([])
|
||||
})
|
||||
|
||||
it('never puts the transport marker on screen', () => {
|
||||
const shown = notice('provider_write_failed: broken pipe')
|
||||
// `provider_write_failed: broken pipe` names nothing a person can act on.
|
||||
expect(shown).not.toContain('provider_write_failed')
|
||||
expect(shown).not.toContain('broken pipe')
|
||||
expect(shown).toBe("Orca couldn't reach the agent. Your message was not sent.")
|
||||
})
|
||||
|
||||
it('shows a content rejection in the provider own words', () => {
|
||||
// The provider explaining itself IS the answer; a generic string throws it away.
|
||||
expect(notice('Claude messages support at most 20 images')).toBe(
|
||||
'Claude messages support at most 20 images'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the sentence a host wrote for the person reading it', () => {
|
||||
const reason = 'The provider stopped before it finished starting.'
|
||||
expect(notice(reason)).toBe(reason)
|
||||
})
|
||||
|
||||
it('never puts the legacy not_delivered marker on screen', () => {
|
||||
// Released clients printed it as it was; it is a marker, not a sentence.
|
||||
expect(notice('not_delivered')).toBe('Your message was not sent.')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the legacy marker alone', undefined],
|
||||
['the marker and its typed fact', { kind: 'cancelled' as const }]
|
||||
])('fails nothing for a Stop-withdrawn send, first reply or replay: %s', (_label, rejection) => {
|
||||
for (const replayed of [false, true]) {
|
||||
const disposition = disposeStructuredAgentSessionSendResult({
|
||||
entries: [entry],
|
||||
entry,
|
||||
result: rejectedWith(DISPATCH_REJECTED_CANCELLED, { rejection, replayed }),
|
||||
createOperationId: () => 'unused'
|
||||
})
|
||||
expect(disposition).toEqual({ entries: [], error: null })
|
||||
}
|
||||
})
|
||||
|
||||
it('reads a withdrawal off the typed fact whatever the reason says', () => {
|
||||
const result = rejectedWith('Withdrawn.', { rejection: { kind: 'cancelled' } })
|
||||
if (!result.ok || !('submission' in result.value)) {
|
||||
throw new Error('expected rejected submission fixture')
|
||||
}
|
||||
expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission])).toEqual([])
|
||||
expect(
|
||||
disposeStructuredAgentSessionSendResult({
|
||||
entries: [entry],
|
||||
entry,
|
||||
result,
|
||||
createOperationId: () => 'unused'
|
||||
}).error
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('claims no cause when the rejection names none', () => {
|
||||
expect(notice(null)).toBe('Your message was not sent.')
|
||||
})
|
||||
|
||||
it('never puts a local-capacity marker on screen either', () => {
|
||||
// Neither the provider's words nor a transport failure: a refusal we minted
|
||||
// ourselves. It has no user-facing meaning, so it gets copy rather than the token.
|
||||
const shown = notice(DISPATCH_REJECTED_QUEUE_FULL)
|
||||
expect(shown).not.toContain('queue is full')
|
||||
expect(shown).toBe('Your message was not sent.')
|
||||
})
|
||||
})
|
||||
|
||||
// A row that carries the host's fact is worded from it; the reason is not read.
|
||||
describe('what a rejection with a typed fact shows the user', () => {
|
||||
it('says Orca could not hand the message over, whatever the reason holds', () => {
|
||||
expect(notice('provider_write_failed', { kind: 'writeFailed' })).toBe(
|
||||
"Orca couldn't reach the agent. Your message was not sent."
|
||||
)
|
||||
expect(notice('Something unrelated.', { kind: 'writeFailed' })).toBe(
|
||||
"Orca couldn't reach the agent. Your message was not sent."
|
||||
)
|
||||
})
|
||||
|
||||
it("rebuilds the fact's sentence where a marker stands in for it", () => {
|
||||
expect(notice(DISPATCH_REJECTED_QUEUE_FULL, { kind: 'queueFull' })).toBe(
|
||||
'Too many messages were waiting for the agent, so this one was not sent.'
|
||||
)
|
||||
})
|
||||
|
||||
// The surface names the agent; the host's own sentence is never compared or shown.
|
||||
it('words the fact itself, never the sentence the host wrote beside it', () => {
|
||||
expect(
|
||||
notice('Claude never finished starting, so Orca stopped it.', { kind: 'hostStopped' })
|
||||
).toBe('The agent never finished starting, so Orca stopped it.')
|
||||
})
|
||||
|
||||
// The message keeps no fact it cannot place, so the host's sentence stands, as on an older host.
|
||||
it("shows a newer host's sentence when its fact cannot be placed", () => {
|
||||
expect(notice('A sentence a newer host wrote.', JSON.parse('{"kind":"fromTheFuture"}'))).toBe(
|
||||
'A sentence a newer host wrote.'
|
||||
)
|
||||
})
|
||||
|
||||
// The message's copy drops the detail and the refusal these kinds are worded from, so the
|
||||
// sentence the host wrote for the person stands in for them; with none, the table's words.
|
||||
it("shows the host's sentence for a kind whose words its copy cannot rebuild", () => {
|
||||
expect(
|
||||
notice('The provider did not accept this message: Image type .bmp.', {
|
||||
kind: 'providerRejected',
|
||||
detail: { text: 'Image type .bmp', audience: 'person' }
|
||||
})
|
||||
).toBe('The provider did not accept this message: Image type .bmp.')
|
||||
expect(
|
||||
notice("Claude couldn't start. Start a new chat to continue.", {
|
||||
kind: 'startFailed',
|
||||
refusal: { code: 'agent_session_identity_required' }
|
||||
})
|
||||
).toBe("Claude couldn't start. Start a new chat to continue.")
|
||||
expect(notice(null, { kind: 'providerRejected' })).toBe(
|
||||
'The provider did not accept this message.'
|
||||
)
|
||||
})
|
||||
|
||||
it('says only that the message was not sent for a fact no message can carry', () => {
|
||||
expect(notice('Compaction failed.', { kind: 'compactionFailed' })).toBe(
|
||||
'Your message was not sent.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('what a refusal shows the user', () => {
|
||||
it('keeps the refusal as a fact on the message, without the host diagnostic', () => {
|
||||
const disposition = disposeStructuredAgentSessionSendResult({
|
||||
entries: [entry],
|
||||
entry,
|
||||
result: {
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'agent_session_checkpoint_stale',
|
||||
message: 'Expected runtime fence 1; the session is at 3.'
|
||||
}
|
||||
},
|
||||
createOperationId: () => 'unused'
|
||||
})
|
||||
|
||||
expect(disposition.error).toBeNull()
|
||||
expect(structuredAgentSessionEntryHeldForRetry(disposition.entries[0]!)).toBe(true)
|
||||
expect(disposition.entries).toMatchObject([
|
||||
{
|
||||
clientMessageId: entry.clientMessageId,
|
||||
state: 'queued',
|
||||
lastFailure: { kind: 'refused', code: 'agent_session_checkpoint_stale' }
|
||||
}
|
||||
])
|
||||
expect(
|
||||
agentSessionWriteNoticeEnglish(
|
||||
structuredAgentSessionAttemptFailureParts(disposition.entries[0]!.lastFailure!)
|
||||
)
|
||||
).toBe('Your message was not sent.')
|
||||
})
|
||||
|
||||
it("keeps the reason the host named, and says it on the message's Retry row", () => {
|
||||
const disposition = disposeStructuredAgentSessionSendResult({
|
||||
entries: [entry],
|
||||
entry,
|
||||
result: {
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'agent_session_conflict',
|
||||
message: 'The chat is still starting.',
|
||||
details: { reason: 'chatStarting' }
|
||||
}
|
||||
},
|
||||
createOperationId: () => 'unused'
|
||||
})
|
||||
|
||||
const lastFailure = disposition.entries[0]?.lastFailure
|
||||
expect(lastFailure).toEqual({
|
||||
kind: 'refused',
|
||||
code: 'agent_session_conflict',
|
||||
details: { reason: 'chatStarting' }
|
||||
})
|
||||
expect(
|
||||
agentSessionWriteNoticeEnglish(structuredAgentSessionAttemptFailureParts(lastFailure!))
|
||||
).toBe(
|
||||
'The agent is still starting. Your message was not sent. Wait for the agent to finish starting.'
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps a rejected message's typed fact without the provider's detail", () => {
|
||||
const disposition = disposeStructuredAgentSessionSendResult({
|
||||
entries: [entry],
|
||||
entry,
|
||||
result: rejectedWith('An image on this message is empty, so the message was not sent.', {
|
||||
rejection: {
|
||||
kind: 'attachmentInvalid',
|
||||
attachment: { reason: 'empty' },
|
||||
detail: { text: 'image block 2: zero bytes', audience: 'log' }
|
||||
}
|
||||
}),
|
||||
createOperationId: () => 'unused'
|
||||
})
|
||||
|
||||
expect(disposition.entries[0]?.lastFailure).toEqual({
|
||||
kind: 'rejected',
|
||||
reason: 'An image on this message is empty, so the message was not sent.',
|
||||
rejection: { kind: 'attachmentInvalid', attachment: { reason: 'empty' } }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a failed request as a fact, not a transport error string', () => {
|
||||
const disposition = disposeStructuredAgentSessionSendFailure({
|
||||
entries: [entry],
|
||||
entry,
|
||||
cause: new Error('socket hang up: ECONNRESET 10.0.0.2:443'),
|
||||
isDeliveryUnknown: () => false
|
||||
})
|
||||
|
||||
expect(disposition.error).toBeNull()
|
||||
expect(disposition.entries[0]?.lastFailure).toEqual({ kind: 'failed' })
|
||||
})
|
||||
|
||||
it('drops the reason once the same message is accepted', () => {
|
||||
const refused: StructuredAgentSessionOutboxEntry = {
|
||||
...entry,
|
||||
lastFailure: { kind: 'refused', code: 'agent_session_checkpoint_stale' }
|
||||
}
|
||||
const result = rejectedWith(null)
|
||||
if (!result.ok || !('submission' in result.value)) {
|
||||
throw new Error('expected a send result')
|
||||
}
|
||||
result.value.submission = { ...result.value.submission, dispatchState: 'accepted' }
|
||||
const disposition = disposeStructuredAgentSessionSendResult({
|
||||
entries: [refused],
|
||||
entry: refused,
|
||||
result,
|
||||
createOperationId: () => 'unused'
|
||||
})
|
||||
|
||||
expect(disposition.entries).toEqual([])
|
||||
expect(disposition.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ambiguous operation refusals', () => {
|
||||
it.each([
|
||||
{ ...entry, state: 'unconfirmed' as const, lastAttemptAt: 10 },
|
||||
{ ...entry, state: 'queued' as const, lastAttemptAt: 10, retryAfterUnknownSubmittedAt: 10 }
|
||||
])(
|
||||
'never rotates $state operation after its host tombstone expires; holds it for its Retry',
|
||||
(ambiguous) => {
|
||||
const disposition = disposeStructuredAgentSessionSendResult({
|
||||
entries: [ambiguous],
|
||||
entry: ambiguous,
|
||||
result: {
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'agent_session_operation_expired',
|
||||
message: 'Operation expired.'
|
||||
}
|
||||
},
|
||||
createOperationId: () => 'fresh-id'
|
||||
})
|
||||
|
||||
// An earlier attempt under the kept id may have landed, so no new id goes out on its own.
|
||||
expect(disposition.entries).toMatchObject([
|
||||
{
|
||||
clientMessageId: entry.clientMessageId,
|
||||
state: 'queued',
|
||||
lastFailure: { kind: 'refused', code: 'agent_session_operation_expired' }
|
||||
}
|
||||
])
|
||||
expect(structuredAgentSessionEntryHeldForRetry(disposition.entries[0]!)).toBe(true)
|
||||
expect(disposition.error).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('rotates a first attempt the host calls expired, which nothing can have delivered', () => {
|
||||
const disposition = disposeStructuredAgentSessionSendResult({
|
||||
entries: [entry],
|
||||
entry,
|
||||
result: {
|
||||
ok: false,
|
||||
refusal: { code: 'agent_session_operation_expired', message: 'Operation expired.' }
|
||||
},
|
||||
createOperationId: () => 'fresh-id'
|
||||
})
|
||||
|
||||
expect(disposition.entries).toMatchObject([{ clientMessageId: 'fresh-id', state: 'rejected' }])
|
||||
})
|
||||
|
||||
it("parks an older host's restart answer that carries the reason but not the recovered marker", () => {
|
||||
const result = rejectedWith(null)
|
||||
if (!result.ok || !('submission' in result.value)) {
|
||||
throw new Error('expected a send result')
|
||||
}
|
||||
result.value.submission = {
|
||||
...result.value.submission,
|
||||
dispatchState: 'unknown',
|
||||
reason: 'host_restarted_before_acknowledgement'
|
||||
}
|
||||
|
||||
const disposition = disposeStructuredAgentSessionSendResult({
|
||||
entries: [entry],
|
||||
entry,
|
||||
result,
|
||||
createOperationId: () => 'unused'
|
||||
})
|
||||
|
||||
expect(disposition.entries).toMatchObject([
|
||||
{
|
||||
clientMessageId: entry.clientMessageId,
|
||||
state: 'unconfirmed',
|
||||
retryAfterUnknownSubmittedAt: -1
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('parks a recovered missing submission without polling forever', () => {
|
||||
const result = rejectedWith(null)
|
||||
if (!result.ok || !('submission' in result.value)) {
|
||||
throw new Error('expected a send result')
|
||||
}
|
||||
result.value.submission = {
|
||||
...result.value.submission,
|
||||
dispatchState: 'unknown',
|
||||
reason: 'durable_send_submission_missing',
|
||||
recovered: true
|
||||
}
|
||||
|
||||
const disposition = disposeStructuredAgentSessionSendResult({
|
||||
entries: [entry],
|
||||
entry,
|
||||
result,
|
||||
createOperationId: () => 'unused'
|
||||
})
|
||||
|
||||
expect(disposition.entries).toMatchObject([
|
||||
{
|
||||
clientMessageId: entry.clientMessageId,
|
||||
state: 'unconfirmed',
|
||||
retryAfterUnknownSubmittedAt: -1
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,329 +0,0 @@
|
||||
// How one send outcome changes the outbox.
|
||||
//
|
||||
// The sibling of `reconcileStructuredAgentSessionOutbox`: that one folds the
|
||||
// journal's view of a submission into the queue, this one folds the answer to a
|
||||
// single `agentSession.send`. Both write the same state, so they live together
|
||||
// and speak the same vocabulary. Pure on purpose — the hook that calls this owns
|
||||
// the refs, the React state and the storage write, and nothing else decides an
|
||||
// entry's state.
|
||||
|
||||
import type { AgentJournalSubmission } from './agent-session-journal-types'
|
||||
import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire'
|
||||
import {
|
||||
agentSessionWriteNoticeEnglish,
|
||||
agentSessionWriteNoticeParts,
|
||||
agentSessionWriteNotDoneParts
|
||||
} from './agent-session-refusal-notice'
|
||||
import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy'
|
||||
import {
|
||||
agentSessionRefusalFailure,
|
||||
type AgentSessionWriteRefusal
|
||||
} from './agent-session-write-failure'
|
||||
import type { AgentSessionFailureFact } from './agent-session-failure'
|
||||
import type { AgentSessionFailureWordsContext } from './agent-session-failure-words'
|
||||
import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection'
|
||||
import { isRecoveredStructuredAgentSessionSubmission } from './structured-agent-session-unanswered-dispatch'
|
||||
import {
|
||||
classifyStructuredAgentSessionSendFailure,
|
||||
requeueStructuredAgentSessionSendRefusal,
|
||||
structuredAgentSessionRejectedFailure,
|
||||
type StructuredAgentSessionAttemptFailure,
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from './structured-agent-session-outbox'
|
||||
|
||||
export type StructuredAgentSessionSendDisposition = {
|
||||
entries: StructuredAgentSessionOutboxEntry[]
|
||||
/** Only for an outcome with no entry left to carry it; a kept entry holds its own failure. */
|
||||
error: AgentSessionWriteNoticePart[] | null
|
||||
}
|
||||
|
||||
/** A message this client couldn't store to send; the composer's draft or the row's Retry still
|
||||
* has it. */
|
||||
export const STRUCTURED_AGENT_SESSION_OUTBOX_NOT_SAVED: readonly AgentSessionWriteNoticePart[] = [
|
||||
'messageNotSaved',
|
||||
'tryAgain'
|
||||
]
|
||||
|
||||
type SendDispositionInput = {
|
||||
entries: readonly StructuredAgentSessionOutboxEntry[]
|
||||
entry: StructuredAgentSessionOutboxEntry
|
||||
}
|
||||
|
||||
function replaceEntryState(
|
||||
input: SendDispositionInput,
|
||||
state: StructuredAgentSessionOutboxEntry['state'],
|
||||
lastFailure?: StructuredAgentSessionAttemptFailure
|
||||
): StructuredAgentSessionOutboxEntry[] {
|
||||
return input.entries.map((candidate) =>
|
||||
candidate.clientMessageId === input.entry.clientMessageId
|
||||
? withLastFailure({ ...candidate, state }, lastFailure)
|
||||
: candidate
|
||||
)
|
||||
}
|
||||
|
||||
function withLastFailure(
|
||||
entry: StructuredAgentSessionOutboxEntry,
|
||||
lastFailure: StructuredAgentSessionAttemptFailure | undefined
|
||||
): StructuredAgentSessionOutboxEntry {
|
||||
const { lastFailure: _previous, ...rest } = entry
|
||||
return lastFailure === undefined ? rest : { ...rest, lastFailure }
|
||||
}
|
||||
|
||||
function dropEntry(input: SendDispositionInput): StructuredAgentSessionOutboxEntry[] {
|
||||
return input.entries.filter(
|
||||
(candidate) => candidate.clientMessageId !== input.entry.clientMessageId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The user force-retried a host-confirmed `unknown` and got the same submission
|
||||
* back. That is now the only answer such a retry can get: `unknown` means the
|
||||
* host cannot tell whether the provider has the message, and no reason it
|
||||
* records ever makes a second delivery safe. Parking the entry would offer a
|
||||
* Retry that does nothing in front of a queue nothing can drain, so it leaves
|
||||
* the outbox. Nothing is lost from the conversation: the durable submission row
|
||||
* already renders the message.
|
||||
*
|
||||
* A `rejected` submission takes the other path — the message provably did not
|
||||
* happen, so Retry rotates the id and sends it as a genuinely new message.
|
||||
*/
|
||||
function refusedRedelivery(
|
||||
entry: StructuredAgentSessionOutboxEntry,
|
||||
submission: AgentJournalSubmission
|
||||
): boolean {
|
||||
return (
|
||||
entry.retryAfterUnknownSubmittedAt !== null &&
|
||||
submission.dispatchState === 'unknown' &&
|
||||
submission.submittedAt === entry.retryAfterUnknownSubmittedAt
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether the journal already answers a send still in flight, so its own reply adds nothing: the
|
||||
* host holds the message, rejected it, or handed it off as a queued draft — a later `pending`
|
||||
* reply must not undo that. */
|
||||
export function journalAnswersInFlightSend(
|
||||
submissions: readonly AgentJournalSubmission[],
|
||||
clientMessageId: string | null
|
||||
): boolean {
|
||||
return submissions.some(
|
||||
(submission) =>
|
||||
(submission.clientMessageId === clientMessageId && submission.dispatchState !== 'unknown') ||
|
||||
(clientMessageId !== null && submission.queuedMessageId === clientMessageId)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What to put on screen for a rejection.
|
||||
*
|
||||
* A content rejection's reason is the provider explaining itself, so it is shown
|
||||
* verbatim — "Claude does not support the image type .bmp" is the whole answer and
|
||||
* a generic string would throw it away. A transport rejection's reason is an
|
||||
* internal marker; printing it put `provider_write_failed: broken pipe` in front of
|
||||
* users, which names nothing they can act on. That case gets copy that says what
|
||||
* happened, and on the phone that the message can be sent again — which it can,
|
||||
* because the frame provably never left, so a resend cannot duplicate.
|
||||
*
|
||||
* The null default claims no cause and no next step, because at that point we know
|
||||
* neither: all it asserts is the one thing every rejection shares.
|
||||
*
|
||||
* Exported because a client without an outbox needs the same copy: the rule about
|
||||
* which reasons a person may read is a property of the reason, not of the queue.
|
||||
*/
|
||||
export function structuredAgentSessionRejectionNotice(
|
||||
reason: string | null,
|
||||
write: 'send' | 'composer-send'
|
||||
): string {
|
||||
return agentSessionWriteNoticeEnglish(structuredAgentSessionRejectionParts(reason, write))
|
||||
}
|
||||
|
||||
export function structuredAgentSessionRejectionParts(
|
||||
reason: string | null,
|
||||
write: 'send' | 'composer-send',
|
||||
/** The host's typed fact, which decides when the row carried one. */
|
||||
fact?: AgentSessionFailureFact,
|
||||
context: AgentSessionFailureWordsContext = {}
|
||||
): AgentSessionWriteNoticePart[] {
|
||||
if (fact) {
|
||||
return rejectionFactParts(write, fact, context)
|
||||
}
|
||||
if (reason === null) {
|
||||
return ['notDoneSend']
|
||||
}
|
||||
const rejection = classifyDispatchRejection({ reason })
|
||||
if (rejection.kind === 'writeFailed') {
|
||||
return ['unreachable', ...agentSessionWriteNotDoneParts(write)]
|
||||
}
|
||||
// A legacy marker is an internal cause with no user-facing meaning; any other reason is a
|
||||
// sentence written to be read — the provider's, or the host's own.
|
||||
return rejection.kind ? agentSessionWriteNotDoneParts(write) : [{ text: reason }]
|
||||
}
|
||||
|
||||
function rejectionFactParts(
|
||||
write: 'send' | 'composer-send',
|
||||
fact: AgentSessionFailureFact,
|
||||
context: AgentSessionFailureWordsContext
|
||||
): AgentSessionWriteNoticePart[] {
|
||||
const { kind } = classifyDispatchRejection({ reason: null, rejection: fact })
|
||||
if (kind === 'writeFailed') {
|
||||
return ['unreachable', ...agentSessionWriteNotDoneParts(write)]
|
||||
}
|
||||
// A fact this build cannot place proves only that the message did not happen.
|
||||
return kind
|
||||
? [{ failure: { ...fact, kind }, surface: 'rejection', context }]
|
||||
: agentSessionWriteNotDoneParts(write)
|
||||
}
|
||||
|
||||
/** Kinds whose words need what the message's copy drops: the provider's detail, or the refusal. */
|
||||
const WORDED_FROM_WHOLE_FACT: ReadonlySet<AgentSessionFailureFact['kind']> = new Set<
|
||||
AgentSessionFailureFact['kind']
|
||||
>(['providerRejected', 'startFailed', 'restartFailed'])
|
||||
|
||||
/** What the Retry row says about why its message did not go through. */
|
||||
export function structuredAgentSessionAttemptFailureParts(
|
||||
failure: StructuredAgentSessionAttemptFailure,
|
||||
context: AgentSessionFailureWordsContext = {},
|
||||
/** The journal's whole fact for a recorded rejection, when its submission is loaded: the
|
||||
* message's own copy keeps only its kind and attachment. */
|
||||
recorded?: AgentSessionFailureFact
|
||||
): AgentSessionWriteNoticePart[] {
|
||||
if (failure.kind !== 'rejected') {
|
||||
return agentSessionWriteNoticeParts(failure, 'send', context)
|
||||
}
|
||||
const fact = recorded ?? failure.rejection
|
||||
// Without the journal's fact, the host's sentence still holds what the copy dropped.
|
||||
if (!recorded && fact && WORDED_FROM_WHOLE_FACT.has(fact.kind) && failure.reason !== null) {
|
||||
return [{ text: failure.reason }]
|
||||
}
|
||||
return structuredAgentSessionRejectionParts(failure.reason, 'send', fact, context)
|
||||
}
|
||||
|
||||
/** A send the host refused, whether it returned the refusal or threw it. */
|
||||
export function disposeStructuredAgentSessionSendRefusal(
|
||||
input: SendDispositionInput & {
|
||||
refusal: AgentSessionWriteRefusal
|
||||
createOperationId: () => string
|
||||
}
|
||||
): StructuredAgentSessionSendDisposition {
|
||||
// The refusal saved on a message it keeps `queued` is what holds it for the user's Retry.
|
||||
const entries: StructuredAgentSessionOutboxEntry[] = input.entries.map((candidate) =>
|
||||
candidate.clientMessageId === input.entry.clientMessageId
|
||||
? withLastFailure(
|
||||
requeueStructuredAgentSessionSendRefusal(
|
||||
candidate,
|
||||
input.refusal,
|
||||
input.createOperationId,
|
||||
input.entry.lastAttemptAt !== null
|
||||
),
|
||||
input.refusal
|
||||
)
|
||||
: candidate
|
||||
)
|
||||
return { entries, error: null }
|
||||
}
|
||||
|
||||
export function disposeStructuredAgentSessionSendResult(
|
||||
input: SendDispositionInput & {
|
||||
result: AgentSessionMutationResult<AgentSessionSendResult>
|
||||
createOperationId: () => string
|
||||
}
|
||||
): StructuredAgentSessionSendDisposition {
|
||||
const result = input.result
|
||||
if (!result.ok) {
|
||||
return disposeStructuredAgentSessionSendRefusal({
|
||||
...input,
|
||||
refusal: agentSessionRefusalFailure(result.refusal)
|
||||
})
|
||||
}
|
||||
if ('queued' in result.value) {
|
||||
// The host holds the draft (or already settled it, on a replay). Either way
|
||||
// the send is spent and the queue owns it now: the outbox entry retires,
|
||||
// and the draft card — not this queue — carries any later refusal.
|
||||
return {
|
||||
entries: dropEntry(input),
|
||||
error: null
|
||||
}
|
||||
}
|
||||
const submission = result.value.submission
|
||||
// A replay of a send the host queued and then handed off answers with that hand-off, under its
|
||||
// own id: the host owns the message, and the entry leaves as the reconcile drops it.
|
||||
if (submission.queuedMessageId === input.entry.clientMessageId) {
|
||||
return {
|
||||
entries: dropEntry(input),
|
||||
error: null
|
||||
}
|
||||
}
|
||||
if (refusedRedelivery(input.entry, submission)) {
|
||||
return {
|
||||
entries: dropEntry(input),
|
||||
error: ['sendOutcomeLost']
|
||||
}
|
||||
}
|
||||
if (submission.dispatchState === 'accepted') {
|
||||
return {
|
||||
entries: dropEntry(input),
|
||||
error: null
|
||||
}
|
||||
}
|
||||
// A Stop's withdrawal failed nothing, first reply or replay: the entry leaves as the reconcile
|
||||
// drops it, with no notice.
|
||||
if (
|
||||
submission.dispatchState === 'rejected' &&
|
||||
classifyDispatchRejection(submission).category === 'withdrawn'
|
||||
) {
|
||||
return {
|
||||
entries: dropEntry(input),
|
||||
error: null
|
||||
}
|
||||
}
|
||||
if (submission.dispatchState === 'rejected') {
|
||||
return {
|
||||
entries: replaceEntryState(
|
||||
input,
|
||||
'rejected',
|
||||
structuredAgentSessionRejectedFailure(submission)
|
||||
),
|
||||
error: null
|
||||
}
|
||||
}
|
||||
if (isRecoveredStructuredAgentSessionSubmission(submission)) {
|
||||
return {
|
||||
entries: input.entries.map((candidate) =>
|
||||
candidate.clientMessageId === input.entry.clientMessageId
|
||||
? { ...candidate, state: 'unconfirmed', retryAfterUnknownSubmittedAt: -1 }
|
||||
: candidate
|
||||
),
|
||||
error: null
|
||||
}
|
||||
}
|
||||
// `pending` is the host saying the message was written and is awaiting the
|
||||
// provider's acknowledgement, which cannot arrive until the turn ahead of it
|
||||
// ends. That is not doubt, and keeping order is no longer the reason to hold
|
||||
// the entry -- the host fixed the order when it wrote the row. It stays
|
||||
// because a `pending` can still settle `rejected` or `unknown`, and only the
|
||||
// entry carries the retry state that answer needs.
|
||||
return {
|
||||
entries: replaceEntryState(
|
||||
input,
|
||||
submission.dispatchState === 'unknown' ? 'unconfirmed' : 'dispatching'
|
||||
),
|
||||
error: null
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeStructuredAgentSessionSendFailure(
|
||||
input: SendDispositionInput & {
|
||||
cause: unknown
|
||||
isDeliveryUnknown: (error: unknown) => boolean
|
||||
}
|
||||
): StructuredAgentSessionSendDisposition {
|
||||
const failure = classifyStructuredAgentSessionSendFailure(input.cause, input.isDeliveryUnknown)
|
||||
const deliveryUnknown = failure === 'delivery-unknown'
|
||||
return {
|
||||
// An unconfirmed entry's Retry row already says delivery is unconfirmed; the saved failure
|
||||
// holds the other for its Retry.
|
||||
entries: deliveryUnknown
|
||||
? replaceEntryState(input, 'unconfirmed')
|
||||
: replaceEntryState(input, 'queued', { kind: 'failed' }),
|
||||
error: null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// What a rejection puts on the user's screen.
|
||||
//
|
||||
// The module is pure so this can be asserted directly instead of through the hook,
|
||||
// which is the whole reason it was split out.
|
||||
|
||||
// What a rejection puts on the user's screen.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionFailureFact } from './agent-session-failure'
|
||||
import type { AgentJournalSubmission } from './agent-session-journal-types'
|
||||
import { DISPATCH_REJECTED_QUEUE_FULL } from './structured-agent-session-dispatch-rejection'
|
||||
import { agentSessionWriteNoticeEnglish } from './agent-session-refusal-notice'
|
||||
import { structuredAgentSessionAttemptFailureParts } from './structured-agent-session-send-failure-words'
|
||||
import { structuredAgentSessionRejectedFailure } from './structured-agent-session-outbox'
|
||||
|
||||
function rejected(
|
||||
reason: string | null,
|
||||
rejection?: AgentSessionFailureFact
|
||||
): AgentJournalSubmission {
|
||||
return {
|
||||
clientMessageId: 'client-1',
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'rejected',
|
||||
providerItemId: null,
|
||||
reason,
|
||||
submittedAt: 10,
|
||||
resolvedAt: 10,
|
||||
...(rejection ? { rejection } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function notice(reason: string | null, rejection?: AgentSessionFailureFact): string {
|
||||
return agentSessionWriteNoticeEnglish(
|
||||
structuredAgentSessionAttemptFailureParts(
|
||||
structuredAgentSessionRejectedFailure(rejected(reason, rejection))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
describe('what a rejection shows the user', () => {
|
||||
it('never puts the transport marker on screen', () => {
|
||||
const shown = notice('provider_write_failed: broken pipe')
|
||||
// `provider_write_failed: broken pipe` names nothing a person can act on.
|
||||
expect(shown).not.toContain('provider_write_failed')
|
||||
expect(shown).not.toContain('broken pipe')
|
||||
expect(shown).toBe("Orca couldn't reach the agent. Your message was not sent.")
|
||||
})
|
||||
|
||||
it('shows a content rejection in the provider own words', () => {
|
||||
// The provider explaining itself IS the answer; a generic string throws it away.
|
||||
expect(notice('Claude messages support at most 20 images')).toBe(
|
||||
'Claude messages support at most 20 images'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the sentence a host wrote for the person reading it', () => {
|
||||
const reason = 'The provider stopped before it finished starting.'
|
||||
expect(notice(reason)).toBe(reason)
|
||||
})
|
||||
|
||||
it('never puts the legacy not_delivered marker on screen', () => {
|
||||
// Released clients printed it as it was; it is a marker, not a sentence.
|
||||
expect(notice('not_delivered')).toBe('Your message was not sent.')
|
||||
})
|
||||
|
||||
it('claims no cause when the rejection names none', () => {
|
||||
expect(notice(null)).toBe('Your message was not sent.')
|
||||
})
|
||||
|
||||
it('never puts a local-capacity marker on screen either', () => {
|
||||
// Neither the provider's words nor a transport failure: a refusal we minted
|
||||
// ourselves. It has no user-facing meaning, so it gets copy rather than the token.
|
||||
const shown = notice(DISPATCH_REJECTED_QUEUE_FULL)
|
||||
expect(shown).not.toContain('queue is full')
|
||||
expect(shown).toBe('Your message was not sent.')
|
||||
})
|
||||
})
|
||||
|
||||
// A row that carries the host's fact is worded from it; the reason is not read.
|
||||
describe('what a rejection with a typed fact shows the user', () => {
|
||||
it('says Orca could not hand the message over, whatever the reason holds', () => {
|
||||
expect(notice('provider_write_failed', { kind: 'writeFailed' })).toBe(
|
||||
"Orca couldn't reach the agent. Your message was not sent."
|
||||
)
|
||||
expect(notice('Something unrelated.', { kind: 'writeFailed' })).toBe(
|
||||
"Orca couldn't reach the agent. Your message was not sent."
|
||||
)
|
||||
})
|
||||
|
||||
it("rebuilds the fact's sentence where a marker stands in for it", () => {
|
||||
expect(notice(DISPATCH_REJECTED_QUEUE_FULL, { kind: 'queueFull' })).toBe(
|
||||
'Too many messages were waiting for the agent, so this one was not sent.'
|
||||
)
|
||||
})
|
||||
|
||||
// The surface names the agent; the host's own sentence is never compared or shown.
|
||||
it('words the fact itself, never the sentence the host wrote beside it', () => {
|
||||
expect(
|
||||
notice('Claude never finished starting, so Orca stopped it.', { kind: 'hostStopped' })
|
||||
).toBe('The agent never finished starting, so Orca stopped it.')
|
||||
})
|
||||
|
||||
// The message keeps no fact it cannot place, so the host's sentence stands, as on an older host.
|
||||
it("shows a newer host's sentence when its fact cannot be placed", () => {
|
||||
expect(notice('A sentence a newer host wrote.', JSON.parse('{"kind":"fromTheFuture"}'))).toBe(
|
||||
'A sentence a newer host wrote.'
|
||||
)
|
||||
})
|
||||
|
||||
// The message's copy drops the detail and the refusal these kinds are worded from, so the
|
||||
// sentence the host wrote for the person stands in for them; with none, the table's words.
|
||||
it("shows the host's sentence for a kind whose words its copy cannot rebuild", () => {
|
||||
expect(
|
||||
notice('The provider did not accept this message: Image type .bmp.', {
|
||||
kind: 'providerRejected',
|
||||
detail: { text: 'Image type .bmp', audience: 'person' }
|
||||
})
|
||||
).toBe('The provider did not accept this message: Image type .bmp.')
|
||||
expect(
|
||||
notice("Claude couldn't start. Start a new chat to continue.", {
|
||||
kind: 'startFailed',
|
||||
refusal: { code: 'agent_session_identity_required' }
|
||||
})
|
||||
).toBe("Claude couldn't start. Start a new chat to continue.")
|
||||
expect(notice(null, { kind: 'providerRejected' })).toBe(
|
||||
'The provider did not accept this message.'
|
||||
)
|
||||
})
|
||||
|
||||
it('says only that the message was not sent for a fact no message can carry', () => {
|
||||
expect(notice('Compaction failed.', { kind: 'compactionFailed' })).toBe(
|
||||
'Your message was not sent.'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
// The words for a chat send that did not go through: a rejection the host recorded, a refusal, or
|
||||
// a message this client could not store.
|
||||
|
||||
import {
|
||||
agentSessionWriteNoticeEnglish,
|
||||
agentSessionWriteNoticeParts,
|
||||
agentSessionWriteNotDoneParts
|
||||
} from './agent-session-refusal-notice'
|
||||
import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy'
|
||||
import type { AgentSessionFailureFact } from './agent-session-failure'
|
||||
import type { AgentSessionFailureWordsContext } from './agent-session-failure-words'
|
||||
import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection'
|
||||
import type { StructuredAgentSessionAttemptFailure } from './structured-agent-session-outbox'
|
||||
|
||||
/** A message this client couldn't store to send; the composer's draft still has it. */
|
||||
export const STRUCTURED_AGENT_SESSION_OUTBOX_NOT_SAVED: readonly AgentSessionWriteNoticePart[] = [
|
||||
'messageNotSaved',
|
||||
'tryAgain'
|
||||
]
|
||||
|
||||
/**
|
||||
* What to put on screen for a rejection.
|
||||
*
|
||||
* A content rejection's reason is the provider explaining itself, so it is shown
|
||||
* verbatim — "Claude does not support the image type .bmp" is the whole answer and
|
||||
* a generic string would throw it away. A transport rejection's reason is an
|
||||
* internal marker; printing it put `provider_write_failed: broken pipe` in front of
|
||||
* users, which names nothing they can act on. That case gets copy that says what
|
||||
* happened, and on the phone that the message can be sent again — which it can,
|
||||
* because the frame provably never left, so a resend cannot duplicate.
|
||||
*
|
||||
* The null default claims no cause and no next step, because at that point we know
|
||||
* neither: all it asserts is the one thing every rejection shares.
|
||||
*
|
||||
* Exported because a client without an outbox needs the same copy: the rule about
|
||||
* which reasons a person may read is a property of the reason, not of the queue.
|
||||
*/
|
||||
export function structuredAgentSessionRejectionNotice(
|
||||
reason: string | null,
|
||||
write: 'send' | 'composer-send'
|
||||
): string {
|
||||
return agentSessionWriteNoticeEnglish(structuredAgentSessionRejectionParts(reason, write))
|
||||
}
|
||||
|
||||
export function structuredAgentSessionRejectionParts(
|
||||
reason: string | null,
|
||||
write: 'send' | 'composer-send',
|
||||
/** The host's typed fact, which decides when the row carried one. */
|
||||
fact?: AgentSessionFailureFact,
|
||||
context: AgentSessionFailureWordsContext = {}
|
||||
): AgentSessionWriteNoticePart[] {
|
||||
if (fact) {
|
||||
return rejectionFactParts(write, fact, context)
|
||||
}
|
||||
if (reason === null) {
|
||||
return ['notDoneSend']
|
||||
}
|
||||
const rejection = classifyDispatchRejection({ reason })
|
||||
if (rejection.kind === 'writeFailed') {
|
||||
return ['unreachable', ...agentSessionWriteNotDoneParts(write)]
|
||||
}
|
||||
// A legacy marker is an internal cause with no user-facing meaning; any other reason is a
|
||||
// sentence written to be read — the provider's, or the host's own.
|
||||
return rejection.kind ? agentSessionWriteNotDoneParts(write) : [{ text: reason }]
|
||||
}
|
||||
|
||||
function rejectionFactParts(
|
||||
write: 'send' | 'composer-send',
|
||||
fact: AgentSessionFailureFact,
|
||||
context: AgentSessionFailureWordsContext
|
||||
): AgentSessionWriteNoticePart[] {
|
||||
const { kind } = classifyDispatchRejection({ reason: null, rejection: fact })
|
||||
if (kind === 'writeFailed') {
|
||||
return ['unreachable', ...agentSessionWriteNotDoneParts(write)]
|
||||
}
|
||||
// A fact this build cannot place proves only that the message did not happen.
|
||||
return kind
|
||||
? [{ failure: { ...fact, kind }, surface: 'rejection', context }]
|
||||
: agentSessionWriteNotDoneParts(write)
|
||||
}
|
||||
|
||||
/** Kinds whose words need what the message's copy drops: the provider's detail, or the refusal. */
|
||||
const WORDED_FROM_WHOLE_FACT: ReadonlySet<AgentSessionFailureFact['kind']> = new Set<
|
||||
AgentSessionFailureFact['kind']
|
||||
>(['providerRejected', 'startFailed', 'restartFailed'])
|
||||
|
||||
/** Why a message the host recorded, or a queued card, did not go through. */
|
||||
export function structuredAgentSessionAttemptFailureParts(
|
||||
failure: StructuredAgentSessionAttemptFailure,
|
||||
context: AgentSessionFailureWordsContext = {},
|
||||
/** The journal's whole fact for a recorded rejection, when its submission is loaded: the
|
||||
* message's own copy keeps only its kind and attachment. */
|
||||
recorded?: AgentSessionFailureFact
|
||||
): AgentSessionWriteNoticePart[] {
|
||||
if (failure.kind !== 'rejected') {
|
||||
return agentSessionWriteNoticeParts(failure, 'send', context)
|
||||
}
|
||||
const fact = recorded ?? failure.rejection
|
||||
// Without the journal's fact, the host's sentence still holds what the copy dropped.
|
||||
if (!recorded && fact && WORDED_FROM_WHOLE_FACT.has(fact.kind) && failure.reason !== null) {
|
||||
return [{ text: failure.reason }]
|
||||
}
|
||||
return structuredAgentSessionRejectionParts(failure.reason, 'send', fact, context)
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentJournalSubmission } from './agent-session-journal-types'
|
||||
import type { AgentSessionWireRefusalCode } from './agent-session-wire'
|
||||
import {
|
||||
createStructuredAgentSessionOutboxEntry,
|
||||
parseStructuredAgentSessionOutboxEntry,
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from './structured-agent-session-outbox'
|
||||
import {
|
||||
applyStructuredAgentSessionSendSettlement,
|
||||
settleStructuredAgentSessionEntryFromJournal,
|
||||
settleStructuredAgentSessionSendAnswer,
|
||||
structuredAgentSessionEntryOutlivedHostWindow,
|
||||
type StructuredAgentSessionSendAnswer,
|
||||
type StructuredAgentSessionSendSettlementContext
|
||||
} from './structured-agent-session-send-settlement'
|
||||
import { AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS } from './agent-session-host-authority'
|
||||
import { DISPATCH_REJECTED_CANCELLED } from './structured-agent-session-dispatch-rejection'
|
||||
|
||||
const ID = '1759600000000-0123456789abcdef0123456789abcdef'
|
||||
const NO_ROWS: AgentJournalSubmission[] = []
|
||||
|
||||
function entry(
|
||||
patch: Partial<StructuredAgentSessionOutboxEntry> = {}
|
||||
): StructuredAgentSessionOutboxEntry {
|
||||
return {
|
||||
...createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: ID,
|
||||
sessionId: 'session-1',
|
||||
text: 'hello',
|
||||
attachments: [],
|
||||
queuedAt: 1
|
||||
}),
|
||||
...patch
|
||||
}
|
||||
}
|
||||
|
||||
function row(patch: Partial<AgentJournalSubmission> = {}): AgentJournalSubmission {
|
||||
return {
|
||||
clientMessageId: ID,
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fp',
|
||||
dispatchState: 'accepted',
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
submittedAt: 1,
|
||||
resolvedAt: 1,
|
||||
...patch
|
||||
}
|
||||
}
|
||||
|
||||
function sent(submission: AgentJournalSubmission): StructuredAgentSessionSendAnswer {
|
||||
return {
|
||||
kind: 'result',
|
||||
result: {
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'e', sequence: 1 },
|
||||
value: { clientMessageId: ID, submission }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refused(
|
||||
code: AgentSessionWireRefusalCode,
|
||||
details?: Record<string, unknown>
|
||||
): StructuredAgentSessionSendAnswer {
|
||||
return {
|
||||
kind: 'result',
|
||||
result: { ok: false, refusal: { code, message: code, ...(details ? { details } : {}) } }
|
||||
}
|
||||
}
|
||||
|
||||
const FIRST: StructuredAgentSessionSendSettlementContext = {
|
||||
firstAttempt: true,
|
||||
answersProve: true,
|
||||
journalHasRow: false
|
||||
}
|
||||
const RESEND_PROVING: StructuredAgentSessionSendSettlementContext = {
|
||||
...FIRST,
|
||||
firstAttempt: false
|
||||
}
|
||||
const RESEND_OLD_HOST: StructuredAgentSessionSendSettlementContext = {
|
||||
...RESEND_PROVING,
|
||||
answersProve: false
|
||||
}
|
||||
|
||||
describe('a send answer settles one of three ways', () => {
|
||||
it("case 1: any row the host returns, in any settled state, is the host's from then on", () => {
|
||||
for (const state of ['accepted', 'rejected', 'unknown'] as const) {
|
||||
expect(
|
||||
settleStructuredAgentSessionSendAnswer(sent(row({ dispatchState: state })), ID, FIRST)
|
||||
).toEqual({
|
||||
kind: 'recorded'
|
||||
})
|
||||
}
|
||||
// A recovered unknown (a restart lost its outcome) is a record too, so it never parks the queue.
|
||||
expect(
|
||||
settleStructuredAgentSessionSendAnswer(
|
||||
sent(row({ dispatchState: 'unknown', recovered: true })),
|
||||
ID,
|
||||
RESEND_OLD_HOST
|
||||
)
|
||||
).toEqual({ kind: 'recorded' })
|
||||
})
|
||||
|
||||
it('case 1: a queued receipt or its hand-off belongs to the host', () => {
|
||||
expect(
|
||||
settleStructuredAgentSessionSendAnswer(
|
||||
{
|
||||
kind: 'result',
|
||||
result: {
|
||||
ok: true,
|
||||
replayed: true,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'e', sequence: 1 },
|
||||
value: { clientMessageId: ID, queued: { messageId: ID, position: 0, state: 'waiting' } }
|
||||
}
|
||||
},
|
||||
ID,
|
||||
FIRST
|
||||
)
|
||||
).toEqual({ kind: 'recorded' })
|
||||
expect(
|
||||
settleStructuredAgentSessionSendAnswer(
|
||||
sent(row({ clientMessageId: 'other', queuedMessageId: ID, dispatchState: 'rejected' })),
|
||||
ID,
|
||||
FIRST
|
||||
)
|
||||
).toEqual({ kind: 'recorded' })
|
||||
})
|
||||
|
||||
it('a pending row keeps the entry until the row settles; a Stop withdrawal hands it back silently', () => {
|
||||
expect(
|
||||
settleStructuredAgentSessionSendAnswer(sent(row({ dispatchState: 'pending' })), ID, FIRST)
|
||||
).toEqual({ kind: 'pending' })
|
||||
expect(
|
||||
settleStructuredAgentSessionSendAnswer(
|
||||
sent(row({ dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED })),
|
||||
ID,
|
||||
FIRST
|
||||
)
|
||||
).toEqual({ kind: 'withdrawn' })
|
||||
})
|
||||
|
||||
it("case 2: a first attempt's refusal proves no record, on any host", () => {
|
||||
const settled = settleStructuredAgentSessionSendAnswer(
|
||||
refused('agent_session_operation_capacity'),
|
||||
ID,
|
||||
{ ...FIRST, answersProve: false }
|
||||
)
|
||||
expect(settled.kind).toBe('returned')
|
||||
})
|
||||
|
||||
it("case 2: on a host whose answers prove, a resend's refusal proves no record", () => {
|
||||
expect(
|
||||
settleStructuredAgentSessionSendAnswer(
|
||||
refused('agent_session_journal_unreadable'),
|
||||
ID,
|
||||
RESEND_PROVING
|
||||
).kind
|
||||
).toBe('returned')
|
||||
})
|
||||
|
||||
it('case 3: an older host may refuse a resent id before looking it up, so it goes again', () => {
|
||||
expect(
|
||||
settleStructuredAgentSessionSendAnswer(
|
||||
refused('agent_session_journal_unreadable'),
|
||||
ID,
|
||||
RESEND_OLD_HOST
|
||||
)
|
||||
).toEqual({ kind: 'unanswered' })
|
||||
})
|
||||
|
||||
it('case 3: "outcome unknown" is never proof, even on a first attempt', () => {
|
||||
expect(
|
||||
settleStructuredAgentSessionSendAnswer(
|
||||
refused('agent_session_operation_unknown', { reason: 'outcomeUnknown' }),
|
||||
ID,
|
||||
FIRST
|
||||
)
|
||||
).toEqual({ kind: 'unanswered' })
|
||||
})
|
||||
|
||||
it('an expired id is settled by the journal: a row keeps it, none hands it back to check', () => {
|
||||
expect(
|
||||
settleStructuredAgentSessionSendAnswer(refused('agent_session_operation_expired'), ID, {
|
||||
...RESEND_OLD_HOST,
|
||||
journalHasRow: true
|
||||
})
|
||||
).toEqual({ kind: 'recorded' })
|
||||
expect(
|
||||
settleStructuredAgentSessionSendAnswer(
|
||||
refused('agent_session_operation_expired'),
|
||||
ID,
|
||||
RESEND_OLD_HOST
|
||||
)
|
||||
).toEqual({ kind: 'returned', words: ['sendOutcomeLost'] })
|
||||
})
|
||||
|
||||
it('a closed chat or a reused id can never settle by resending, so it comes back to check', () => {
|
||||
for (const answer of [
|
||||
refused('agent_session_ownership_unknown', { reason: 'sessionNotAttached' }),
|
||||
refused('agent_session_operation_conflict', { reason: 'fingerprintMismatch' })
|
||||
]) {
|
||||
expect(settleStructuredAgentSessionSendAnswer(answer, ID, RESEND_PROVING)).toEqual({
|
||||
kind: 'returned',
|
||||
words: ['sendOutcomeLost']
|
||||
})
|
||||
expect(settleStructuredAgentSessionSendAnswer(answer, ID, RESEND_OLD_HOST)).toEqual({
|
||||
kind: 'returned',
|
||||
words: ['sendOutcomeLost']
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('a thrown error is no answer, by code: a timeout, a closed socket or a thrown refusal goes again', () => {
|
||||
for (const rpcCode of [
|
||||
'runtime_timeout',
|
||||
'remote_runtime_unavailable',
|
||||
'runtime_unavailable',
|
||||
undefined
|
||||
]) {
|
||||
expect(
|
||||
settleStructuredAgentSessionSendAnswer(
|
||||
{ kind: 'thrown', carriedRefusal: false, rpcCode },
|
||||
ID,
|
||||
FIRST
|
||||
)
|
||||
).toEqual({ kind: 'unanswered' })
|
||||
}
|
||||
expect(
|
||||
settleStructuredAgentSessionSendAnswer(
|
||||
{ kind: 'thrown', carriedRefusal: true, rpcCode: 'agent_session_refused' },
|
||||
ID,
|
||||
FIRST
|
||||
)
|
||||
).toEqual({ kind: 'unanswered' })
|
||||
})
|
||||
|
||||
it('a call the host turned away before running it proves no record', () => {
|
||||
for (const rpcCode of ['method_not_found', 'invalid_argument']) {
|
||||
expect(
|
||||
settleStructuredAgentSessionSendAnswer(
|
||||
{ kind: 'thrown', carriedRefusal: false, rpcCode },
|
||||
ID,
|
||||
RESEND_OLD_HOST
|
||||
).kind
|
||||
).toBe('returned')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('the journal settles what an answer did not', () => {
|
||||
const reading = {
|
||||
submissions: NO_ROWS,
|
||||
cursor: { epoch: 'e', sequence: 10 },
|
||||
inFlightClientMessageId: null,
|
||||
queuedMessageIds: null
|
||||
}
|
||||
|
||||
it('a recovered-unknown row is a record: the parked head leaves, so nothing waits behind it', () => {
|
||||
const settled = settleStructuredAgentSessionEntryFromJournal(entry({ state: 'unconfirmed' }), {
|
||||
...reading,
|
||||
submissions: [row({ dispatchState: 'unknown', recovered: true })]
|
||||
})
|
||||
expect(settled).toEqual({ kind: 'recorded' })
|
||||
})
|
||||
|
||||
it('a row while the send is in flight as pending changes nothing', () => {
|
||||
expect(
|
||||
settleStructuredAgentSessionEntryFromJournal(entry({ state: 'dispatching' }), {
|
||||
...reading,
|
||||
submissions: [row({ dispatchState: 'pending' })]
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('no row: an ordinary entry waits for its own answer', () => {
|
||||
expect(
|
||||
settleStructuredAgentSessionEntryFromJournal(entry({ state: 'unconfirmed' }), reading)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("a host-published queued card under the id is the host's", () => {
|
||||
expect(
|
||||
settleStructuredAgentSessionEntryFromJournal(entry(), { ...reading, queuedMessageIds: [ID] })
|
||||
).toEqual({ kind: 'recorded' })
|
||||
})
|
||||
|
||||
it("a Stop's stamp: no row once the journal is read through the Stop's answer hands it back silently", () => {
|
||||
const stopped = entry({
|
||||
state: 'unconfirmed',
|
||||
lastAttemptAt: 2,
|
||||
stoppedBy: { operationId: 'stop-1', cursor: { epoch: 'e', sequence: 12 } }
|
||||
})
|
||||
expect(settleStructuredAgentSessionEntryFromJournal(stopped, reading)).toBeNull()
|
||||
expect(
|
||||
settleStructuredAgentSessionEntryFromJournal(stopped, {
|
||||
...reading,
|
||||
cursor: { epoch: 'e', sequence: 12 }
|
||||
})
|
||||
).toEqual({ kind: 'withdrawn' })
|
||||
// Its own request still out: its answer settles it.
|
||||
expect(
|
||||
settleStructuredAgentSessionEntryFromJournal(stopped, {
|
||||
...reading,
|
||||
cursor: { epoch: 'e', sequence: 12 },
|
||||
inFlightClientMessageId: ID
|
||||
})
|
||||
).toBeNull()
|
||||
// Another epoch can't say; the person checks.
|
||||
expect(
|
||||
settleStructuredAgentSessionEntryFromJournal(stopped, {
|
||||
...reading,
|
||||
cursor: { epoch: 'f', sequence: 99 }
|
||||
})
|
||||
).toEqual({ kind: 'returned', words: ['sendOutcomeLost'] })
|
||||
})
|
||||
|
||||
it("a stamped send whose row the journal shows is the host's, whatever the Stop said", () => {
|
||||
const stopped = entry({ state: 'unconfirmed', stoppedBy: { operationId: 'stop-1' } })
|
||||
expect(
|
||||
settleStructuredAgentSessionEntryFromJournal(stopped, {
|
||||
...reading,
|
||||
submissions: [row({ dispatchState: 'accepted' })]
|
||||
})
|
||||
).toEqual({ kind: 'recorded' })
|
||||
})
|
||||
|
||||
it('a Stop that will never be answered hands a stamped send back to check, once the journal loads', () => {
|
||||
const stopped = entry({
|
||||
state: 'unconfirmed',
|
||||
stoppedBy: { operationId: 'stop-1', unanswerable: true }
|
||||
})
|
||||
expect(
|
||||
settleStructuredAgentSessionEntryFromJournal(stopped, { ...reading, cursor: null })
|
||||
).toBeNull()
|
||||
expect(settleStructuredAgentSessionEntryFromJournal(stopped, reading)).toEqual({
|
||||
kind: 'returned',
|
||||
words: ['sendOutcomeLost']
|
||||
})
|
||||
})
|
||||
|
||||
it("a queue send stamped by a Stop waits for the host's draft list before coming back", () => {
|
||||
const stopped = entry({
|
||||
state: 'unconfirmed',
|
||||
sentDelivery: 'queue-if-active',
|
||||
stoppedBy: { operationId: 'stop-1', cursor: { epoch: 'e', sequence: 1 } }
|
||||
})
|
||||
expect(settleStructuredAgentSessionEntryFromJournal(stopped, reading)).toBeNull()
|
||||
expect(
|
||||
settleStructuredAgentSessionEntryFromJournal(stopped, { ...reading, queuedMessageIds: [] })
|
||||
).toEqual({ kind: 'withdrawn' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('entries an older build saved are migrated, never sent again', () => {
|
||||
const saved = (value: Record<string, unknown>) =>
|
||||
parseStructuredAgentSessionOutboxEntry({ ...entry(), ...value }, 'session-1')
|
||||
|
||||
it('rejected, held for Retry and outlived-Stop entries read as awaiting settlement', () => {
|
||||
expect(saved({ state: 'rejected' })).toMatchObject({ state: 'queued', legacyUnsettled: true })
|
||||
expect(saved({ state: 'queued', lastFailure: { kind: 'failed' } })).toMatchObject({
|
||||
legacyUnsettled: true
|
||||
})
|
||||
expect(saved({ state: 'unconfirmed', outlivedStop: true })).toMatchObject({
|
||||
legacyUnsettled: true
|
||||
})
|
||||
// An unconfirmed one is unchanged: it is still resent.
|
||||
expect(saved({ state: 'unconfirmed', retryAfterUnknownSubmittedAt: 5 })).not.toHaveProperty(
|
||||
'legacyUnsettled'
|
||||
)
|
||||
})
|
||||
|
||||
it('once the journal loads: a row drops it, none hands it back with words to check the chat', () => {
|
||||
const legacy = saved({ state: 'rejected' })!
|
||||
const reading = {
|
||||
submissions: NO_ROWS,
|
||||
cursor: null,
|
||||
inFlightClientMessageId: null,
|
||||
queuedMessageIds: null
|
||||
}
|
||||
expect(settleStructuredAgentSessionEntryFromJournal(legacy, reading)).toBeNull()
|
||||
expect(
|
||||
settleStructuredAgentSessionEntryFromJournal(legacy, {
|
||||
...reading,
|
||||
cursor: { epoch: 'e', sequence: 1 }
|
||||
})
|
||||
).toEqual({ kind: 'returned', words: ['sendOutcomeLost'] })
|
||||
expect(
|
||||
settleStructuredAgentSessionEntryFromJournal(legacy, {
|
||||
...reading,
|
||||
submissions: [row({ dispatchState: 'rejected' })]
|
||||
})
|
||||
).toEqual({ kind: 'recorded' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('applying a settlement', () => {
|
||||
it('records and returns leave the outbox; only a return hands the entry back, with its words', () => {
|
||||
const outbox = [entry(), entry({ clientMessageId: 'next' })]
|
||||
expect(applyStructuredAgentSessionSendSettlement(outbox, ID, { kind: 'recorded' })).toEqual({
|
||||
entries: [outbox[1]],
|
||||
returned: null
|
||||
})
|
||||
expect(
|
||||
applyStructuredAgentSessionSendSettlement(outbox, ID, {
|
||||
kind: 'returned',
|
||||
words: ['tryAgain']
|
||||
})
|
||||
).toEqual({ entries: [outbox[1]], returned: { entry: outbox[0], words: ['tryAgain'] } })
|
||||
expect(
|
||||
applyStructuredAgentSessionSendSettlement(outbox, ID, { kind: 'withdrawn' }).returned
|
||||
).toEqual({
|
||||
entry: outbox[0],
|
||||
words: null
|
||||
})
|
||||
})
|
||||
|
||||
it('no answer keeps it under the same id, in doubt; pending keeps it dispatching', () => {
|
||||
const outbox = [entry({ state: 'dispatching' })]
|
||||
expect(
|
||||
applyStructuredAgentSessionSendSettlement(outbox, ID, { kind: 'unanswered' }).entries[0]
|
||||
).toMatchObject({ clientMessageId: ID, state: 'unconfirmed' })
|
||||
expect(
|
||||
applyStructuredAgentSessionSendSettlement(outbox, ID, { kind: 'pending' }).entries[0]
|
||||
).toMatchObject({ clientMessageId: ID, state: 'dispatching' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('the host window bounds every entry', () => {
|
||||
it('an id older than the host replays can no longer be settled by a resend', () => {
|
||||
const madeAt = 1_759_600_000_000
|
||||
expect(structuredAgentSessionEntryOutlivedHostWindow(entry(), madeAt + 1000)).toBe(false)
|
||||
expect(
|
||||
structuredAgentSessionEntryOutlivedHostWindow(
|
||||
entry(),
|
||||
madeAt + AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS + 1
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,280 @@
|
||||
// How a desktop chat send ends. Every send ends one of three ways, decided only by what the host
|
||||
// said, never by a flag this client stored:
|
||||
// 1. recorded: the host holds a row, a queued card or a hand-off, and from then on the host's row
|
||||
// shows the message to every viewer, however it ends;
|
||||
// 2. returned: the host proved it has no record and never will, so the text goes back to the
|
||||
// chat's draft and the reason is said once;
|
||||
// 3. unanswered: nothing proves either, so the same id is sent again until the host answers. The
|
||||
// host records an id at most once, so a resend can't deliver twice.
|
||||
// Pure on purpose: the callers own the outbox, the draft and the chat line.
|
||||
|
||||
import type { AgentJournalCursor, AgentJournalSubmission } from './agent-session-journal-types'
|
||||
import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire'
|
||||
import { agentSessionWriteNoticeParts } from './agent-session-refusal-notice'
|
||||
import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy'
|
||||
import {
|
||||
agentSessionRefusalFailure,
|
||||
agentSessionRpcErrorFailure,
|
||||
type AgentSessionWriteRefusal
|
||||
} from './agent-session-write-failure'
|
||||
import {
|
||||
AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS,
|
||||
parseAgentSessionOperationTimestamp
|
||||
} from './agent-session-host-authority'
|
||||
import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection'
|
||||
import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox'
|
||||
|
||||
export type StructuredAgentSessionSendSettlement =
|
||||
/** Case 1: the host's row (or card) holds the message from here. */
|
||||
| { kind: 'recorded' }
|
||||
/** Case 1, not final yet: the host wrote the row and has not handed it to the agent. The entry
|
||||
* stays until the row settles, since a Stop may still withdraw it back to this composer. */
|
||||
| { kind: 'pending' }
|
||||
/** A Stop took it back: the text returns to the draft and nothing is said. */
|
||||
| { kind: 'withdrawn' }
|
||||
/** Case 2: the text returns to the draft with these words on the chat line. */
|
||||
| { kind: 'returned'; words: AgentSessionWriteNoticePart[] }
|
||||
/** Case 3: no answer; the same id goes again. */
|
||||
| { kind: 'unanswered' }
|
||||
|
||||
/** What one `agentSession.send` attempt got back. */
|
||||
export type StructuredAgentSessionSendAnswer =
|
||||
| { kind: 'result'; result: AgentSessionMutationResult<AgentSessionSendResult> }
|
||||
/** The request threw. Read by its codes, never its message text: whether its error carried a
|
||||
* host refusal, and the RPC error code. */
|
||||
| { kind: 'thrown'; carriedRefusal: boolean; rpcCode: string | undefined }
|
||||
|
||||
export type StructuredAgentSessionSendSettlementContext = {
|
||||
/** No earlier attempt under this id went out from anywhere, so nothing can hold it but this
|
||||
* attempt. Only an older host needs it: it may refuse a resent id before looking it up. */
|
||||
firstAttempt: boolean
|
||||
/** The host checks a resent id before anything else, so every refusal it returns proves the id
|
||||
* has no record (`agent-session.send-answers-proof.v1`). */
|
||||
answersProve: boolean
|
||||
/** The loaded journal holds a row for this id. */
|
||||
journalHasRow: boolean
|
||||
}
|
||||
|
||||
/** Words for a message Orca can no longer settle with the host: an earlier attempt may already be
|
||||
* in the chat, so the person checks before sending it again. */
|
||||
export const STRUCTURED_AGENT_SESSION_SEND_UNCONFIRMED_WORDS: readonly AgentSessionWriteNoticePart[] =
|
||||
['sendOutcomeLost']
|
||||
|
||||
function returnedFor(refusal: AgentSessionWriteRefusal): StructuredAgentSessionSendSettlement {
|
||||
return { kind: 'returned', words: agentSessionWriteNoticeParts(refusal, 'composer-send') }
|
||||
}
|
||||
|
||||
function refusalSettlement(
|
||||
refusal: AgentSessionWriteRefusal,
|
||||
context: StructuredAgentSessionSendSettlementContext
|
||||
): StructuredAgentSessionSendSettlement {
|
||||
const reason = refusal.details?.reason
|
||||
if (refusal.code === 'agent_session_operation_unknown') {
|
||||
// A rewind the host refused before writing anything is settled; every other unknown is doubt.
|
||||
return reason === 'rewindUnconfirmed' ? returnedFor(refusal) : { kind: 'unanswered' }
|
||||
}
|
||||
if (refusal.code === 'agent_session_operation_expired') {
|
||||
// The host forgot the id; the journal is what still knows whether it landed.
|
||||
return context.journalHasRow
|
||||
? { kind: 'recorded' }
|
||||
: { kind: 'returned', words: [...STRUCTURED_AGENT_SESSION_SEND_UNCONFIRMED_WORDS] }
|
||||
}
|
||||
if (context.firstAttempt) {
|
||||
return returnedFor(refusal)
|
||||
}
|
||||
if (
|
||||
refusal.code === 'agent_session_operation_conflict' ||
|
||||
(refusal.code === 'agent_session_ownership_unknown' && reason === 'sessionNotAttached')
|
||||
) {
|
||||
// The id holds another payload, or the chat is closed on the host, which answers that before
|
||||
// looking the id up: no resend can settle it, and what an earlier attempt left is in the chat.
|
||||
return context.journalHasRow
|
||||
? { kind: 'recorded' }
|
||||
: { kind: 'returned', words: [...STRUCTURED_AGENT_SESSION_SEND_UNCONFIRMED_WORDS] }
|
||||
}
|
||||
// An older host may refuse a resent id before looking it up, so only the next resend can tell.
|
||||
return context.answersProve ? returnedFor(refusal) : { kind: 'unanswered' }
|
||||
}
|
||||
|
||||
/** A request that threw is not an answer: no code tells "never written" from "written, then the
|
||||
* answer was lost", and a thrown refusal may come after the host wrote the row. Only a host that
|
||||
* turned the call away before running it (a method it lacks, params it rejects) proves no row. */
|
||||
function thrownSettlement(
|
||||
answer: Extract<StructuredAgentSessionSendAnswer, { kind: 'thrown' }>
|
||||
): StructuredAgentSessionSendSettlement {
|
||||
if (answer.carriedRefusal) {
|
||||
return { kind: 'unanswered' }
|
||||
}
|
||||
const failure = agentSessionRpcErrorFailure(answer.rpcCode)
|
||||
return failure.kind === 'refused' ? returnedFor(failure) : { kind: 'unanswered' }
|
||||
}
|
||||
|
||||
/** The host's answer to one attempt, as one of the three ends. */
|
||||
export function settleStructuredAgentSessionSendAnswer(
|
||||
answer: StructuredAgentSessionSendAnswer,
|
||||
clientMessageId: string,
|
||||
context: StructuredAgentSessionSendSettlementContext
|
||||
): StructuredAgentSessionSendSettlement {
|
||||
if (answer.kind === 'thrown') {
|
||||
return thrownSettlement(answer)
|
||||
}
|
||||
const { result } = answer
|
||||
if (!result.ok) {
|
||||
return refusalSettlement(agentSessionRefusalFailure(result.refusal), context)
|
||||
}
|
||||
if ('queued' in result.value) {
|
||||
return { kind: 'recorded' }
|
||||
}
|
||||
const { submission } = result.value
|
||||
// A replay answering with the hand-off of the draft this send queued: the host owns it.
|
||||
if (submission.queuedMessageId === clientMessageId) {
|
||||
return { kind: 'recorded' }
|
||||
}
|
||||
return settleStructuredAgentSessionSendRow(submission)
|
||||
}
|
||||
|
||||
/** Whether the host can no longer settle this id: past its replay window, measured as the host
|
||||
* measures it from the id's own time, it refuses the id for good, so a host that keeps failing the
|
||||
* request can't hold the entry forever either. */
|
||||
export function structuredAgentSessionEntryOutlivedHostWindow(
|
||||
entry: Pick<StructuredAgentSessionOutboxEntry, 'clientMessageId'>,
|
||||
now: number
|
||||
): boolean {
|
||||
const madeAt = parseAgentSessionOperationTimestamp(entry.clientMessageId)
|
||||
return madeAt !== null && now - madeAt > AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS
|
||||
}
|
||||
|
||||
/** A row the host holds for the send. */
|
||||
function settleStructuredAgentSessionSendRow(
|
||||
submission: AgentJournalSubmission
|
||||
): StructuredAgentSessionSendSettlement {
|
||||
if (submission.dispatchState === 'pending') {
|
||||
return { kind: 'pending' }
|
||||
}
|
||||
// A withdrawn hand-off of a queued draft is never given back: the Stop put the card back.
|
||||
if (
|
||||
submission.dispatchState === 'rejected' &&
|
||||
dispatchWasWithdrawn(submission) &&
|
||||
submission.queuedMessageId === undefined
|
||||
) {
|
||||
return { kind: 'withdrawn' }
|
||||
}
|
||||
// Accepted, rejected, or in doubt (a restart lost its outcome): the row says which, for everyone.
|
||||
return { kind: 'recorded' }
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionJournalReading = {
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
/** How far this client has read the journal; null until a page has loaded. */
|
||||
cursor: AgentJournalCursor | null
|
||||
/** The entry's own request is still out, so its answer will settle it. */
|
||||
inFlightClientMessageId: string | null
|
||||
/** Ids of the drafts the host publishes as queued; null until it has published a list. */
|
||||
queuedMessageIds: readonly string[] | null
|
||||
}
|
||||
|
||||
function readThrough(
|
||||
reading: StructuredAgentSessionJournalReading,
|
||||
at: AgentJournalCursor
|
||||
): boolean {
|
||||
return (
|
||||
reading.cursor !== null &&
|
||||
reading.cursor.epoch === at.epoch &&
|
||||
reading.cursor.sequence >= at.sequence
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the journal alone settles about an entry, or null while it settles nothing. A row settles
|
||||
* it as the send's answer would. Without a row:
|
||||
* - an entry an older build left waiting for a Retry that no longer exists is handed back once the
|
||||
* journal has loaded, with words that say to check the chat (never sent again on its own: the
|
||||
* person was told it did not go);
|
||||
* - an entry a Stop outran is handed back once the journal is read through the Stop's own answer:
|
||||
* the host runs a chat's sends and Stops one at a time, so a send it took before the Stop has its
|
||||
* row by then. A journal that moved to another epoch can't say, nor can a Stop that will never
|
||||
* be answered, so those say to check the chat.
|
||||
*/
|
||||
export function settleStructuredAgentSessionEntryFromJournal(
|
||||
entry: StructuredAgentSessionOutboxEntry,
|
||||
reading: StructuredAgentSessionJournalReading
|
||||
): StructuredAgentSessionSendSettlement | null {
|
||||
// The host handed it off as a queued draft, in whatever state: the card carries it.
|
||||
if (
|
||||
reading.submissions.some((candidate) => candidate.queuedMessageId === entry.clientMessageId)
|
||||
) {
|
||||
return { kind: 'recorded' }
|
||||
}
|
||||
const submission = reading.submissions.find(
|
||||
(candidate) => candidate.clientMessageId === entry.clientMessageId
|
||||
)
|
||||
if (submission) {
|
||||
const settlement = settleStructuredAgentSessionSendRow(submission)
|
||||
// A pending row changes nothing an attempt in flight doesn't already show.
|
||||
return settlement.kind === 'pending' && entry.state === 'dispatching' ? null : settlement
|
||||
}
|
||||
// The host holds it as a queued draft: its card carries the text.
|
||||
if (reading.queuedMessageIds?.includes(entry.clientMessageId)) {
|
||||
return { kind: 'recorded' }
|
||||
}
|
||||
if (reading.cursor === null || reading.inFlightClientMessageId === entry.clientMessageId) {
|
||||
return null
|
||||
}
|
||||
if (entry.legacyUnsettled === true) {
|
||||
return { kind: 'returned', words: [...STRUCTURED_AGENT_SESSION_SEND_UNCONFIRMED_WORDS] }
|
||||
}
|
||||
const stopCursor = entry.stoppedBy?.cursor
|
||||
if (stopCursor) {
|
||||
if (reading.cursor.epoch !== stopCursor.epoch) {
|
||||
return { kind: 'returned', words: [...STRUCTURED_AGENT_SESSION_SEND_UNCONFIRMED_WORDS] }
|
||||
}
|
||||
// A queued draft is no journal row, so only the published list can say the host lacks it.
|
||||
const queueSendUnknown =
|
||||
entry.sentDelivery === 'queue-if-active' && reading.queuedMessageIds === null
|
||||
return readThrough(reading, stopCursor) && !queueSendUnknown ? { kind: 'withdrawn' } : null
|
||||
}
|
||||
if (entry.stoppedBy?.unanswerable === true) {
|
||||
return { kind: 'returned', words: [...STRUCTURED_AGENT_SESSION_SEND_UNCONFIRMED_WORDS] }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The outbox after a settlement, and the entry whose text goes back to the draft, if any. */
|
||||
export type StructuredAgentSessionSettledOutbox = {
|
||||
entries: StructuredAgentSessionOutboxEntry[]
|
||||
returned: {
|
||||
entry: StructuredAgentSessionOutboxEntry
|
||||
/** Null for a Stop's withdrawal, which says nothing. */
|
||||
words: AgentSessionWriteNoticePart[] | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export function applyStructuredAgentSessionSendSettlement(
|
||||
entries: readonly StructuredAgentSessionOutboxEntry[],
|
||||
clientMessageId: string,
|
||||
settlement: StructuredAgentSessionSendSettlement
|
||||
): StructuredAgentSessionSettledOutbox {
|
||||
const entry = entries.find((candidate) => candidate.clientMessageId === clientMessageId)
|
||||
if (!entry) {
|
||||
return { entries: [...entries], returned: null }
|
||||
}
|
||||
const others = entries.filter((candidate) => candidate !== entry)
|
||||
switch (settlement.kind) {
|
||||
case 'recorded':
|
||||
return { entries: others, returned: null }
|
||||
case 'withdrawn':
|
||||
return { entries: others, returned: { entry, words: null } }
|
||||
case 'returned':
|
||||
return { entries: others, returned: { entry, words: settlement.words } }
|
||||
case 'pending':
|
||||
case 'unanswered': {
|
||||
const state = settlement.kind === 'pending' ? 'dispatching' : 'unconfirmed'
|
||||
return {
|
||||
entries: entries.map((candidate) =>
|
||||
candidate === entry ? { ...candidate, state } : candidate
|
||||
),
|
||||
returned: null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user