Merge remote-tracking branch 'origin/main' into brennanb2025/reject-unknown-model-at-worker-start

This commit is contained in:
Merge Sim
2026-09-10 16:50:27 -07:00
60 changed files with 3989 additions and 1086 deletions
@@ -1,4 +1,5 @@
import {
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
AGENT_SESSION_TURN_ITEM_CAPABILITY,
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
@@ -8,6 +9,7 @@ import { remoteRuntimeClientCapabilities } from '../../../src/shared/remote-runt
export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilities([
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
// Opts into the typed turn record; without it the host sends the legacy status carrier.
@@ -92,6 +92,7 @@ describe('mobile rpc-client capabilities', () => {
expect(capabilityRequest.params).toMatchObject({
clientCapabilities: expect.arrayContaining([
'agent-session.structured.v1',
'agent-session.pending-send-result.v1',
'agent-session.structured.claude.v1'
])
})
@@ -1,6 +1,9 @@
import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'
import { describe, expect, it } from 'vitest'
import { createClaudeUserMessageQueue } from './claude-agent-sdk-user-message-queue'
import {
claudeUserMessageWasProvablyUnwritten,
createClaudeUserMessageQueue
} from './claude-agent-sdk-user-message-queue'
/**
* The SDK's input pump is `for await (const frame of prompt) { await transport.write(frame) }`.
@@ -24,7 +27,7 @@ const settled = (promise: Promise<void>): Promise<'settled' | 'pending'> =>
])
describe('claude user message queue', () => {
it('rejects the frame the SDK pulled but abandoned without writing', async () => {
it('treats a frame the SDK pulled and abandoned as write-outcome unknown', async () => {
const queue = createClaudeUserMessageQueue()
const pump = queue.messages[Symbol.asyncIterator]()
const sent = queue.push(frame('hello'))
@@ -33,9 +36,11 @@ describe('claude user message queue', () => {
await pump.return?.(undefined)
await expect(settled(sent)).resolves.toBe('settled')
await expect(sent).rejects.toThrow(
'claude stream-json input ended before the frame was written'
)
const error = await sent.catch((caught: unknown) => caught)
expect(error).toMatchObject({
message: 'claude stream-json input ended before confirming the frame write'
})
expect(claudeUserMessageWasProvablyUnwritten(error)).toBe(false)
})
it('rejects an in-flight frame from fail() when the SDK never resumes the pump', async () => {
@@ -47,7 +52,22 @@ describe('claude user message queue', () => {
queue.fail(new Error('claude stream-json exited: child died'))
await expect(settled(sent)).resolves.toBe('settled')
await expect(sent).rejects.toThrow('claude stream-json exited: child died')
const error = await sent.catch((caught: unknown) => caught)
expect(error).toMatchObject({ message: 'claude stream-json exited: child died' })
expect(claudeUserMessageWasProvablyUnwritten(error)).toBe(false)
})
it('marks only frames still queued in Orca as provably unwritten', async () => {
const queue = createClaudeUserMessageQueue()
const pump = queue.messages[Symbol.asyncIterator]()
const inFlight = queue.push(frame('first')).catch((caught: unknown) => caught)
await pump.next()
const queued = queue.push(frame('second')).catch((caught: unknown) => caught)
queue.fail(new Error('claude stream-json exited: child died'))
expect(claudeUserMessageWasProvablyUnwritten(await inFlight)).toBe(false)
expect(claudeUserMessageWasProvablyUnwritten(await queued)).toBe(true)
})
it('still settles a written frame only once the pump asks for the next one', async () => {
@@ -6,18 +6,42 @@ type QueuedMessage = {
reject: (error: Error) => void
}
type ClaudeUserMessageFailureDisposition = 'unwritten' | 'write-outcome-unknown'
class ClaudeUserMessageFailure extends Error {
readonly disposition: ClaudeUserMessageFailureDisposition
constructor(disposition: ClaudeUserMessageFailureDisposition, cause: Error) {
super(cause.message, { cause })
this.name = 'ClaudeUserMessageFailure'
this.disposition = disposition
}
}
export function claudeUnwrittenUserMessageError(cause: Error): Error {
return new ClaudeUserMessageFailure('unwritten', cause)
}
export function claudeUserMessageWasProvablyUnwritten(error: unknown): boolean {
return error instanceof ClaudeUserMessageFailure && error.disposition === 'unwritten'
}
function claudeAmbiguousUserMessageError(cause: Error): Error {
return new ClaudeUserMessageFailure('write-outcome-unknown', cause)
}
export type ClaudeUserMessageQueue = {
/** The SDK's streaming-input prompt; it stays open until `end`. */
messages: AsyncIterable<SDKUserMessage>
/** Resolves once the SDK has finished writing the frame to the child. */
push: (message: SDKUserMessage) => Promise<void>
/** Reject every unwritten frame, in-flight included; a caller waiting on a send must not hang past the exit. */
/** Reject every unsettled frame; an in-flight frame carries an ambiguous write outcome. */
fail: (error: Error) => void
end: () => void
}
/** The rejection an abandoned frame carries when nothing else has named a cause yet. */
const UNWRITTEN_FRAME_MESSAGE = 'claude stream-json input ended before the frame was written'
const UNCONFIRMED_FRAME_MESSAGE = 'claude stream-json input ended before confirming the frame write'
export function createClaudeUserMessageQueue(): ClaudeUserMessageQueue {
const queued: QueuedMessage[] = []
@@ -57,7 +81,9 @@ export function createClaudeUserMessageQueue(): ClaudeUserMessageQueue {
// is the same "the frame reached the child" proof the hand-rolled write gave.
next.resolve()
} else {
rejectInFlight(failure ?? new Error(UNWRITTEN_FRAME_MESSAGE))
rejectInFlight(
claudeAmbiguousUserMessageError(failure ?? new Error(UNCONFIRMED_FRAME_MESSAGE))
)
}
}
continue
@@ -76,7 +102,7 @@ export function createClaudeUserMessageQueue(): ClaudeUserMessageQueue {
push: (message) =>
new Promise<void>((resolve, reject) => {
if (failure) {
reject(failure)
reject(claudeUnwrittenUserMessageError(failure))
return
}
queued.push({ message, resolve, reject })
@@ -85,11 +111,11 @@ export function createClaudeUserMessageQueue(): ClaudeUserMessageQueue {
fail: (error) => {
failure ??= error
for (const entry of queued.splice(0)) {
entry.reject(error)
entry.reject(claudeUnwrittenUserMessageError(error))
}
// A pump that never resumes cannot run the generator's cleanup, so the
// exit path has to reach the in-flight frame itself.
rejectInFlight(error)
rejectInFlight(claudeAmbiguousUserMessageError(error))
notify()
},
end: () => {
@@ -15,7 +15,10 @@ import {
import { createClaudeChildTreeReaper, proveClaudeChildExit } from './claude-agent-sdk-exit-proof'
import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification'
import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn'
import { createClaudeUserMessageQueue } from './claude-agent-sdk-user-message-queue'
import {
claudeUnwrittenUserMessageError,
createClaudeUserMessageQueue
} from './claude-agent-sdk-user-message-queue'
import type { ClaudeStructuredSdkOptions } from './claude-structured-launch-resolution'
export { ClaudeControlRequestError }
@@ -236,7 +239,11 @@ export async function openClaudeStreamJsonConnection(
const send = (message: Record<string, unknown>): Promise<void> => {
if (closing || exited || terminalError || child.stdin.destroyed || !child.stdin.writable) {
return Promise.reject(terminalError ?? new Error('claude stream-json connection is closed'))
return Promise.reject(
claudeUnwrittenUserMessageError(
terminalError ?? new Error('claude stream-json connection is closed')
)
)
}
return inbox.push(message as unknown as SDKUserMessage)
}
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction'
import { isClaudeCompactionContent } from './claude-structured-compaction'
import { claudeUnwrittenUserMessageError } from './claude-agent-sdk-user-message-queue'
import { compactClaudeSession, isClaudeCompactionContent } from './claude-structured-compaction'
import { sessionFor } from './claude-structured-dispatch-test-support'
afterEach(() => {
vi.useRealTimers()
})
describe('Claude compaction transcript content', () => {
it('keeps generated summaries and command echoes out of the transcript only during explicit compaction', async () => {
@@ -26,4 +32,35 @@ describe('Claude compaction transcript content', () => {
await completion
expect(isClaudeCompactionContent(tracker, event)).toBe(false)
})
it('fails a provably unwritten command without waiting for the completion deadline', async () => {
vi.useFakeTimers()
const session = sessionFor(
vi.fn().mockRejectedValue(claudeUnwrittenUserMessageError(new Error('input closed')))
)
const pending = compactClaudeSession(session, new StructuredSessionCompaction(60_000), {
sessionId: 'orca-session',
fence: 1,
turnId: 'compact-1'
})
await vi.advanceTimersByTimeAsync(1)
await expect(pending).resolves.toEqual({ error: 'provider_write_failed: input closed' })
})
it('keeps waiting when the command write outcome is ambiguous', async () => {
vi.useFakeTimers()
const session = sessionFor(vi.fn().mockRejectedValue(new Error('input pump stopped')))
const pending = compactClaudeSession(session, new StructuredSessionCompaction(10), {
sessionId: 'orca-session',
fence: 1,
turnId: 'compact-1'
})
const rejection = expect(pending).rejects.toThrow('Compaction completion is unconfirmed.')
await vi.advanceTimersByTimeAsync(10)
await rejection
})
})
+12 -11
View File
@@ -2,25 +2,26 @@ import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-struc
import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction'
import { dispatchClaudeTurn } from './claude-structured-dispatch'
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { dispatchDoubtProvesUndelivered } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons'
/** Compaction needs no ack deadline of its own: `compactions.run` keeps its own
* 180s completion window and settles on Claude's terminal `result` frame, so
* the dispatch here only has to report a refusal to send. */
export function compactClaudeSession(
session: ClaudeSession,
compactions: StructuredSessionCompaction,
input: Parameters<NonNullable<StructuredAgentSessionAdapter['compact']>>[0],
timeoutMs: number
input: Parameters<NonNullable<StructuredAgentSessionAdapter['compact']>>[0]
): Promise<{ error?: string }> {
return compactions.run(
input.sessionId,
session.providerSessionId,
async () => {
const result = await dispatchClaudeTurn(
session,
{
clientMessageId: `compact-${input.fence}`,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] }
},
timeoutMs
)
if (result.state === 'rejected') {
const result = await dispatchClaudeTurn(session, {
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] }
})
if (
result.state === 'rejected' ||
(result.state === 'unknown' && dispatchDoubtProvesUndelivered(result.reason))
) {
return { error: result.reason }
}
return undefined
@@ -0,0 +1,123 @@
// The contract the admission fix exists for: dispatch settles when the write
// completes, and nothing about elapsed time ever puts a message in doubt.
import { describe, expect, it, vi } from 'vitest'
import { dispatchClaudeTurn, resolveClaudeReplayWaiter } from './claude-structured-dispatch'
import {
childExited,
sessionFor,
userMessage,
userReplayFrame
} from './claude-structured-dispatch-test-support'
describe('Claude structured dispatch admission', () => {
it('settles a send queued behind a running turn when that turn starts, with no doubt in between', async () => {
vi.useFakeTimers()
try {
const session = sessionFor()
const settled = vi.fn()
const running = await dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: 'one' }])
})
const runningUuid = session.dispatchWaiters[0]!.sentUuid
expect(resolveClaudeReplayWaiter(session, userReplayFrame(runningUuid, 'one'), settled)).toBe(
true
)
// Queued while turn one is still running: Claude cannot echo it until that
// turn ends, so nothing about the wait is evidence of a delivery problem.
const queued = await dispatchClaudeTurn(session, {
clientMessageId: 'client-2',
body: userMessage([{ type: 'text', text: 'two' }])
})
const queuedUuid = session.dispatchWaiters[0]!.sentUuid
expect(running).toEqual({ state: 'admitted' })
expect(queued).toEqual({ state: 'admitted' })
await vi.advanceTimersByTimeAsync(10 * 60_000)
expect(session.dispatchWaiters).toHaveLength(1)
expect(session.retiredDispatchWaiters).toHaveLength(0)
expect(settled).toHaveBeenCalledTimes(1)
// Turn one ends and turn two starts: the echo lands and settles the send.
expect(resolveClaudeReplayWaiter(session, userReplayFrame(queuedUuid, 'two'), settled)).toBe(
true
)
expect(settled).toHaveBeenLastCalledWith({
clientMessageId: 'client-2',
providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: queuedUuid }
})
expect(session.activeTurnId).toBe(queuedUuid)
} finally {
vi.useRealTimers()
}
})
it('returns as soon as the write completes, without awaiting the echo', async () => {
const session = sessionFor()
await expect(
dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: 'one' }])
})
).resolves.toEqual({ state: 'admitted' })
expect(session.connection.send).toHaveBeenCalledTimes(1)
// Still unacknowledged, and deliberately so: the waiter outlives the call.
expect(session.dispatchWaiters).toHaveLength(1)
expect(session.dispatchWaiters[0]!.settledUuid).toBeUndefined()
})
it('resolves every live waiter and retires it when the child exits', async () => {
const session = sessionFor()
await dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: 'one' }])
})
await dispatchClaudeTurn(session, {
clientMessageId: 'client-2',
body: userMessage([{ type: 'text', text: 'two' }])
})
expect(session.dispatchWaiters).toHaveLength(2)
childExited(session)
expect(session.dispatchWaiters).toHaveLength(0)
expect(session.retiredDispatchWaiters).toHaveLength(2)
expect(session.retiredDispatchWaiters.every((waiter) => waiter.retired === true)).toBe(true)
})
it('bounds pending replay identities instead of retaining an unbounded queue', async () => {
const session = sessionFor()
for (let index = 0; index < 64; index += 1) {
await expect(
dispatchClaudeTurn(session, {
clientMessageId: `client-${index}`,
body: userMessage([{ type: 'text', text: String(index) }])
})
).resolves.toEqual({ state: 'admitted' })
}
await expect(
dispatchClaudeTurn(session, {
clientMessageId: 'client-over-capacity',
body: userMessage([{ type: 'text', text: 'one too many' }])
})
).resolves.toEqual({ state: 'rejected', reason: 'claude structured dispatch queue is full' })
expect(session.dispatchWaiters).toHaveLength(64)
expect(session.connection.send).toHaveBeenCalledTimes(64)
})
it('does not publish a journal settlement for a provider-control turn', async () => {
const session = sessionFor()
const settled = vi.fn()
await dispatchClaudeTurn(session, {
body: userMessage([{ type: 'text', text: '/compact' }])
})
const uuid = session.dispatchWaiters[0]!.sentUuid
resolveClaudeReplayWaiter(session, userReplayFrame(uuid, '/compact'), settled)
expect(settled).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,52 @@
import { vi, type Mock } from 'vitest'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import { retireClaudeDispatchWaiters } from './claude-structured-dispatch'
import type { ClaudeSession } from './claude-structured-session-state'
import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker'
import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog'
export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): ClaudeSession {
return {
connection: { send } as unknown as ClaudeSession['connection'],
providerSessionId: 'provider-session',
claudeConfigDir: '/accounts/claude',
leafUuid: null,
fence: 1,
acquisitionGeneration: 'generation-1',
prompts: {} as ClaudeSession['prompts'],
dispatchWaiters: [],
retiredDispatchWaiters: [],
replayContentFallbackBlocked: false,
backgroundTasks: new ClaudeBackgroundTaskTracker(),
commands: new ClaudeSlashCommandCatalog(),
dispatchSequence: 0,
optionMutationSequence: 0,
options: new Map(),
reportedOptions: {},
reportedModelMutation: 0,
confirmedOptions: new Set(),
restoreSkippedOptions: new Set(),
capabilities: [],
events: undefined,
translator: null
}
}
export function userMessage(blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem {
return { kind: 'message', role: 'user', blocks }
}
/** The child died. Nothing else retires a live waiter now that no deadline does. */
export function childExited(session: ClaudeSession): void {
retireClaudeDispatchWaiters(session)
}
export function userReplayFrame(uuid: string, text: string): Record<string, unknown> {
return {
type: 'user',
parent_tool_use_id: null,
session_id: 'provider-session',
uuid,
message: { role: 'user', content: [{ type: 'text', text }] }
}
}
+397 -314
View File
@@ -2,104 +2,86 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import { dispatchClaudeTurn, resolveClaudeReplayWaiter } from './claude-structured-dispatch'
import { readClaudeImage } from './claude-structured-dispatch-content'
import { claudeUnwrittenUserMessageError } from './claude-agent-sdk-user-message-queue'
import type { ClaudeSession } from './claude-structured-session-state'
import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker'
import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog'
function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession {
return {
connection: { send } as unknown as ClaudeSession['connection'],
providerSessionId: 'provider-session',
claudeConfigDir: '/accounts/claude',
leafUuid: null,
fence: 1,
acquisitionGeneration: 'generation-1',
prompts: {} as ClaudeSession['prompts'],
dispatchWaiters: [],
retiredDispatchWaiters: [],
replayContentFallbackBlocked: false,
backgroundTasks: new ClaudeBackgroundTaskTracker(),
commands: new ClaudeSlashCommandCatalog(),
dispatchSequence: 0,
optionMutationSequence: 0,
options: new Map(),
reportedOptions: {},
reportedModelMutation: 0,
confirmedOptions: new Set(),
restoreSkippedOptions: new Set(),
capabilities: [],
events: undefined,
translator: null
}
}
function userMessage(blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem {
return { kind: 'message', role: 'user', blocks }
}
function userReplayFrame(uuid: string, text: string): Record<string, unknown> {
return {
type: 'user',
parent_tool_use_id: null,
session_id: 'provider-session',
uuid,
message: { role: 'user', content: [{ type: 'text', text }] }
}
}
import {
childExited,
sessionFor,
userMessage,
userReplayFrame
} from './claude-structured-dispatch-test-support'
describe('Claude structured dispatch image limits', () => {
it.each(['isMeta', 'isSynthetic', 'isCompactSummary'])(
'does not acknowledge a dispatch with %s context even when the client uuid matches',
async (flag) => {
const session = sessionFor()
const dispatched = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/example' }]) },
1000
)
const settled = vi.fn()
const dispatched = dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: '/example' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
const sentUuid = session.dispatchWaiters[0]!.sentUuid
const replay = userReplayFrame(sentUuid, '/example')
expect(resolveClaudeReplayWaiter(session, { ...replay, [flag]: true })).toBe(false)
expect(resolveClaudeReplayWaiter(session, { ...replay, [flag]: true }, settled)).toBe(false)
expect(session.dispatchWaiters).toHaveLength(1)
expect(resolveClaudeReplayWaiter(session, replay)).toBe(true)
await expect(dispatched).resolves.toMatchObject({
state: 'accepted',
providerIdentity: { uuid: sentUuid }
expect(settled).not.toHaveBeenCalled()
expect(resolveClaudeReplayWaiter(session, replay, settled)).toBe(true)
await expect(dispatched).resolves.toEqual({ state: 'admitted' })
expect(settled).toHaveBeenCalledWith({
clientMessageId: 'client-1',
providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: sentUuid }
})
}
)
it('recovers the active identity when a timed-out replay arrives late', async () => {
it('takes the active turn identity from a replay that lands after dispatch returned', async () => {
const session = sessionFor()
const dispatched = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) },
500
)
const dispatched = dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: 'one' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid
await expect(dispatched).resolves.toMatchObject({ state: 'unknown' })
await expect(dispatched).resolves.toEqual({ state: 'admitted' })
expect(session.activeTurnId).toBeUndefined()
expect(resolveClaudeReplayWaiter(session, userReplayFrame(sentUuid!, 'one'))).toBe(true)
expect(session.activeTurnId).toBe(sentUuid)
expect(session.activeTurnSequence).toBe(session.dispatchSequence)
})
it('settles the send a timed-out replay proves was delivered', async () => {
it('recovers the active identity when a replay lands after the child died', async () => {
const session = sessionFor()
const settled = vi.fn()
const dispatched = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) },
500
)
const dispatched = dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: 'one' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid
await expect(dispatched).resolves.toMatchObject({ state: 'unknown' })
await expect(dispatched).resolves.toEqual({ state: 'admitted' })
childExited(session)
expect(session.dispatchWaiters).toHaveLength(0)
expect(session.retiredDispatchWaiters).toHaveLength(1)
expect(resolveClaudeReplayWaiter(session, userReplayFrame(sentUuid!, 'one'))).toBe(true)
expect(session.activeTurnId).toBe(sentUuid)
expect(session.activeTurnSequence).toBe(session.dispatchSequence)
})
it('settles the send the replay proves was delivered, whenever it arrives', async () => {
const session = sessionFor()
const settled = vi.fn()
const dispatched = dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: 'one' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid
await expect(dispatched).resolves.toEqual({ state: 'admitted' })
resolveClaudeReplayWaiter(session, userReplayFrame(sentUuid!, 'one'), settled)
expect(settled).toHaveBeenCalledWith({
@@ -111,20 +93,19 @@ describe('Claude structured dispatch image limits', () => {
it('settles a superseded dispatch even though it no longer owns the turn identity', async () => {
const session = sessionFor()
const settled = vi.fn()
const first = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) },
500
)
const first = dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: 'one' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
const firstUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid
await expect(first).resolves.toMatchObject({ state: 'unknown' })
await expect(first).resolves.toEqual({ state: 'admitted' })
childExited(session)
const second = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) },
100
)
const second = dispatchClaudeTurn(session, {
clientMessageId: 'client-2',
body: userMessage([{ type: 'text', text: 'two' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
const secondUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid
@@ -138,51 +119,62 @@ describe('Claude structured dispatch image limits', () => {
clientMessageId: 'client-1',
providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: firstUuid }
})
resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid!, 'two'), settled)
await expect(second).resolves.toMatchObject({ state: 'accepted' })
expect(settled).toHaveBeenCalledTimes(1)
expect(resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid!, 'two'), settled)).toBe(
true
)
await expect(second).resolves.toEqual({ state: 'admitted' })
expect(settled).toHaveBeenLastCalledWith({
clientMessageId: 'client-2',
providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: secondUuid }
})
})
it('never lets a late replay for dispatch A resolve dispatch B', async () => {
const session = sessionFor()
const first = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) },
500
)
const settled = vi.fn()
const first = dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: 'one' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
const firstUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid
await expect(first).resolves.toMatchObject({ state: 'unknown' })
await expect(first).resolves.toEqual({ state: 'admitted' })
childExited(session)
const second = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) },
100
)
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
await expect(
dispatchClaudeTurn(session, {
clientMessageId: 'client-2',
body: userMessage([{ type: 'text', text: 'two' }])
})
).resolves.toEqual({ state: 'admitted' })
const secondUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid
expect(resolveClaudeReplayWaiter(session, userReplayFrame(firstUuid!, 'one'))).toBe(false)
expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid })
expect(resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid!, 'two'))).toBe(true)
await expect(second).resolves.toMatchObject({ providerIdentity: { uuid: secondUuid } })
expect(resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid!, 'two'), settled)).toBe(
true
)
expect(settled).toHaveBeenCalledWith({
clientMessageId: 'client-2',
providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: secondUuid }
})
})
it('does not let an identical late replay for dispatch A resolve active dispatch B', async () => {
const session = sessionFor()
const first = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'same prompt' }]) },
500
)
const settled = vi.fn()
const first = dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: 'same prompt' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
await expect(first).resolves.toMatchObject({ state: 'unknown' })
await expect(first).resolves.toEqual({ state: 'admitted' })
childExited(session)
const second = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'same prompt' }]) },
100
)
const second = dispatchClaudeTurn(session, {
clientMessageId: 'client-2',
body: userMessage([{ type: 'text', text: 'same prompt' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
const secondUuid = session.dispatchWaiters[0]!.sentUuid
@@ -191,34 +183,35 @@ describe('Claude structured dispatch image limits', () => {
)
expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid })
resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid, 'same prompt'))
await expect(second).resolves.toMatchObject({ providerIdentity: { uuid: secondUuid } })
resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid, 'same prompt'), settled)
await expect(second).resolves.toEqual({ state: 'admitted' })
expect(settled).toHaveBeenCalledWith({
clientMessageId: 'client-2',
providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: secondUuid }
})
})
it('does not let a fresh-UUID replay for an evicted dispatch resolve active dispatch B', async () => {
const session = sessionFor()
const first = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'same prompt' }]) },
100
)
const first = dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: 'same prompt' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
await expect(first).resolves.toMatchObject({ state: 'unknown' })
await expect(first).resolves.toEqual({ state: 'admitted' })
childExited(session)
const firstUuid = session.retiredDispatchWaiters[0]!.sentUuid
const fillerDispatches = await Promise.all(
Array.from({ length: 64 }, (_, index) =>
dispatchClaudeTurn(
session,
{
clientMessageId: `filler-${index}`,
body: userMessage([{ type: 'text', text: 'same prompt' }])
},
5
)
dispatchClaudeTurn(session, {
clientMessageId: `filler-${index}`,
body: userMessage([{ type: 'text', text: 'same prompt' }])
})
)
)
expect(fillerDispatches.every((outcome) => outcome.state === 'unknown')).toBe(true)
expect(fillerDispatches.every((outcome) => outcome.state === 'admitted')).toBe(true)
childExited(session)
expect(session.retiredDispatchWaiters).toHaveLength(64)
expect(session.replayContentFallbackBlocked).toBe(true)
expect(session.retiredDispatchWaiters.some((waiter) => waiter.sentUuid === firstUuid)).toBe(
@@ -231,47 +224,48 @@ describe('Claude structured dispatch image limits', () => {
}
expect(session.retiredDispatchWaiters).toHaveLength(0)
const second = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'same prompt' }]) },
100
)
const second = dispatchClaudeTurn(session, {
clientMessageId: 'client-2',
body: userMessage([{ type: 'text', text: 'same prompt' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
const secondUuid = session.dispatchWaiters[0]!.sentUuid
const settled = vi.fn()
expect(
resolveClaudeReplayWaiter(session, userReplayFrame('provider-a-late', 'same prompt'))
).toBe(false)
expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid })
resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid, 'same prompt'))
await expect(second).resolves.toMatchObject({ providerIdentity: { uuid: secondUuid } })
resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid, 'same prompt'), settled)
await expect(second).resolves.toEqual({ state: 'admitted' })
expect(settled).toHaveBeenCalledWith({
clientMessageId: 'client-2',
providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: secondUuid }
})
})
it('does not let a fresh-UUID result for an evicted slash dispatch resolve active dispatch B', async () => {
const session = sessionFor()
const first = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) },
100
)
const first = dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: '/permissions' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
await expect(first).resolves.toMatchObject({ state: 'unknown' })
await expect(first).resolves.toEqual({ state: 'admitted' })
childExited(session)
const firstUuid = session.retiredDispatchWaiters[0]!.sentUuid
const fillerDispatches = await Promise.all(
Array.from({ length: 64 }, (_, index) =>
dispatchClaudeTurn(
session,
{
clientMessageId: `filler-${index}`,
body: userMessage([{ type: 'text', text: '/permissions' }])
},
5
)
dispatchClaudeTurn(session, {
clientMessageId: `filler-${index}`,
body: userMessage([{ type: 'text', text: '/permissions' }])
})
)
)
expect(fillerDispatches.every((outcome) => outcome.state === 'unknown')).toBe(true)
expect(fillerDispatches.every((outcome) => outcome.state === 'admitted')).toBe(true)
childExited(session)
expect(session.retiredDispatchWaiters).toHaveLength(64)
expect(session.replayContentFallbackBlocked).toBe(true)
expect(session.retiredDispatchWaiters.some((waiter) => waiter.sentUuid === firstUuid)).toBe(
@@ -292,13 +286,13 @@ describe('Claude structured dispatch image limits', () => {
}
expect(session.retiredDispatchWaiters).toHaveLength(0)
const second = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: '/permissions' }]) },
100
)
const second = dispatchClaudeTurn(session, {
clientMessageId: 'client-2',
body: userMessage([{ type: 'text', text: '/permissions' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
const secondUuid = session.dispatchWaiters[0]!.sentUuid
const settled = vi.fn()
expect(
resolveClaudeReplayWaiter(session, {
@@ -311,70 +305,127 @@ describe('Claude structured dispatch image limits', () => {
expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid })
expect(
resolveClaudeReplayWaiter(session, {
type: 'result',
subtype: 'success',
session_id: 'provider-session',
uuid: 'result-b',
user_message_uuid: secondUuid
})
resolveClaudeReplayWaiter(
session,
{
type: 'result',
subtype: 'success',
session_id: 'provider-session',
uuid: 'result-b',
user_message_uuid: secondUuid
},
settled
)
).toBe(false)
await expect(second).resolves.toMatchObject({
providerIdentity: { uuid: 'result-b' }
await expect(second).resolves.toEqual({ state: 'admitted' })
expect(settled).toHaveBeenCalledWith({
clientMessageId: 'client-2',
providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: 'result-b' }
})
})
it('does not let a legacy result for timed-out ordinary dispatch A resolve slash dispatch B', async () => {
const session = sessionFor()
const first = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'ordinary' }]) },
100
)
const settled = vi.fn()
const first = dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: 'ordinary' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
await expect(first).resolves.toMatchObject({ state: 'unknown' })
await expect(first).resolves.toEqual({ state: 'admitted' })
childExited(session)
const second = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: '/permissions' }]) },
100
)
const second = dispatchClaudeTurn(session, {
clientMessageId: 'client-2',
body: userMessage([{ type: 'text', text: '/permissions' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
expect(
resolveClaudeReplayWaiter(session, {
type: 'result',
subtype: 'success',
session_id: 'provider-session',
uuid: 'legacy-result-a'
})
resolveClaudeReplayWaiter(
session,
{
type: 'result',
subtype: 'success',
session_id: 'provider-session',
uuid: 'legacy-result-a'
},
settled
)
).toBe(false)
await expect(second).resolves.toMatchObject({ state: 'unknown' })
await expect(second).resolves.toEqual({ state: 'admitted' })
// Ambiguous, so it settles nothing: the slash waiter is still waiting.
expect(session.dispatchWaiters).toHaveLength(1)
expect(settled).not.toHaveBeenCalled()
})
it('removes only its own waiter when a later send fails', async () => {
const session = sessionFor()
const first = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) },
100
)
const settled = vi.fn()
const first = dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: 'one' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
const firstWaiter = session.dispatchWaiters[0]
session.connection.send = vi.fn().mockRejectedValue(new Error('broken pipe'))
session.connection.send = vi
.fn()
.mockRejectedValue(claudeUnwrittenUserMessageError(new Error('broken pipe')))
// A refused write is a transport fact, and the only thing besides child exit
// that puts one message's delivery in doubt.
await expect(
dispatchClaudeTurn(
session,
{ clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) },
100
)
).resolves.toMatchObject({ state: 'unknown', reason: 'broken pipe' })
dispatchClaudeTurn(session, {
clientMessageId: 'client-2',
body: userMessage([{ type: 'text', text: 'two' }])
})
).resolves.toEqual({ state: 'unknown', reason: 'provider_write_failed: broken pipe' })
expect(session.dispatchWaiters).toEqual([firstWaiter])
const firstUuid = (firstWaiter as { sentUuid?: string }).sentUuid
resolveClaudeReplayWaiter(session, userReplayFrame(firstUuid!, 'one'))
await expect(first).resolves.toMatchObject({ providerIdentity: { uuid: firstUuid } })
resolveClaudeReplayWaiter(session, userReplayFrame(firstUuid!, 'one'), settled)
await expect(first).resolves.toEqual({ state: 'admitted' })
expect(settled).toHaveBeenCalledWith({
clientMessageId: 'client-1',
providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: firstUuid }
})
})
it('does not let a provably unwritten attempt block retry correlation', async () => {
const send = vi
.fn()
.mockRejectedValueOnce(claudeUnwrittenUserMessageError(new Error('broken pipe')))
.mockResolvedValue(undefined)
const session = sessionFor(send)
const body = userMessage([{ type: 'text', text: 'retry me' }])
await expect(
dispatchClaudeTurn(session, { clientMessageId: 'client-1', body })
).resolves.toEqual({ state: 'unknown', reason: 'provider_write_failed: broken pipe' })
expect(session.dispatchWaiters).toHaveLength(0)
expect(session.retiredDispatchWaiters).toHaveLength(0)
await expect(
dispatchClaudeTurn(session, { clientMessageId: 'client-1', body })
).resolves.toEqual({ state: 'admitted' })
expect(resolveClaudeReplayWaiter(session, userReplayFrame('fresh-replay', 'retry me'))).toBe(
true
)
expect(session.activeTurnId).toBe('fresh-replay')
})
it('does not claim an SDK-pulled frame was unwritten when its write outcome is ambiguous', async () => {
const session = sessionFor(vi.fn().mockRejectedValue(new Error('input pump stopped')))
await expect(
dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: 'one' }])
})
).resolves.toEqual({
state: 'unknown',
reason: 'provider_write_outcome_unknown: input pump stopped'
})
})
it('keeps a replay accepted before its send reports failure', async () => {
@@ -386,35 +437,39 @@ describe('Claude structured dispatch image limits', () => {
session = sessionFor(send)
await expect(
dispatchClaudeTurn(
session,
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) },
100
)
dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: 'one' }])
})
).resolves.toMatchObject({ state: 'accepted', providerIdentity: { uuid: 'turn-race' } })
expect(session.dispatchWaiters).toHaveLength(0)
})
it('accepts a slash command from its result receipt when Claude omits the user replay', async () => {
const session = sessionFor()
const dispatched = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) },
100
)
const settled = vi.fn()
const dispatched = dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: '/permissions' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
expect(
resolveClaudeReplayWaiter(session, {
type: 'result',
subtype: 'success',
session_id: 'provider-session',
uuid: 'command-result-uuid'
})
resolveClaudeReplayWaiter(
session,
{
type: 'result',
subtype: 'success',
session_id: 'provider-session',
uuid: 'command-result-uuid'
},
settled
)
).toBe(false)
await expect(dispatched).resolves.toEqual({
state: 'accepted',
await expect(dispatched).resolves.toEqual({ state: 'admitted' })
expect(settled).toHaveBeenCalledWith({
clientMessageId: 'client-1',
providerIdentity: {
provider: 'claude',
sessionId: 'provider-session',
@@ -425,32 +480,38 @@ describe('Claude structured dispatch image limits', () => {
it('accepts a slash command sent with an attachment from its result receipt', async () => {
const session = sessionFor()
const dispatched = dispatchClaudeTurn(
session,
{
clientMessageId: 'client-1',
body: userMessage([
{ type: 'text', text: '/permissions' },
{ type: 'image-ref', url: 'https://example.test/a.png' }
])
},
100
)
const settled = vi.fn()
const dispatched = dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([
{ type: 'text', text: '/permissions' },
{ type: 'image-ref', url: 'https://example.test/a.png' }
])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
// The mapper moves the image ahead of the prompt, so Claude runs the command and replies
// with a result receipt instead of a user replay.
expect(
resolveClaudeReplayWaiter(session, {
type: 'result',
subtype: 'success',
session_id: 'provider-session',
uuid: 'command-result-uuid'
})
resolveClaudeReplayWaiter(
session,
{
type: 'result',
subtype: 'success',
session_id: 'provider-session',
uuid: 'command-result-uuid'
},
settled
)
).toBe(false)
await expect(dispatched).resolves.toMatchObject({
state: 'accepted',
providerIdentity: { uuid: 'command-result-uuid' }
await expect(dispatched).resolves.toEqual({ state: 'admitted' })
expect(settled).toHaveBeenCalledWith({
clientMessageId: 'client-1',
providerIdentity: {
provider: 'claude',
sessionId: 'provider-session',
uuid: 'command-result-uuid'
}
})
// The sent order is the fix: the waiter's verdict alone was already what it is today.
expect(session.connection.send).toHaveBeenCalledWith(
@@ -468,68 +529,76 @@ describe('Claude structured dispatch image limits', () => {
it('does not take a result receipt for leading whitespace Claude never reads as a command', async () => {
const session = sessionFor()
const dispatched = dispatchClaudeTurn(
session,
{
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: ' /permissions' }])
},
100
)
const settled = vi.fn()
const dispatched = dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: ' /permissions' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
expect(
resolveClaudeReplayWaiter(session, {
type: 'result',
subtype: 'success',
session_id: 'provider-session',
uuid: 'unrelated-result-uuid'
})
resolveClaudeReplayWaiter(
session,
{
type: 'result',
subtype: 'success',
session_id: 'provider-session',
uuid: 'unrelated-result-uuid'
},
settled
)
).toBe(false)
await expect(dispatched).resolves.toMatchObject({ state: 'unknown' })
await expect(dispatched).resolves.toEqual({ state: 'admitted' })
expect(session.dispatchWaiters).toHaveLength(1)
expect(settled).not.toHaveBeenCalled()
})
it('correlates a later slash-command result by user_message_uuid despite a timed-out slash waiter', async () => {
it('correlates a later slash-command result by user_message_uuid despite a retired slash waiter', async () => {
const session = sessionFor()
const first = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) },
500
)
const settled = vi.fn()
const first = dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: '/permissions' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
await expect(first).resolves.toMatchObject({ state: 'unknown' })
await expect(first).resolves.toEqual({ state: 'admitted' })
childExited(session)
const second = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: '/permissions' }]) },
500
)
const second = dispatchClaudeTurn(session, {
clientMessageId: 'client-2',
body: userMessage([{ type: 'text', text: '/permissions' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
const secondUuid = session.dispatchWaiters[0]!.sentUuid
expect(
resolveClaudeReplayWaiter(session, {
type: 'result',
subtype: 'success',
session_id: 'provider-session',
uuid: 'result-b',
user_message_uuid: secondUuid
})
resolveClaudeReplayWaiter(
session,
{
type: 'result',
subtype: 'success',
session_id: 'provider-session',
uuid: 'result-b',
user_message_uuid: secondUuid
},
settled
)
).toBe(false)
await expect(second).resolves.toMatchObject({
state: 'accepted',
providerIdentity: { uuid: 'result-b' }
await expect(second).resolves.toEqual({ state: 'admitted' })
expect(settled).toHaveBeenCalledWith({
clientMessageId: 'client-2',
providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: 'result-b' }
})
})
it('does not mistake a normal turn result for its missing user replay', async () => {
const session = sessionFor()
const dispatched = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'hello' }]) },
100
)
const settled = vi.fn()
const dispatched = dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: 'hello' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
expect(
@@ -541,31 +610,40 @@ describe('Claude structured dispatch image limits', () => {
).toBe(false)
expect(session.dispatchWaiters).toHaveLength(1)
expect(
resolveClaudeReplayWaiter(session, {
type: 'user',
parent_tool_use_id: null,
session_id: 'provider-session',
uuid: 'user-replay-uuid',
message: {
role: 'user',
content: [{ type: 'text', text: 'hello' }]
}
})
resolveClaudeReplayWaiter(
session,
{
type: 'user',
parent_tool_use_id: null,
session_id: 'provider-session',
uuid: 'user-replay-uuid',
message: {
role: 'user',
content: [{ type: 'text', text: 'hello' }]
}
},
settled
)
).toBe(true)
await expect(dispatched).resolves.toMatchObject({
state: 'accepted',
providerIdentity: { uuid: 'user-replay-uuid' }
await expect(dispatched).resolves.toEqual({ state: 'admitted' })
expect(settled).toHaveBeenCalledWith({
clientMessageId: 'client-1',
providerIdentity: {
provider: 'claude',
sessionId: 'provider-session',
uuid: 'user-replay-uuid'
}
})
})
it('ignores a top-level tool-result user frame while waiting for a slash command replay', async () => {
const session = sessionFor()
const dispatched = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) },
100
)
const settled = vi.fn()
const dispatched = dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: '/permissions' }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
resolveClaudeReplayWaiter(session, {
@@ -580,19 +658,24 @@ describe('Claude structured dispatch image limits', () => {
})
expect(session.dispatchWaiters).toHaveLength(1)
resolveClaudeReplayWaiter(session, {
type: 'user',
parent_tool_use_id: null,
session_id: 'provider-session',
uuid: 'user-replay-uuid',
message: {
role: 'user',
content: [{ type: 'text', text: '/permissions' }]
}
})
resolveClaudeReplayWaiter(
session,
{
type: 'user',
parent_tool_use_id: null,
session_id: 'provider-session',
uuid: 'user-replay-uuid',
message: {
role: 'user',
content: [{ type: 'text', text: '/permissions' }]
}
},
settled
)
await expect(dispatched).resolves.toEqual({
state: 'accepted',
await expect(dispatched).resolves.toEqual({ state: 'admitted' })
expect(settled).toHaveBeenCalledWith({
clientMessageId: 'client-1',
providerIdentity: {
provider: 'claude',
sessionId: 'provider-session',
@@ -611,7 +694,7 @@ describe('Claude structured dispatch image limits', () => {
)
await expect(
dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1)
dispatchClaudeTurn(session, { clientMessageId: 'client-1', body })
).resolves.toEqual({ state: 'rejected', reason: 'Claude messages support at most 20 images' })
expect(session.connection.send).not.toHaveBeenCalled()
})
@@ -630,7 +713,7 @@ describe('Claude structured dispatch image limits', () => {
const body = userMessage(paths.map((path) => ({ type: 'image-ref' as const, path })))
await expect(
dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1)
dispatchClaudeTurn(session, { clientMessageId: 'client-1', body })
).resolves.toEqual({
state: 'rejected',
reason: `Claude images must total no more than ${20 * 1024 * 1024} bytes`
@@ -650,7 +733,7 @@ describe('Claude structured dispatch image limits', () => {
const body = userMessage([{ type: 'image-ref', path }])
await expect(
dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1)
dispatchClaudeTurn(session, { clientMessageId: 'client-1', body })
).resolves.toEqual({
state: 'rejected',
reason: `Claude image must be a non-empty file no larger than ${5 * 1024 * 1024} bytes`
@@ -668,11 +751,10 @@ describe('Claude structured dispatch image limits', () => {
const path = join(directory, 'small.png')
await writeFile(path, Buffer.alloc(64))
const session = sessionFor()
const dispatched = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-1', body: userMessage([{ type: 'image-ref', path }]) },
100
)
const dispatched = dispatchClaudeTurn(session, {
clientMessageId: 'client-1',
body: userMessage([{ type: 'image-ref', path }])
})
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid
resolveClaudeReplayWaiter(session, {
@@ -684,7 +766,7 @@ describe('Claude structured dispatch image limits', () => {
]
}
})
await expect(dispatched).resolves.toMatchObject({ state: 'accepted' })
await expect(dispatched).resolves.toEqual({ state: 'admitted' })
expect(allocUnsafe).toHaveBeenCalled()
expect(allocUnsafe.mock.calls.some(([size]) => size === 64 + 1)).toBe(true)
expect(allocUnsafe.mock.calls.some(([size]) => size >= 5 * 1024 * 1024)).toBe(false)
@@ -694,7 +776,7 @@ describe('Claude structured dispatch image limits', () => {
}
})
it('bounds retained waiter identity bytes when image dispatches time out', async () => {
it('bounds retained waiter identity bytes when image dispatches are retired', async () => {
const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-'))
try {
const path = join(directory, 'large.png')
@@ -703,9 +785,10 @@ describe('Claude structured dispatch image limits', () => {
const body = userMessage([{ type: 'image-ref', path }])
await Promise.all(
Array.from({ length: 64 }, (_, index) =>
dispatchClaudeTurn(session, { clientMessageId: `client-${index}`, body }, 1)
dispatchClaudeTurn(session, { clientMessageId: `client-${index}`, body })
)
)
childExited(session)
expect(session.retiredDispatchWaiters).toHaveLength(64)
const retainedKeyBytes = session.retiredDispatchWaiters.reduce(
+80 -44
View File
@@ -15,10 +15,16 @@ import {
claudeDispatchInvokesSlashCommand,
claudeDispatchMessageContent
} from './claude-structured-dispatch-content'
import {
dispatchWriteFailureReason,
dispatchWriteOutcomeUnknownReason
} from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons'
import { claudeUserMessageWasProvablyUnwritten } from './claude-agent-sdk-user-message-queue'
const MAX_RETIRED_DISPATCH_WAITERS = 64
const MAX_ACTIVE_DISPATCH_WAITERS = 64
/** A dispatch whose ack window expired, proven delivered by this replay. */
/** Directly settles provider-proven delivery; the durable replay row independently reconciles it. */
export type ClaudeLateDispatchSettlement = (input: {
clientMessageId: string
providerIdentity: AgentJournalItemIdentity
@@ -55,7 +61,7 @@ export function resolveClaudeReplayWaiter(
(candidate) => candidate.sentUuid === userMessageUuid
)
if (exact) {
settleWaiter(session, exact, uuid)
settleWaiter(session, exact, uuid, onSettledLate)
return isUserReplay && exact.dispatchSequence === session.dispatchSequence
}
const retired = session.retiredDispatchWaiters.find(
@@ -70,7 +76,7 @@ export function resolveClaudeReplayWaiter(
const exact = session.dispatchWaiters.find((candidate) => candidate.sentUuid === uuid)
if (exact) {
settleWaiter(session, exact, uuid)
settleWaiter(session, exact, uuid, onSettledLate)
return isUserReplay && exact.dispatchSequence === session.dispatchSequence
}
const retired = session.retiredDispatchWaiters.find((candidate) => candidate.sentUuid === uuid)
@@ -90,7 +96,7 @@ export function resolveClaudeReplayWaiter(
(candidate) => candidate.replayContentKey === replayContentKey
)
if (compatible.length === 1) {
settleWaiter(session, compatible[0]!, uuid)
settleWaiter(session, compatible[0]!, uuid, onSettledLate)
return compatible[0]!.dispatchSequence === session.dispatchSequence
}
} else if (!session.replayContentFallbackBlocked && session.dispatchWaiters.length === 0) {
@@ -120,22 +126,36 @@ export function resolveClaudeReplayWaiter(
}
const waiter = uuid ? session.dispatchWaiters.shift() : undefined
if (waiter && uuid) {
clearTimeout(waiter.timer)
waiter.settledUuid = uuid
waiter.resolve(uuid)
settleWaiter(session, waiter, uuid, onSettledLate)
return isUserReplay
}
return false
}
function settleWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter, uuid: string): void {
function settleWaiter(
session: ClaudeSession,
waiter: ClaudeDispatchWaiter,
uuid: string,
onSettledLate?: ClaudeLateDispatchSettlement
): void {
const index = session.dispatchWaiters.indexOf(waiter)
if (index !== -1) {
session.dispatchWaiters.splice(index, 1)
}
clearTimeout(waiter.timer)
waiter.settledUuid = uuid
waiter.resolve(uuid)
// Dispatch returned on admission. Settle delivery unfenced while the sequence
// still fences which turn owns the identity; see `recoverLateIdentity`.
if (waiter.clientMessageId) {
onSettledLate?.({
clientMessageId: waiter.clientMessageId,
providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid }
})
}
if (waiter.dispatchSequence === session.dispatchSequence) {
session.activeTurnId = uuid
session.activeTurnSequence = waiter.dispatchSequence
}
}
function forgetRetiredWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void {
@@ -158,10 +178,12 @@ function recoverLateIdentity(
// The provider acted on this dispatch, so the send it came from is delivered.
// Unfenced on purpose: the dispatch-sequence check below only decides which
// turn owns the identity, while delivery is settled for good either way.
onSettledLate?.({
clientMessageId: waiter.clientMessageId,
providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid }
})
if (waiter.clientMessageId) {
onSettledLate?.({
clientMessageId: waiter.clientMessageId,
providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid }
})
}
if (waiter.dispatchSequence === session.dispatchSequence) {
session.activeTurnId = uuid
session.activeTurnSequence = waiter.dispatchSequence
@@ -169,13 +191,19 @@ function recoverLateIdentity(
return isUserReplay && waiter.dispatchSequence === session.dispatchSequence
}
/**
* A waiter with no deadline. The echo Claude sends is emitted when the provider
* STARTS the turn, so a message queued behind a running turn cannot be echoed
* until that turn ends — an interval bounded only by the previous turn. Elapsed
* time is therefore not evidence about delivery, and nothing here expires.
* Waiters are retired by process facts instead: a failed write, or child exit.
*/
function waitForReplay(
session: ClaudeSession,
timeoutMs: number,
acceptsResult: boolean,
sentUuid: string,
replayContentKey: string,
clientMessageId: string
clientMessageId: string | null
): { waiter: ClaudeDispatchWaiter; promise: Promise<string | null> } {
let waiter!: ClaudeDispatchWaiter
const promise = new Promise<string | null>((resolve) => {
@@ -185,28 +213,22 @@ function waitForReplay(
sentUuid,
dispatchSequence: session.dispatchSequence,
replayContentKey,
resolve,
timer: setTimeout(() => {
const index = session.dispatchWaiters.indexOf(waiter)
if (index !== -1) {
session.dispatchWaiters.splice(index, 1)
}
retireWaiter(session, waiter)
resolve(null)
}, timeoutMs)
resolve
}
waiter.timer.unref?.()
session.dispatchWaiters.push(waiter)
})
return { waiter, promise }
}
function retireWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void {
function forgetWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void {
const index = session.dispatchWaiters.indexOf(waiter)
if (index !== -1) {
session.dispatchWaiters.splice(index, 1)
}
clearTimeout(waiter.timer)
}
function retireWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void {
forgetWaiter(session, waiter)
if (!waiter.retired) {
waiter.retired = true
session.retiredDispatchWaiters.push(waiter)
@@ -220,10 +242,19 @@ function retireWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): voi
}
}
/** Nothing expires a waiter, so the child's death is what ends every live one.
* Retired rather than dropped: their identities stay joinable, bounded by
* `MAX_RETIRED_DISPATCH_WAITERS`. */
export function retireClaudeDispatchWaiters(session: ClaudeSession): void {
for (const waiter of session.dispatchWaiters.splice(0)) {
retireWaiter(session, waiter)
waiter.resolve(null)
}
}
export async function dispatchClaudeTurn(
session: ClaudeSession,
input: { clientMessageId: string; body: AgentJournalMessageItem },
timeoutMs: number
input: { clientMessageId?: string; body: AgentJournalMessageItem }
): Promise<AgentSessionDispatchOutcome> {
let content: unknown[]
try {
@@ -231,6 +262,9 @@ export async function dispatchClaudeTurn(
} catch (error) {
return { state: 'rejected', reason: (error as Error).message }
}
if (session.dispatchWaiters.length >= MAX_ACTIVE_DISPATCH_WAITERS) {
return { state: 'rejected', reason: 'claude structured dispatch queue is full' }
}
const dispatchSequence = ++session.dispatchSequence
// Read the sent content, not the journal blocks: only the mapped trailing prompt decides
// whether Claude runs a command, so the two cannot disagree about which frame settles this.
@@ -238,11 +272,10 @@ export async function dispatchClaudeTurn(
const sentUuid = randomUUID()
const replay = waitForReplay(
session,
timeoutMs,
acceptsResult,
sentUuid,
claudeDispatchContentKey(content),
input.clientMessageId
input.clientMessageId ?? null
)
const replayed = replay.promise
try {
@@ -266,21 +299,24 @@ export async function dispatchClaudeTurn(
}
}
}
if (!waiter.retired) {
const provablyUnwritten = claudeUserMessageWasProvablyUnwritten(error)
if (provablyUnwritten) {
forgetWaiter(session, waiter)
forgetRetiredWaiter(session, waiter)
waiter.resolve(null)
} else if (!waiter.retired) {
retireWaiter(session, waiter)
waiter.resolve(null)
}
return { state: 'unknown', reason: (error as Error).message }
return {
state: 'unknown',
reason: provablyUnwritten
? dispatchWriteFailureReason(error)
: dispatchWriteOutcomeUnknownReason(error)
}
}
const uuid = await replayed
if (uuid) {
session.activeTurnId = uuid
session.activeTurnSequence = dispatchSequence
}
return uuid
? {
state: 'accepted',
providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid }
}
: { state: 'unknown', reason: 'claude accepted a message but did not replay its uuid in time' }
// The write is the admission signal. Awaiting the echo here would block on the
// turn already running, which is why the deadline this replaces kept declaring
// doubt about messages that were delivered. `settleWaiter` finishes the job.
return { state: 'admitted' }
}
@@ -0,0 +1,261 @@
// What the adapter reports for one turn: how a dispatch is admitted and named,
// and which turn a cancellation is allowed to interrupt.
import { describe, expect, it, vi } from 'vitest'
import { ClaudeControlRequestError } from './claude-stream-json-connection'
import { claudeUnwrittenUserMessageError } from './claude-agent-sdk-user-message-queue'
import {
acquired,
fakeClaude,
PROVIDER_SESSION_ID,
USER_MESSAGE
} from './claude-structured-session-test-support'
describe('ClaudeStructuredSessionAdapter turns and controls', () => {
it("admits a dispatch on the write and names it from Claude's replay", async () => {
const claude = fakeClaude({ replayUuid: 'user-provider-uuid' })
const settled = vi.fn()
const adapter = await acquired(claude, {}, [], settled)
const result = await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: USER_MESSAGE,
fence: 7
})
expect(result).toEqual({ state: 'admitted' })
expect(settled).toHaveBeenCalledWith({
sessionId: 'session-1',
clientMessageId: 'client-1',
providerIdentity: {
provider: 'claude',
sessionId: PROVIDER_SESSION_ID,
uuid: 'user-provider-uuid'
}
})
expect(claude.connections[0].sent[0]).toMatchObject({
type: 'user',
message: { role: 'user', content: [{ type: 'text', text: 'ship it' }] },
session_id: PROVIDER_SESSION_ID
})
})
it('does not put delivery in doubt while no replay uuid has arrived', async () => {
const settled = vi.fn()
const adapter = await acquired(fakeClaude({ replayUuid: null }), {}, [], settled)
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: USER_MESSAGE,
fence: 7
})
).resolves.toEqual({ state: 'admitted' })
expect(settled).not.toHaveBeenCalled()
})
it('puts delivery in doubt only when the write itself fails', async () => {
const claude = fakeClaude({ replayUuid: null })
const adapter = await acquired(claude)
claude.connections[0]!.send = async () => {
throw claudeUnwrittenUserMessageError(new Error('broken pipe'))
}
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: USER_MESSAGE,
fence: 7
})
).resolves.toEqual({ state: 'unknown', reason: 'provider_write_failed: broken pipe' })
})
it('requires an acknowledged interrupt and supports controlled options', async () => {
const claude = fakeClaude()
const adapter = await acquired(claude)
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 })
).resolves.toEqual({ cancelled: true })
await expect(
adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'sonnet', fence: 7 })
).resolves.toEqual({ model: 'sonnet' })
expect(claude.connections[0].calls.slice(-2)).toEqual([
{ subtype: 'interrupt', params: {} },
{ subtype: 'set_model', params: { model: 'sonnet' } }
])
claude.routes.interrupt = () => {
throw new ClaudeControlRequestError('interrupt', 'not running')
}
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-2', fence: 7 })
).resolves.toEqual({ cancelled: false })
claude.routes.interrupt = () => {
throw new Error('claude interrupt request timed out')
}
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-3', fence: 7 })
).rejects.toThrow('timed out')
})
it('does not let a delayed cancellation for an earlier turn interrupt the later turn', async () => {
const claude = fakeClaude({ replayUuids: ['turn-T', 'turn-U'] })
const adapter = await acquired(claude)
await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-T',
body: USER_MESSAGE,
fence: 7
})
await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-U',
body: USER_MESSAGE,
fence: 7
})
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-T', fence: 7 })
).resolves.toEqual({ cancelled: false })
expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength(
0
)
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-U', fence: 6 })
).resolves.toEqual({ cancelled: false })
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-U', fence: 7 })
).resolves.toEqual({ cancelled: true })
expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength(
1
)
})
it('does not cancel an acknowledged turn after a later dispatch is still unacknowledged', async () => {
const claude = fakeClaude({ replayUuids: ['turn-T', null] })
const settled = vi.fn()
const adapter = await acquired(claude, {}, [], settled)
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-T',
body: USER_MESSAGE,
fence: 7
})
).resolves.toEqual({ state: 'admitted' })
expect(settled).toHaveBeenCalledWith({
sessionId: 'session-1',
clientMessageId: 'client-T',
providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: 'turn-T' }
})
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-U',
body: USER_MESSAGE,
fence: 7
})
).resolves.toEqual({ state: 'admitted' })
expect(claude.connections[0].sent).toHaveLength(2)
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-T', fence: 7 })
).resolves.toEqual({ cancelled: false })
expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength(
0
)
})
it('classifies provider-declined options without treating timeouts as settled', async () => {
const claude = fakeClaude({
routes: {
set_model: () => {
throw new ClaudeControlRequestError('set_model', 'model unavailable')
}
}
})
const adapter = await acquired(claude)
await expect(
adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'fable', fence: 7 })
).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' })
claude.routes.set_model = () => {
throw new Error('claude set_model request timed out')
}
await expect(
adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 })
).rejects.toThrow('timed out')
})
it('hydrates live model choices and maps the resolved current model to its CLI id', async () => {
const claude = fakeClaude({
initModel: 'claude-sonnet-5',
routes: {
list_models: () => [
{ value: 'default', resolvedModel: 'claude-opus-5', displayName: 'Default' },
{
value: 'opus',
resolvedModel: 'claude-opus-5',
displayName: 'Opus',
supportsEffort: true,
supportedEffortLevels: ['low', 'high']
},
{
value: 'sonnet',
resolvedModel: 'claude-sonnet-5',
displayName: 'Sonnet'
}
]
}
})
const adapter = await acquired(claude)
await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({
models: [
{
id: 'opus',
label: 'Opus',
isDefault: true,
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
},
{ id: 'sonnet', label: 'Sonnet', isDefault: false, efforts: [] }
],
current: { model: 'sonnet', effort: 'high', confirmed: ['model', 'effort'] }
})
})
it('keeps the shared Claude seed when live model discovery is unavailable', async () => {
const claude = fakeClaude({
initModel: 'custom-model',
routes: {
list_models: () => {
throw new Error('unsupported')
}
}
})
const adapter = await acquired(claude)
const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
expect(result.models.map((model) => model.id)).toEqual([
'fable',
'opus',
'sonnet',
'haiku',
'custom-model'
])
expect(result.current).toEqual({
model: 'custom-model',
effort: 'high',
confirmed: ['model', 'effort']
})
})
})
@@ -144,10 +144,11 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => {
expect(claude.connections[0]?.closeCount).toBe(1)
})
it('recovers a cancellable lifecycle when a timed-out replay arrives late', async () => {
it('recovers a cancellable lifecycle when the replay arrives after dispatch returned', async () => {
const claude = fakeClaude({ replayUuid: null })
const events: ClaudeStructuredSessionEvent[] = []
const adapter = await acquired(claude, {}, events)
const settled = vi.fn()
const adapter = await acquired(claude, {}, events, settled)
await expect(
adapter.dispatch({
@@ -156,7 +157,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => {
body: USER_MESSAGE,
fence: 7
})
).resolves.toMatchObject({ state: 'unknown' })
).resolves.toEqual({ state: 'admitted' })
const sent = claude.connections[0]!.sent[0]!
claude.connections[0]!.handlers.onMessage?.({
...sent,
@@ -170,6 +171,15 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => {
message: expect.objectContaining({ uuid: 'late-turn-1' })
})
)
expect(settled).toHaveBeenCalledWith({
sessionId: 'session-1',
clientMessageId: 'client-1',
providerIdentity: {
provider: 'claude',
sessionId: PROVIDER_SESSION_ID,
uuid: 'late-turn-1'
}
})
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'late-turn-1', fence: 7 })
).resolves.toEqual({ cancelled: true })
@@ -178,7 +188,8 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => {
it('quarantines SDK frames without the acquired session identity', async () => {
const claude = fakeClaude({ replayUuid: null })
const events: ClaudeStructuredSessionEvent[] = []
const adapter = await acquired(claude, {}, events)
const settled = vi.fn()
const adapter = await acquired(claude, {}, events, settled)
const connection = claude.connections[0]!
connection.handlers.onMessage?.({
@@ -193,13 +204,14 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => {
message: { role: 'assistant', content: [{ type: 'text', text: 'do not admit' }] }
})
const dispatch = adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: USER_MESSAGE,
fence: 7
})
await Promise.resolve()
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: USER_MESSAGE,
fence: 7
})
).resolves.toEqual({ state: 'admitted' })
expect(connection.sent).toHaveLength(1)
connection.handlers.onMessage?.({
...connection.sent[0],
@@ -208,14 +220,20 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => {
})
await Promise.resolve()
expect(events.filter((event) => event.type === 'message')).toHaveLength(1)
expect(settled).not.toHaveBeenCalled()
connection.handlers.onMessage?.({
...connection.sent[0],
session_id: PROVIDER_SESSION_ID
})
await expect(dispatch).resolves.toMatchObject({
state: 'accepted',
providerIdentity: { uuid: connection.sent[0]!.uuid }
expect(settled).toHaveBeenCalledWith({
sessionId: 'session-1',
clientMessageId: 'client-1',
providerIdentity: {
provider: 'claude',
sessionId: PROVIDER_SESSION_ID,
uuid: connection.sent[0]!.uuid
}
})
})
@@ -382,231 +400,6 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => {
})
})
describe('ClaudeStructuredSessionAdapter turns and controls', () => {
it('accepts a dispatch only after Claude replays its provider uuid', async () => {
const claude = fakeClaude({ replayUuid: 'user-provider-uuid' })
const adapter = await acquired(claude)
const result = await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: USER_MESSAGE,
fence: 7
})
expect(result).toEqual({
state: 'accepted',
providerIdentity: {
provider: 'claude',
sessionId: PROVIDER_SESSION_ID,
uuid: 'user-provider-uuid'
}
})
expect(claude.connections[0].sent[0]).toMatchObject({
type: 'user',
message: { role: 'user', content: [{ type: 'text', text: 'ship it' }] },
session_id: PROVIDER_SESSION_ID
})
})
it('leaves delivery unconfirmed when no replay uuid arrives', async () => {
const adapter = await acquired(fakeClaude({ replayUuid: null }))
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: USER_MESSAGE,
fence: 7
})
).resolves.toMatchObject({ state: 'unknown' })
})
it('requires an acknowledged interrupt and supports controlled options', async () => {
const claude = fakeClaude()
const adapter = await acquired(claude)
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 })
).resolves.toEqual({ cancelled: true })
await expect(
adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'sonnet', fence: 7 })
).resolves.toEqual({ model: 'sonnet' })
expect(claude.connections[0].calls.slice(-2)).toEqual([
{ subtype: 'interrupt', params: {} },
{ subtype: 'set_model', params: { model: 'sonnet' } }
])
claude.routes.interrupt = () => {
throw new ClaudeControlRequestError('interrupt', 'not running')
}
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-2', fence: 7 })
).resolves.toEqual({ cancelled: false })
claude.routes.interrupt = () => {
throw new Error('claude interrupt request timed out')
}
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-3', fence: 7 })
).rejects.toThrow('timed out')
})
it('does not let a delayed cancellation for an earlier turn interrupt the later turn', async () => {
const claude = fakeClaude({ replayUuids: ['turn-T', 'turn-U'] })
const adapter = await acquired(claude)
await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-T',
body: USER_MESSAGE,
fence: 7
})
await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-U',
body: USER_MESSAGE,
fence: 7
})
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-T', fence: 7 })
).resolves.toEqual({ cancelled: false })
expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength(
0
)
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-U', fence: 6 })
).resolves.toEqual({ cancelled: false })
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-U', fence: 7 })
).resolves.toEqual({ cancelled: true })
expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength(
1
)
})
it('does not cancel an acknowledged turn after a later dispatch returns unknown', async () => {
const claude = fakeClaude({ replayUuids: ['turn-T', null] })
const adapter = await acquired(claude)
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-T',
body: USER_MESSAGE,
fence: 7
})
).resolves.toMatchObject({
state: 'accepted',
providerIdentity: { uuid: 'turn-T' }
})
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-U',
body: USER_MESSAGE,
fence: 7
})
).resolves.toMatchObject({ state: 'unknown' })
expect(claude.connections[0].sent).toHaveLength(2)
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-T', fence: 7 })
).resolves.toEqual({ cancelled: false })
expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength(
0
)
})
it('classifies provider-declined options without treating timeouts as settled', async () => {
const claude = fakeClaude({
routes: {
set_model: () => {
throw new ClaudeControlRequestError('set_model', 'model unavailable')
}
}
})
const adapter = await acquired(claude)
await expect(
adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'fable', fence: 7 })
).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' })
claude.routes.set_model = () => {
throw new Error('claude set_model request timed out')
}
await expect(
adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 })
).rejects.toThrow('timed out')
})
it('hydrates live model choices and maps the resolved current model to its CLI id', async () => {
const claude = fakeClaude({
initModel: 'claude-sonnet-5',
routes: {
list_models: () => [
{ value: 'default', resolvedModel: 'claude-opus-5', displayName: 'Default' },
{
value: 'opus',
resolvedModel: 'claude-opus-5',
displayName: 'Opus',
supportsEffort: true,
supportedEffortLevels: ['low', 'high']
},
{
value: 'sonnet',
resolvedModel: 'claude-sonnet-5',
displayName: 'Sonnet'
}
]
}
})
const adapter = await acquired(claude)
await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({
models: [
{
id: 'opus',
label: 'Opus',
isDefault: true,
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
},
{ id: 'sonnet', label: 'Sonnet', isDefault: false, efforts: [] }
],
current: { model: 'sonnet', effort: 'high', confirmed: ['model', 'effort'] }
})
})
it('keeps the shared Claude seed when live model discovery is unavailable', async () => {
const claude = fakeClaude({
initModel: 'custom-model',
routes: {
list_models: () => {
throw new Error('unsupported')
}
}
})
const adapter = await acquired(claude)
const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
expect(result.models.map((model) => model.id)).toEqual([
'fable',
'opus',
'sonnet',
'haiku',
'custom-model'
])
expect(result.current).toEqual({
model: 'custom-model',
effort: 'high',
confirmed: ['model', 'effort']
})
})
})
describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => {
/** A start that fails after the child self-exited, with its close verdict scripted. */
function failedStart(
@@ -40,8 +40,6 @@ export type {
ClaudeStructuredSessionEvent
} from './claude-structured-session-state'
const DISPATCH_ACK_TIMEOUT_MS = 10_000
function backgroundTaskState(session: ClaudeSession): AgentSessionBackgroundTaskState | null {
const state = session.backgroundTasks.state
return state ? { ...state, supportsTaskStop: true } : null
@@ -220,19 +218,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
}
dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) =>
dispatchClaudeTurn(
this.session(input.sessionId),
input,
this.deps.dispatchAckTimeoutMs ?? DISPATCH_ACK_TIMEOUT_MS
)
dispatchClaudeTurn(this.session(input.sessionId), input)
compact: NonNullable<StructuredAgentSessionAdapter['compact']> = (input) =>
compactClaudeSession(
this.session(input.sessionId),
this.compactions,
input,
this.deps.dispatchAckTimeoutMs ?? DISPATCH_ACK_TIMEOUT_MS
)
compactClaudeSession(this.session(input.sessionId), this.compactions, input)
cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (input) => {
const session = this.session(input.sessionId)
@@ -13,6 +13,7 @@ import {
import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection'
import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire'
import { closeProcessRegistry } from '../../shared/child-process/close-process-registry'
import { retireClaudeDispatchWaiters } from './claude-structured-dispatch'
import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof'
export function claudeAcquisitionCleanupError(
@@ -28,15 +29,10 @@ export function claudeAcquisitionCleanupError(
: new AgentSessionAcquisitionExitUnprovenError(cause)
}
export function settleClaudeDispatchWaiters(session: ClaudeSession): void {
for (const waiter of session.dispatchWaiters.splice(0)) {
clearTimeout(waiter.timer)
waiter.resolve(null)
}
}
export function settleClaudeExitedSession(session: ClaudeSession): void {
settleClaudeDispatchWaiters(session)
// The child is gone, so no replay can start these turns. Nothing else ends a
// waiter's life now that no deadline does.
retireClaudeDispatchWaiters(session)
for (const prompt of session.prompts.clear()) {
prompt.settle(null)
}
@@ -68,7 +64,7 @@ async function finalizeClaudePublishedSession(
input: CloseClaudePublishedSessionInput,
session: ClaudeSession
): Promise<boolean> {
settleClaudeDispatchWaiters(session)
retireClaudeDispatchWaiters(session)
// Settle every in-flight permission callback so closing leaves no dangling promise; `null`
// writes no response, and the SDK ignores any post-cleanup answer regardless.
for (const prompt of session.prompts.clear()) {
@@ -64,7 +64,7 @@ export type ClaudeStructuredSessionAdapterDeps = {
identity: AgentSessionJournalIdentity
}) => Promise<ClaudeStructuredLaunch>
onEvent?: (event: ClaudeStructuredSessionEvent) => void
/** A dispatch whose ack timed out, proven delivered by a later provider replay. */
/** Direct settlement path for a provider replay; its durable item row also reconciles delivery. */
onDispatchSettledLate?: (input: {
sessionId: string
clientMessageId: string
@@ -81,7 +81,6 @@ export type ClaudeStructuredSessionAdapterDeps = {
now?: () => number
requestTimeoutMs?: number
initTimeoutMs?: number
dispatchAckTimeoutMs?: number
persistHandle?: (input: {
sessionId: string
providerSessionId: string
@@ -100,18 +99,16 @@ export type ClaudeStructuredSessionAdapterDeps = {
export type ClaudeDispatchWaiter = {
resolve: (uuid: string | null) => void
timer: ReturnType<typeof setTimeout>
acceptsResult: boolean
/** Carried so a replay that lands after the ack window can settle the journal
* submission this dispatch came from, not just the in-memory turn identity. */
clientMessageId: string
/** Submission settled by the replay, or null for provider-control turns. */
clientMessageId: string | null
/** Client uuid echoed by Claude so a replay is tied to its own dispatch. */
sentUuid: string
/** Sequence used to fence a late identity from a newer dispatch. */
dispatchSequence: number
/** Set when the provider replay settled this waiter before send returned. */
settledUuid?: string
/** The waiter timed out or its write failed, but its replay may still arrive. */
/** The write failed or the child died, but a replay may still name it. */
retired?: boolean
/** Bounded digest/summary for compatibility CLIs that mint UUIDs. */
replayContentKey: string
@@ -127,7 +124,7 @@ export type ClaudeSession = {
acquisitionGeneration: string
prompts: ClaudePromptRegistry
dispatchWaiters: ClaudeDispatchWaiter[]
/** Bounded identities for dispatches whose ack was unknown when they returned. */
/** Bounded identities for dispatches whose child died or whose write failed. */
retiredDispatchWaiters: ClaudeDispatchWaiter[]
/** Once a retired waiter is evicted, legacy content-only replay matching is unsafe. */
replayContentFallbackBlocked: boolean
@@ -198,7 +198,8 @@ export function adapterFor(
initTimeoutMs?: number,
readTranscriptLeaf?: ClaudeStructuredSessionAdapterDeps['readTranscriptLeaf'],
persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle'],
onBackgroundTasksChanged?: ClaudeStructuredSessionAdapterDeps['onBackgroundTasksChanged']
onBackgroundTasksChanged?: ClaudeStructuredSessionAdapterDeps['onBackgroundTasksChanged'],
onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate']
): ClaudeStructuredSessionAdapter {
return new ClaudeStructuredSessionAdapter({
resolveLaunch: async () => ({
@@ -216,13 +217,13 @@ export function adapterFor(
readProcessStartTime: async () => 1_700_000_000_000,
now: () => 1_700_000_000_500,
...(initTimeoutMs === undefined ? {} : { initTimeoutMs }),
dispatchAckTimeoutMs: 10,
persistHandle:
persistHandle ??
(async (handle) => {
persistedHandles.push(handle)
}),
...(onBackgroundTasksChanged ? { onBackgroundTasksChanged } : {}),
...(onDispatchSettledLate ? { onDispatchSettledLate } : {}),
...(readTranscriptLeaf ? { readTranscriptLeaf } : {})
})
}
@@ -230,9 +231,20 @@ export function adapterFor(
export async function acquired(
claude: ReturnType<typeof fakeClaude>,
launch: Partial<ClaudeStructuredLaunch> = {},
events: ClaudeStructuredSessionEvent[] = []
events: ClaudeStructuredSessionEvent[] = [],
onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate']
): Promise<ClaudeStructuredSessionAdapter> {
const adapter = adapterFor(claude, launch, events)
const adapter = adapterFor(
claude,
launch,
events,
undefined,
undefined,
undefined,
undefined,
undefined,
onDispatchSettledLate
)
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
return adapter
}
@@ -176,6 +176,7 @@ describe.skipIf(!claudeAuthenticated)('real Claude TUI resume proof', () => {
const providerSessionId = randomUUID()
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude')
const events: ClaudeStructuredSessionEvent[] = []
const settlements: { clientMessageId: string }[] = []
const adapter = new ClaudeStructuredSessionAdapter({
resolveLaunch: async () => ({
pathToClaudeCodeExecutable: command,
@@ -191,6 +192,7 @@ describe.skipIf(!claudeAuthenticated)('real Claude TUI resume proof', () => {
resumed: false
}),
onEvent: (event) => events.push(event),
onDispatchSettledLate: (settlement) => settlements.push(settlement),
readProcessStartTime: async () => 1
})
let resumed: RunningTui | null = null
@@ -211,8 +213,12 @@ describe.skipIf(!claudeAuthenticated)('real Claude TUI resume proof', () => {
blocks: [{ type: 'text', text: 'Reply only with ORCA_RESUME_READY.' }]
}
})
).resolves.toMatchObject({ state: 'accepted' })
).resolves.toEqual({ state: 'admitted' })
await waitForStructuredResult(events)
// The real CLI's replay is what settles the send; dispatch only admitted it.
expect(settlements.map((settlement) => settlement.clientMessageId)).toContain(
'real-product-turn'
)
const started = await waitForHook(eventsPath, 'startup')
const transcriptPath = String(started.transcript_path)
transcripts.push(transcriptPath)
@@ -7,6 +7,7 @@ import {
} from './codex-app-server-connection'
import { isCodexAppServerUnsupportedError } from './codex-app-server-session'
import { readCodexTurnId } from './codex-structured-thread-facts'
import { DISPATCH_DOUBT_CODEX_TURN_UNNAMED } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons'
// Starting a Codex turn and learning its id, which are not the same event:
// `turn/start` returns the id on newer builds and acks before it exists on
@@ -115,7 +116,7 @@ export async function dispatchCodexTurn(
throw error
}
return turnId === null
? { state: 'unknown', reason: 'codex app-server started a turn it did not name in time' }
? { state: 'unknown', reason: DISPATCH_DOUBT_CODEX_TURN_UNNAMED }
: {
state: 'accepted',
providerIdentity: {
+3
View File
@@ -13,6 +13,7 @@ import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-rest
import {
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
AGENT_SESSION_TURN_ITEM_CAPABILITY,
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
@@ -84,6 +85,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
connectionId: desktopSenders.connectionIdFor(event.sender),
clientCapabilities: [
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
AGENT_SESSION_TURN_ITEM_CAPABILITY,
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
@@ -135,6 +137,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
connectionId,
clientCapabilities: [
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
AGENT_SESSION_TURN_ITEM_CAPABILITY,
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
@@ -16,6 +16,10 @@ import type {
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection'
import {
DISPATCH_DOUBT_RETRY_IN_PROGRESS,
dispatchDoubtProvesUndelivered
} from './journal-dispatch-doubt-reasons'
import { digestPayload } from './journal-payload-bounds'
import {
reconcileSubmissions,
@@ -166,6 +170,58 @@ describe('crash between provider accept and journal commit', () => {
expect(restarted.cursor()).toEqual(cursor)
})
it('preserves a proven write failure while retiring its live dispatch', async () => {
const journal = await open()
await journal.appendSubmission({
clientMessageId: 'cm_write_failed',
payloadFingerprint: digestPayload('safe to retry'),
body: userMessage('safe to retry'),
fence: 1
})
await journal.resolveDispatch({
clientMessageId: 'cm_write_failed',
state: 'unknown',
reason: 'provider_write_failed: broken pipe',
fence: 1
})
const restarted = await open()
await restarted.markPendingSubmissionsUnknown(2)
expect(restarted.submissions()[0]).toMatchObject({
dispatchState: 'unknown',
reason: 'provider_write_failed: broken pipe',
recovered: true
})
expect(dispatchDoubtProvesUndelivered(restarted.submissions()[0]?.reason)).toBe(true)
})
it('turns an interrupted retry marker into recovery doubt', async () => {
const journal = await open()
await journal.appendSubmission({
clientMessageId: 'cm_retrying',
payloadFingerprint: digestPayload('retry interrupted'),
body: userMessage('retry interrupted'),
fence: 1
})
await journal.resolveDispatch({
clientMessageId: 'cm_retrying',
state: 'unknown',
reason: DISPATCH_DOUBT_RETRY_IN_PROGRESS,
fence: 1
})
const restarted = await open()
await restarted.markPendingSubmissionsUnknown(2, 'provider_exited_before_acknowledgement')
expect(restarted.submissions()[0]).toMatchObject({
dispatchState: 'unknown',
reason: 'provider_exited_before_acknowledgement',
recovered: true
})
expect(dispatchDoubtProvesUndelivered(restarted.submissions()[0]?.reason)).toBe(false)
})
it('reports a rejected submission as never delivered, and never re-sends it', async () => {
const journal = await open()
await journal.appendSubmission({
@@ -0,0 +1,74 @@
// Why a submission is in doubt, and whether Orca may put the message on the
// wire a second time.
import type { AgentJournalDispatchState } from '../../../shared/agent-session-journal-types'
//
// `unknown` is never raised by elapsed time; what survives is a process fact.
// But a process fact that ends the WAIT is not the same claim as one that
// proves the message never reached a provider, and only the second justifies a
// re-delivery. The allowlist below names the reasons that carry the stronger
// claim, and it is deliberately FAIL-CLOSED: a reason nobody adds to it is
// refused. Refusing a legitimate retry costs the user one re-typed message;
// allowing an illegitimate one silently sends the model a second copy, which is
// the harm this whole path exists to remove. When those two are in tension,
// choose the re-type.
/** A previous process wrote the message and died before learning its outcome. */
export const DISPATCH_DOUBT_HOST_RESTARTED = 'host_restarted_before_acknowledgement'
/** The child that would have acknowledged the message exited first. */
export const DISPATCH_DOUBT_PROVIDER_EXITED = 'provider_exited_before_acknowledgement'
/** The adapter took the message and only the journal write failed after it. */
export const DISPATCH_DOUBT_PERSISTENCE_FAILED = 'dispatch_result_persistence_failed'
/** A retry was durably armed but had not yet recorded its dispatch outcome. */
export const DISPATCH_DOUBT_RETRY_IN_PROGRESS = 'dispatch_retry_in_progress'
/** Codex owns a turn it started but did not name, because its turn-start still
* settles on a deadline. Delete this once Codex settles on the app-server's
* turn-start response instead; until then this reason is never re-delivered,
* which is what the allowlist below already does by omitting it. */
export const DISPATCH_DOUBT_CODEX_TURN_UNNAMED =
'codex app-server started a turn it did not name in time'
/** The transport refused the frame; the underlying error follows the colon. */
export const DISPATCH_DOUBT_WRITE_FAILED = 'provider_write_failed'
/** The SDK took the frame, but its input pump did not prove whether the write completed. */
export const DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN = 'provider_write_outcome_unknown'
export function dispatchWriteFailureReason(error: unknown): string {
const detail = error instanceof Error ? error.message : String(error)
return `${DISPATCH_DOUBT_WRITE_FAILED}: ${detail}`
}
export function dispatchWriteOutcomeUnknownReason(error: unknown): string {
const detail = error instanceof Error ? error.message : String(error)
return `${DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN}: ${detail}`
}
/**
* The allowlist. True only where the frame is known never to have been taken by
* a provider, so sending it again is a first delivery rather than a second.
*
* A dead child and a dead host are NOT on this list. Both end the wait, neither
* proves non-delivery: the message was already written to that child's stdin,
* and Claude resumes the same provider session by id, so a message that child
* processed before dying is in the conversation Orca resumes. Deciding those
* needs the message matched against provider history — which is exactly what
* `journal-submission-reconciler.ts` does, and that module has no caller yet.
*/
export function dispatchDoubtProvesUndelivered(reason: string | null | undefined): boolean {
return (
reason === DISPATCH_DOUBT_WRITE_FAILED ||
reason?.startsWith(`${DISPATCH_DOUBT_WRITE_FAILED}: `) === true
)
}
export function dispatchMayMatchProviderEcho(
state: AgentJournalDispatchState,
reason: string | null
): boolean {
return state !== 'rejected' && !(state === 'unknown' && dispatchDoubtProvesUndelivered(reason))
}
@@ -0,0 +1,14 @@
import type { JournalReducerState } from './journal-reducer'
export function journalItemRevisionIsStale(
state: JournalReducerState,
itemId: string,
revision: number
): boolean {
const tombstoned = state.tombstones.get(itemId)
const existing = state.items.get(itemId)
return (
(tombstoned !== undefined && revision <= tombstoned) ||
(existing !== undefined && revision <= existing.revision)
)
}
@@ -1,26 +1,37 @@
import {
DISPATCH_DOUBT_HOST_RESTARTED,
DISPATCH_DOUBT_RETRY_IN_PROGRESS
} from './journal-dispatch-doubt-reasons'
import type { AgentSessionJournal } from './journal-store'
/** Settles every submission a process fact left unanswerable. The retry policy
* separately decides whether that fact proves the provider never received it. */
export async function markJournalPendingSubmissionsUnknown(
journal: AgentSessionJournal,
fence: number,
reason = 'host_restarted_before_acknowledgement'
reason: string = DISPATCH_DOUBT_HOST_RESTARTED
): Promise<string[]> {
const pending = journal
const unresolved = journal
.submissions()
.filter(
(entry) =>
entry.dispatchState === 'pending' ||
(entry.dispatchState === 'unknown' && entry.recovered !== true)
)
.map((entry) => entry.clientMessageId)
for (const clientMessageId of pending) {
for (const entry of unresolved) {
const resolvedReason =
entry.dispatchState === 'unknown' &&
entry.reason !== null &&
entry.reason !== DISPATCH_DOUBT_RETRY_IN_PROGRESS
? entry.reason
: reason
await journal.resolveDispatch({
clientMessageId,
clientMessageId: entry.clientMessageId,
state: 'unknown',
reason,
reason: resolvedReason,
fence,
recovered: true
})
}
return pending
return unresolved.map((entry) => entry.clientMessageId)
}
@@ -216,6 +216,106 @@ describe('submission and dispatch state machine', () => {
expect(items[0]?.revision).toBe(1)
})
it('durably accepts a pending submission from the provider echo row itself', () => {
const body = userText('hi')
const state = fold([
{ ...submission, payloadFingerprint: sendFingerprint(body) },
{
kind: 'item',
itemId: 'claude:session-1:user-1',
revision: 1,
body,
...base(2)
}
])
expect(state.submissions.get('cm_1')).toMatchObject({
dispatchState: 'accepted',
providerItemId: 'claude:session-1:user-1',
resolvedAt: 1_002
})
expect(state.receipts.get('cm_1')).toMatchObject({
providerItemId: 'claude:session-1:user-1',
cursor: { epoch: EPOCH, sequence: 2 }
})
})
it('does not give a newer identical echo to an older proven-undelivered submission', () => {
const body = userText('same message')
const state = fold([
{
...submission,
body,
payloadFingerprint: sendFingerprint(body)
},
{
kind: 'dispatch',
clientMessageId: 'cm_1',
state: 'unknown',
providerItemId: null,
reason: 'provider_write_failed: closed before enqueue',
...base(2)
},
{
...submission,
clientMessageId: 'cm_2',
body,
payloadFingerprint: sendFingerprint(body),
...base(3)
},
{
kind: 'item',
itemId: 'claude:session-1:user-1',
revision: 1,
body,
...base(4)
}
])
expect(state.submissions.get('cm_1')?.dispatchState).toBe('unknown')
expect(state.submissions.get('cm_2')).toMatchObject({
dispatchState: 'accepted',
providerItemId: 'claude:session-1:user-1'
})
expect(state.receipts.has('cm_1')).toBe(false)
expect(state.receipts.get('cm_2')?.providerItemId).toBe('claude:session-1:user-1')
})
it('does not accept a submission from a stale provider item behind its tombstone', () => {
const body = userText('hi')
const providerItemId = 'claude:session-1:user-1'
const state = fold([
{ ...submission, payloadFingerprint: sendFingerprint(body) },
{ kind: 'tombstone', itemId: providerItemId, revision: 2, ...base(2) },
{ kind: 'item', itemId: providerItemId, revision: 1, body, ...base(3) }
])
expect(state.submissions.get('cm_1')?.dispatchState).toBe('pending')
expect(state.receipts.has('cm_1')).toBe(false)
expect(state.aliases.has(providerItemId)).toBe(false)
})
it('does not accept a submission from a stale lifecycle item behind its tombstone', () => {
const body = userText('hi')
const providerItemId = 'claude:session-1:user-1'
const state = fold([
{ ...submission, payloadFingerprint: sendFingerprint(body) },
{
kind: 'lifecycle-batch',
settlementId: 'settlement-1',
mutations: [
{ kind: 'tombstone', itemId: providerItemId, revision: 2 },
{ kind: 'item', itemId: providerItemId, revision: 1, body }
],
...base(2)
}
])
expect(state.submissions.get('cm_1')?.dispatchState).toBe('pending')
expect(state.receipts.has('cm_1')).toBe(false)
expect(state.aliases.has(providerItemId)).toBe(false)
})
it.each(['codex:thread-1:turn-1:0', 'claude:session-1:user-1'])(
'preserves submitted text and attachments when %s is restored',
(providerItemId) => {
@@ -384,6 +484,36 @@ describe('submission and dispatch state machine', () => {
expect(state.receipts.get('cm_1')).toBeTruthy()
})
it('returns a proven retry to pending without moving its original submission', () => {
const state = fold([
submission,
{
kind: 'dispatch',
clientMessageId: 'cm_1',
state: 'unknown',
providerItemId: null,
reason: 'provider_write_failed: closed before enqueue',
...base(2)
},
{
kind: 'dispatch',
clientMessageId: 'cm_1',
state: 'pending',
providerItemId: null,
reason: null,
...base(3)
}
])
expect(state.submissions.get('cm_1')).toMatchObject({
dispatchState: 'pending',
submittedAt: submission.ts,
reason: null,
resolvedAt: null
})
expect(renderJournalState(state).items[0]?.sequence).toBe(submission.seq)
})
it('ignores a dispatch for a submission this epoch never saw', () => {
const state = fold([
{
@@ -18,6 +18,8 @@ import {
parseAgentJournalItemKey
} from '../../../shared/agent-session-journal-item-key'
import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation'
import { dispatchMayMatchProviderEcho } from './journal-dispatch-doubt-reasons'
import { journalItemRevisionIsStale } from './journal-item-revision'
import type { JournalRow } from './journal-row-schema'
export const MAX_JOURNAL_APPLIED_SETTLEMENT_IDS = 4_096
@@ -66,7 +68,11 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo
}
state.lastActivityAt = Math.max(state.lastActivityAt, row.ts)
if (row.kind === 'item') {
if (journalItemRevisionIsStale(state, row.itemId, row.revision)) {
return
}
const itemId = resolveJournalItemId(state, row.itemId, row.body)
acceptSubmissionFromProviderItem(state, row.itemId, itemId, row)
upsertItem(state, itemId, row.revision, {
itemId,
revision: row.revision,
@@ -87,7 +93,11 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo
}
for (const mutation of row.mutations) {
if (mutation.kind === 'item') {
if (journalItemRevisionIsStale(state, mutation.itemId, mutation.revision)) {
continue
}
const itemId = resolveJournalItemId(state, mutation.itemId, mutation.body)
acceptSubmissionFromProviderItem(state, mutation.itemId, itemId, row)
upsertItem(state, itemId, mutation.revision, {
itemId,
revision: mutation.revision,
@@ -151,12 +161,12 @@ export function resolveJournalItemId(
// Exact payload plus queue order preserves repeated identical sends one-for-one.
const submission = [...state.submissions.values()]
.sort((left, right) => left.submittedAt - right.submittedAt)
.find((candidate) => {
if (candidate.dispatchState === 'rejected' || candidate.payloadFingerprint !== fingerprint) {
return false
}
return state.items.get(agentJournalSubmissionKey(candidate.clientMessageId))?.revision === 0
})
.find(
(candidate) =>
dispatchMayMatchProviderEcho(candidate.dispatchState, candidate.reason) &&
candidate.payloadFingerprint === fingerprint &&
state.items.get(agentJournalSubmissionKey(candidate.clientMessageId))?.revision === 0
)
if (!submission) {
return itemId
}
@@ -260,7 +270,7 @@ function applyDispatch(
submission.dispatchState = row.state
submission.providerItemId = row.providerItemId
submission.reason = row.reason
submission.resolvedAt = row.ts
submission.resolvedAt = row.state === 'pending' ? null : row.ts
if (row.recovered) {
submission.recovered = row.recovered
} else {
@@ -278,6 +288,39 @@ function applyDispatch(
})
}
function acceptSubmissionFromProviderItem(
state: JournalReducerState,
providerItemId: string,
resolvedItemId: string,
row: Pick<JournalRow, 'epoch' | 'seq' | 'fence' | 'ts'>
): void {
if (providerItemId === resolvedItemId) {
return
}
const submission = [...state.submissions.values()].find(
(candidate) => agentJournalSubmissionKey(candidate.clientMessageId) === resolvedItemId
)
if (
!submission ||
submission.dispatchState === 'accepted' ||
submission.dispatchState === 'rejected'
) {
return
}
submission.fence = row.fence
submission.dispatchState = 'accepted'
submission.providerItemId = providerItemId
submission.reason = null
submission.resolvedAt = row.ts
delete submission.recovered
state.receipts.set(submission.clientMessageId, {
clientMessageId: submission.clientMessageId,
providerItemId,
cursor: { epoch: row.epoch, sequence: row.seq },
acceptedAt: row.ts
})
}
/** Project the folded state into the client-facing snapshot. */
export function renderJournalState(state: JournalReducerState): AgentJournalSnapshot {
// Sequence is the sole ordering key; map insertion order is not, because a
@@ -76,7 +76,8 @@ export function journalDispatchRowBuilder(
clientMessageId: input.clientMessageId,
dispatchState: input.state,
providerItemId,
reason: input.state === 'accepted' ? null : (input.reason ?? null),
reason:
input.state === 'accepted' || input.state === 'pending' ? null : (input.reason ?? null),
seq,
fence: input.fence,
ts,
@@ -219,7 +220,7 @@ export function buildJournalSubmissionRow(input: {
export function buildJournalDispatchRow(input: {
state: JournalReducerState
clientMessageId: string
dispatchState: Exclude<AgentJournalDispatchState, 'pending'>
dispatchState: AgentJournalDispatchState
providerItemId: string | null
reason: string | null
seq: number
@@ -74,7 +74,7 @@ export type JournalSubmissionRow = JournalRowBase & {
export type JournalDispatchRow = JournalRowBase & {
kind: 'dispatch'
clientMessageId: string
state: Exclude<AgentJournalDispatchState, 'pending'>
state: AgentJournalDispatchState
/** Provider item identity adopted on accept. */
providerItemId: string | null
reason: string | null
@@ -29,6 +29,7 @@ export type ResolveDispatchInput = {
recovered?: true
} & (
| { state: 'accepted'; providerIdentity: AgentJournalItemIdentity }
| { state: 'pending' }
| { state: 'rejected' | 'unknown'; reason?: string | null }
)
@@ -232,7 +232,7 @@ export class AgentSessionJournal {
}
/**
* Advance a submission to exactly one of accepted / rejected / unknown.
* Record a dispatch transition, including a proven retry returning to pending.
*
* Accepting REQUIRES the provider identity rather than a free-form id: the
* adopted key is what the provider's echo will upsert into, so a mismatched
@@ -91,6 +91,13 @@ export function isAgentSessionPreSpawnError(error: unknown): error is AgentSessi
export type AgentSessionDispatchOutcome =
/** The provider owns the turn now, under this identity. */
| { state: 'accepted'; providerIdentity: AgentJournalItemIdentity }
/**
* The provider transport took the message; identity settles later, out of band.
* The submission stays `pending`: a message queued behind a running turn is
* acknowledged only when that turn starts, so elapsed time is not evidence of
* anything and never promotes this to `unknown`.
*/
| { state: 'admitted' }
| { state: 'rejected'; reason: string }
/** The call did not settle. Never re-send on the user's behalf. */
| { state: 'unknown'; reason: string }
@@ -0,0 +1,75 @@
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { AgentSessionSubscribers } from './structured-agent-session-subscribers'
import type {
StructuredAgentSessionHostDeps,
StructuredAgentSessionHostSession
} from './structured-agent-session-host-types'
import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission'
import { StructuredAgentSessionSendSettlement } from './structured-agent-session-send-settlement'
import {
createStructuredAgentSessionHostStatusFeed,
type StructuredAgentSessionStatusSubscriber
} from './structured-agent-session-status-feed'
/** Owns every host-to-client publication edge, including compatibility waits. */
export class StructuredAgentSessionClientDelivery {
readonly subscribers: AgentSessionSubscribers
readonly waitForSendSettlement: StructuredAgentSessionSendSettlement['wait']
private readonly statusFeed
private readonly sendSettlement
constructor(
private readonly sessions: Map<string, StructuredAgentSessionHostSession>,
now: () => number,
deps: () => StructuredAgentSessionHostDeps
) {
this.statusFeed = createStructuredAgentSessionHostStatusFeed({ sessions, now, deps })
this.sendSettlement = new StructuredAgentSessionSendSettlement((sessionId) =>
this.requireJournal(sessionId)
)
this.waitForSendSettlement = this.sendSettlement.wait
this.subscribers = new AgentSessionSubscribers({
readCommands: (sessionId) => deps().adapter.readCommands?.(sessionId),
onJournalPublished: (sessionId, journal) => this.publishJournal(sessionId, journal)
})
}
publishStatus = (sessionId: string): void => this.statusFeed.publish(sessionId)
publishStatusAndSettlement = (sessionId: string): void => {
this.statusFeed.publish(sessionId)
const journal = this.sessions.get(sessionId)?.journal
if (journal) {
this.sendSettlement.publish(sessionId, journal)
}
}
publishRestored = (sessionId: string): void =>
this.statusFeed.publish(sessionId, undefined, { replay: true })
subscribeStatus = (subscriber: StructuredAgentSessionStatusSubscriber): (() => void) =>
this.statusFeed.subscribe(subscriber)
forgetStatus = (sessionId: string): void => this.statusFeed.forget(sessionId)
closeSession(sessionId: string): void {
this.sendSettlement.closeSession(sessionId)
this.statusFeed.close(sessionId)
}
closeAll(): void {
this.sendSettlement.closeAll()
}
private publishJournal(sessionId: string, journal: AgentSessionJournal): void {
this.statusFeed.publish(sessionId, journal)
this.sendSettlement.publish(sessionId, journal)
}
private requireJournal(sessionId: string): AgentSessionJournal {
const journal = this.sessions.get(sessionId)?.journal
if (!journal) {
throw new Error(AGENT_SESSION_NOT_ATTACHED.code)
}
return journal
}
}
@@ -0,0 +1,197 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, vi, type Mock } from 'vitest'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire'
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { journalDirectoryFor } from '../agent-session-journal/journal-paths'
import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open'
import type {
AgentSessionDispatchOutcome,
StructuredAgentSessionAdapter
} from './structured-agent-session-adapter'
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
import {
HOST_TEST_NOW as NOW,
HOST_TEST_SESSION as SESSION,
HOST_TEST_THREAD as THREAD,
hostTestAttachParams,
hostTestOperationId,
resetHostTestOperationIds
} from './structured-agent-session-host-test-data'
const journals = createTrackedJournalOpener()
const CALLER = { callerKey: 'client-1' }
function envelope(
method: string,
fields: Record<string, unknown>,
overrides: Partial<AgentSessionMutationEnvelope> = {}
): AgentSessionMutationEnvelope {
return {
sessionId: SESSION,
clientOperationId: hostTestOperationId(),
expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method,
sessionId: SESSION,
fields
}),
...overrides
}
}
const attachParams = (
overrides: Partial<AgentSessionAttachParams> = {}
): AgentSessionAttachParams => hostTestAttachParams(null, overrides)
const ensureParams = (fence: number): AgentSessionAttachParams => hostTestAttachParams(fence)
let root: string
let store: AgentSessionRecordStore
let host: StructuredAgentSessionHost
let acquire: Mock<StructuredAgentSessionAdapter['acquire']>
let releaseAcquisition: Mock<NonNullable<StructuredAgentSessionAdapter['releaseAcquisition']>>
let dispatch: Mock<StructuredAgentSessionAdapter['dispatch']>
let cancelTurn: Mock<StructuredAgentSessionAdapter['cancelTurn']>
let answerPrompt: Mock<StructuredAgentSessionAdapter['answerPrompt']>
let setOption: Mock<StructuredAgentSessionAdapter['setOption']>
let ordinal = 0
function accepted(): AgentSessionDispatchOutcome {
ordinal += 1
return {
state: 'accepted',
providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal }
}
}
function adapter(): StructuredAgentSessionAdapter {
return {
acquire,
releaseAcquisition,
dispatch,
cancelTurn,
answerPrompt,
setOption
}
}
async function attach(): Promise<AgentSessionRecord | null> {
const result = await host.attach(CALLER, attachParams())
expect(result.ok).toBe(true)
return store.getRecord(SESSION)
}
/** Puts a pending approval in the journal BEFORE attach, which is the only way
* 1d can stage one: the adapter that would emit it is phase 2's. */
async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revision: number }> {
const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 99 }
const journalDir = journalDirectoryFor(root, { workspaceId: 'workspace-1', sessionId: SESSION })
const journal = await journals.open({
identity: {
sessionId: SESSION,
workspaceId: 'workspace-1',
hostId: 'local',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: THREAD }
},
journalDir
})
const appended = await journal.appendItem(
identity,
{
kind: 'approval',
title: 'Run the command?',
detail: null,
options: [{ id: optionId, label: 'Allow' }],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
},
{ fence: 1 }
)
return { itemId: appended.itemId, revision: appended.revision }
}
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-wire-host-'))
resetHostTestOperationIds()
ordinal = 0
acquire = vi.fn(async ({ fence }) => ({
process: {
hostId: 'local',
pid: 4242,
processStartTimeMs: 1_700_000_000_000,
spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a'
},
link: {
linkId: `link-${fence}`,
handle: { provider: 'codex', threadId: THREAD },
origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created',
mintedAtFence: fence,
observedAt: NOW
}
}))
releaseAcquisition = vi.fn(async () => true)
dispatch = vi.fn(async () => accepted())
cancelTurn = vi.fn(async () => ({ cancelled: true }))
answerPrompt = vi.fn(async () => undefined)
setOption = vi.fn(async () => undefined)
store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' })
host = new StructuredAgentSessionHost({
store,
adapter: adapter(),
journalRoot: root,
claimKeyId: 'key-1',
mintSpawnToken: () => 'spawn-a',
now: () => NOW
})
})
afterEach(async () => {
await journals.closeAll()
await host.flushAllStreamedEvents()
await rm(root, { recursive: true, force: true })
})
/** A restarted process swaps the store and the host under the same directories.
* The helpers here close over both, so they have to be told. */
export function replaceHostTestState(next: {
store: AgentSessionRecordStore
host: StructuredAgentSessionHost
}): void {
store = next.store
host = next.host
}
/** The live per-test state. Read it in a `beforeEach` so a suite's test bodies
* keep using bare `host` / `store` / `dispatch` exactly as they did when this
* setup was inline. */
export function hostTestState() {
return {
root,
store,
host,
acquire,
releaseAcquisition,
dispatch,
cancelTurn,
answerPrompt,
setOption
}
}
export {
CALLER,
accepted,
adapter,
attach,
attachParams,
ensureParams,
envelope,
journals,
seedApproval
}
@@ -1,63 +1,31 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type {
AgentSessionMutationEnvelope,
AgentSessionSubscribeEvent
} from '../../../shared/agent-session-wire'
import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire'
import { join } from 'node:path'
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { journalDirectoryFor } from '../agent-session-journal/journal-paths'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open'
import type {
AgentSessionDispatchOutcome,
StructuredAgentSessionAdapter
} from './structured-agent-session-adapter'
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
import {
adapter,
attach,
attachParams,
CALLER,
ensureParams,
envelope,
hostTestState,
replaceHostTestState,
seedApproval
} from './structured-agent-session-host-test-harness'
import {
HOST_TEST_NOW as NOW,
HOST_TEST_SESSION as SESSION,
HOST_TEST_THREAD as THREAD,
hostTestAttachParams,
hostTestMessage,
hostTestOperationId,
resetHostTestOperationIds
hostTestMessage
} from './structured-agent-session-host-test-data'
const journals = createTrackedJournalOpener()
const CALLER = { callerKey: 'client-1' }
function envelope(
method: string,
fields: Record<string, unknown>,
overrides: Partial<AgentSessionMutationEnvelope> = {}
): AgentSessionMutationEnvelope {
return {
sessionId: SESSION,
clientOperationId: hostTestOperationId(),
expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method,
sessionId: SESSION,
fields
}),
...overrides
}
}
const attachParams = (
overrides: Partial<AgentSessionAttachParams> = {}
): AgentSessionAttachParams => hostTestAttachParams(null, overrides)
const ensureParams = (fence: number): AgentSessionAttachParams => hostTestAttachParams(fence)
let root: string
let store: AgentSessionRecordStore
let host: StructuredAgentSessionHost
@@ -67,101 +35,19 @@ let dispatch: Mock<StructuredAgentSessionAdapter['dispatch']>
let cancelTurn: Mock<StructuredAgentSessionAdapter['cancelTurn']>
let answerPrompt: Mock<StructuredAgentSessionAdapter['answerPrompt']>
let setOption: Mock<StructuredAgentSessionAdapter['setOption']>
let ordinal = 0
function accepted(): AgentSessionDispatchOutcome {
ordinal += 1
return {
state: 'accepted',
providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal }
}
}
function adapter(): StructuredAgentSessionAdapter {
return {
beforeEach(() => {
;({
root,
store,
host,
acquire,
releaseAcquisition,
dispatch,
cancelTurn,
answerPrompt,
setOption
}
}
async function attach(): Promise<AgentSessionRecord | null> {
const result = await host.attach(CALLER, attachParams())
expect(result.ok).toBe(true)
return store.getRecord(SESSION)
}
/** Puts a pending approval in the journal BEFORE attach, which is the only way
* 1d can stage one: the adapter that would emit it is phase 2's. */
async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revision: number }> {
const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 99 }
const journalDir = journalDirectoryFor(root, { workspaceId: 'workspace-1', sessionId: SESSION })
const journal = await journals.open({
identity: {
sessionId: SESSION,
workspaceId: 'workspace-1',
hostId: 'local',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: THREAD }
},
journalDir
})
const appended = await journal.appendItem(
identity,
{
kind: 'approval',
title: 'Run the command?',
detail: null,
options: [{ id: optionId, label: 'Allow' }],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
},
{ fence: 1 }
)
return { itemId: appended.itemId, revision: appended.revision }
}
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-wire-host-'))
resetHostTestOperationIds()
ordinal = 0
acquire = vi.fn(async ({ fence }) => ({
process: {
hostId: 'local',
pid: 4242,
processStartTimeMs: 1_700_000_000_000,
spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a'
},
link: {
linkId: `link-${fence}`,
handle: { provider: 'codex', threadId: THREAD },
origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created',
mintedAtFence: fence,
observedAt: NOW
}
}))
releaseAcquisition = vi.fn(async () => true)
dispatch = vi.fn(async () => accepted())
cancelTurn = vi.fn(async () => ({ cancelled: true }))
answerPrompt = vi.fn(async () => undefined)
setOption = vi.fn(async () => undefined)
store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' })
host = new StructuredAgentSessionHost({
store,
adapter: adapter(),
journalRoot: root,
claimKeyId: 'key-1',
mintSpawnToken: () => 'spawn-a',
now: () => NOW
})
})
afterEach(async () => {
await journals.closeAll()
await host.flushAllStreamedEvents()
await rm(root, { recursive: true, force: true })
} = hostTestState())
})
describe('attach', () => {
@@ -308,152 +194,6 @@ describe('attach', () => {
})
})
describe('send', () => {
it('writes the submission before dispatching and resolves it accepted', async () => {
await attach()
const body = hostTestMessage('add a retry')
const result = await host.send(CALLER, {
envelope: envelope('agentSession.send', { body }),
body
})
if (!result.ok) {
throw new Error(`expected a send, got ${result.refusal.code}`)
}
expect(result.value.submission.dispatchState).toBe('accepted')
expect(dispatch).toHaveBeenCalledTimes(1)
const page = host.history({ sessionId: SESSION, direction: 'tail' })
expect(page.ok && page.page.items).toHaveLength(1)
expect(page.ok && page.page.fence).toBe(1)
// The injected host clock, so a client can anchor a live counter on it.
expect(page.page.hostNow).toBe(NOW)
expect(page.providerSession).toEqual({ key: 'session_id', id: THREAD })
})
it('settles a thrown dispatch as unknown, never as a rejection', async () => {
await attach()
dispatch.mockRejectedValueOnce(new Error('socket closed'))
const body = hostTestMessage('add a retry')
const result = await host.send(CALLER, {
envelope: envelope('agentSession.send', { body }),
body
})
expect(result).toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } })
})
it('replays a retried send from the journal without dispatching twice', async () => {
await attach()
const body = hostTestMessage('add a retry')
const params = { envelope: envelope('agentSession.send', { body }), body }
await host.send(CALLER, params)
const retry = await host.send(CALLER, params)
expect(retry).toMatchObject({ ok: true, replayed: true })
expect(dispatch).toHaveBeenCalledTimes(1)
})
it('redispatches an explicitly retried durable unknown without appending a second submission', async () => {
await attach()
dispatch
.mockRejectedValueOnce(new Error('socket closed'))
.mockImplementationOnce(async () => accepted())
const body = hostTestMessage('possibly delivered')
const params = { envelope: envelope('agentSession.send', { body }), body }
const first = await host.send(CALLER, params)
expect(first).toMatchObject({
ok: true,
value: { submission: { dispatchState: 'unknown' } }
})
const retried = await host.send(CALLER, { ...params, retryUnknown: true })
expect(retried).toMatchObject({
ok: true,
replayed: false,
value: { submission: { dispatchState: 'accepted' } }
})
expect(dispatch).toHaveBeenCalledTimes(2)
const state = host.history({ sessionId: SESSION, direction: 'tail' })
expect(state.ok && state.page.submissions).toHaveLength(1)
})
it('advances an explicit retry after a ledger-unknown send is reconciled in the journal', async () => {
await attach()
const journal = (
host as unknown as { sessions: Map<string, { journal: AgentSessionJournal }> }
).sessions.get(SESSION)!.journal
vi.spyOn(journal, 'resolveDispatch').mockRejectedValueOnce(new Error('journal resolve failed'))
const body = hostTestMessage('possibly delivered before persistence failed')
const params = { envelope: envelope('agentSession.send', { body }), body }
await expect(host.send(CALLER, params)).rejects.toThrow('journal resolve failed')
expect(journal.submissions()).toMatchObject([
{ clientMessageId: params.envelope.clientOperationId, dispatchState: 'unknown' }
])
expect(
store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId)
?.outcome
).toEqual({ status: 'unknown' })
expect(dispatch).toHaveBeenCalledTimes(1)
await journal.markPendingSubmissionsUnknown(store.getRecord(SESSION)?.lease.runtimeFence ?? 1)
await expect(host.send(CALLER, params)).resolves.toMatchObject({
ok: false,
refusal: { code: 'agent_session_operation_unknown' }
})
expect(dispatch).toHaveBeenCalledTimes(1)
await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({
ok: true,
replayed: false,
value: { submission: { dispatchState: 'accepted' } }
})
expect(dispatch).toHaveBeenCalledTimes(2)
expect(journal.submissions()).toHaveLength(1)
})
it('refuses a stale fence and hands back the current one', async () => {
const record = await attach()
const body = hostTestMessage('add a retry')
const result = await host.send(CALLER, {
envelope: envelope(
'agentSession.send',
{ body },
{ expectedRuntimeFence: (record?.lease.runtimeFence ?? 1) + 5 }
),
body
})
expect(result).toMatchObject({
ok: false,
refusal: { code: 'agent_session_checkpoint_stale', currentFence: record?.lease.runtimeFence }
})
})
it('does not let a refused call leave a ledger row that replays past the fence', async () => {
const record = await attach()
const body = hostTestMessage('add a retry')
const params = {
envelope: envelope(
'agentSession.send',
{ body },
{ expectedRuntimeFence: (record?.lease.runtimeFence ?? 1) + 5 }
),
body
}
await host.send(CALLER, params)
expect(await host.send(CALLER, params)).toMatchObject({
ok: false,
refusal: { code: 'agent_session_checkpoint_stale' }
})
expect(dispatch).not.toHaveBeenCalled()
})
it('refuses any mutation against a session this host has not attached', async () => {
const body = hostTestMessage('add a retry')
expect(
await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
).toMatchObject({ ok: false, refusal: { code: 'agent_session_ownership_unknown' } })
})
})
describe('cancel', () => {
it('records the request acknowledgement as a status item keyed by the operation id', async () => {
await attach()
@@ -665,6 +405,7 @@ describe('restart', () => {
probeOwner,
now: () => NOW
})
replaceHostTestState({ store, host })
}
/** The refusal a restarted host owes a client holding the dead generation's
@@ -10,10 +10,7 @@ import type * as SessionWire from '../../../shared/agent-session-wire'
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission'
import { createRestartReconciler } from './structured-agent-session-restart-reconcile'
import {
AgentSessionSubscribers,
type AgentSessionSubscribeInput
} from './structured-agent-session-subscribers'
import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers'
import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue'
import * as providerSupport from './structured-agent-session-provider-support'
import { createStructuredAgentSessionHostRestore } from './structured-agent-session-reveal'
@@ -50,10 +47,10 @@ import type {
StructuredAgentSessionHostSession,
StructuredAgentSessionReveal
} from './structured-agent-session-host-types'
import { createStructuredAgentSessionHostStatusFeed } from './structured-agent-session-status-feed'
import type { StructuredAgentSessionStatusSubscriber } from './structured-agent-session-status-feed'
import { StructuredAgentSessionEventRecovery } from './structured-agent-session-event-recovery'
import { StructuredAgentSessionBackgroundTaskChannel } from './structured-agent-session-background-task-channel'
import { StructuredAgentSessionClientDelivery } from './structured-agent-session-client-delivery'
export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
export class StructuredAgentSessionHost {
@@ -62,16 +59,12 @@ export class StructuredAgentSessionHost {
this
)
private readonly sessions = new Map<string, StructuredAgentSessionHostSession>()
private readonly statusFeed = createStructuredAgentSessionHostStatusFeed({
sessions: this.sessions,
now: () => this.now(),
deps: () => this.deps
})
private readonly subscribers = new AgentSessionSubscribers({
readCommands: (sessionId) => this.deps.adapter.readCommands?.(sessionId),
onJournalPublished: (sessionId, journal) => this.statusFeed.publish(sessionId, journal),
now: () => this.now()
})
private readonly clientDelivery = new StructuredAgentSessionClientDelivery(
this.sessions,
() => this.now(),
() => this.deps
)
private readonly subscribers = this.clientDelivery.subscribers
private readonly tasks = new StructuredAgentSessionTaskQueue()
private readonly runtimeState: StructuredAgentSessionHostRuntimeState
private readonly reconcileLeases: (
@@ -90,7 +83,7 @@ export class StructuredAgentSessionHost {
this.subscribers,
(sessionId) => this.requireSession(sessionId),
(sessionId) => this.handoffs.status(sessionId),
(sessionId) => this.statusFeed.publish(sessionId)
this.clientDelivery.publishStatus
)
this.runtimeState = new StructuredAgentSessionHostRuntimeState(
deps,
@@ -116,7 +109,7 @@ export class StructuredAgentSessionHost {
flush: (sessionId) => this.flushStreamedEvents(sessionId),
serialize: (sessionId, task) => this.serialize(sessionId, task),
subscribers: this.subscribers,
publishStatus: (sessionId) => this.statusFeed.publish(sessionId),
publishStatus: this.clientDelivery.publishStatus,
now: this.now
})
this.holds = createStructuredAgentSessionHolds(this.lifetimeContext(), {
@@ -133,7 +126,7 @@ export class StructuredAgentSessionHost {
// `hasSession` inside the same serialized step as this `set`.
onReadable: (sessionId, restored) => {
this.sessions.set(sessionId, restored)
this.statusFeed.publish(sessionId, undefined, { replay: true })
this.clientDelivery.publishRestored(sessionId)
},
restoreHandoff: (sessionId) => this.handoffs.restore(sessionId)
})
@@ -144,7 +137,7 @@ export class StructuredAgentSessionHost {
flushLifecycle: (sessionId) => this.runtimeState.lifecycleBarrier(sessionId),
publishFence: (sessionId, session) =>
this.subscribers.snapshot(sessionId, session.journal, session.fence),
publishStatus: (sessionId) => this.statusFeed.publish(sessionId),
publishStatus: this.clientDelivery.publishStatusAndSettlement,
hasResumeCapableHolder: (sessionId) => this.holds.hasResumeCapableHolder(sessionId),
serialize: (sessionId, task) => this.serialize(sessionId, task),
now: () => this.now(),
@@ -179,7 +172,7 @@ export class StructuredAgentSessionHost {
runtimeState: this.runtimeState,
sessions: this.sessions,
now: () => this.now(),
forgetStatus: (sessionId) => this.statusFeed.forget(sessionId)
forgetStatus: this.clientDelivery.forgetStatus
}
}
@@ -191,7 +184,7 @@ export class StructuredAgentSessionHost {
tasks: this.tasks,
reconcileLeases: (sessionId) => this.reconcileLeases(sessionId),
serialize: (sessionId, task) => this.serialize(sessionId, task),
publishStatus: (sessionId) => this.statusFeed.publish(sessionId)
publishStatus: this.clientDelivery.publishStatus
}
}
/** Releases a session's resources without ending the conversation: the record and journal stay
@@ -200,7 +193,7 @@ export class StructuredAgentSessionHost {
return this.serialize(sessionId, async () => {
await this.handoffs.closeRetainedTuiOwner(sessionId)
await evictHeldStructuredAgentSession(this.lifetimeContext(), sessionId)
this.statusFeed.close(sessionId)
this.clientDelivery.closeSession(sessionId)
// Whoever asked for the close, the surfaces that were holding this session are looking at a
// session that no longer exists. A failed eviction throws above and keeps them.
this.holds.forget(sessionId)
@@ -211,7 +204,6 @@ export class StructuredAgentSessionHost {
providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent)
listSessionTabs = () => listStructuredAgentSessionTabs(this.sessions)
getPersistedVisibleSessionTabIndex = () => this.deps.store.getVisibleSessionTabIndex()
setSessionTabVisibility = (sessionId: string, visible: boolean): Promise<void> =>
@@ -260,7 +252,7 @@ export class StructuredAgentSessionHost {
tasks: this.tasks
}),
sessions: this.sessions
})
}).finally(() => this.clientDelivery.closeAll())
}
private mutationContext(): StructuredAgentSessionMutationContext {
@@ -277,6 +269,8 @@ export class StructuredAgentSessionHost {
send = (...args: Parameters<StructuredConversationCommandController['send']>) =>
this.conversationCommands.send(...args)
waitForSendSettlement = this.clientDelivery.waitForSendSettlement
private mutations = structuredAgentSessionMutationDelegates(() => this.mutationContext())
cancel = this.mutations.cancel
respondToPrompt = this.mutations.respondToPrompt
@@ -327,7 +321,7 @@ export class StructuredAgentSessionHost {
/** Every session's projected status for session lists; unlike `subscribe`, retains nothing. */
subscribeStatus = (subscriber: StructuredAgentSessionStatusSubscriber): (() => void) =>
this.statusFeed.subscribe(subscriber)
this.clientDelivery.subscribeStatus(subscriber)
private requireSession(sessionId: string): StructuredAgentSessionHostSession {
const session = this.sessions.get(sessionId)
@@ -8,6 +8,7 @@ import type {
AgentSessionSubscribeEvent
} from '../../../shared/agent-session-wire'
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import type {
AgentSessionDispatchOutcome,
StructuredAgentSessionAdapter
@@ -63,6 +64,12 @@ function submissions(): unknown {
return state.ok ? state.page.submissions : null
}
function journal(): AgentSessionJournal {
return (
host as unknown as { sessions: Map<string, { journal: AgentSessionJournal }> }
).sessions.get(SESSION)!.journal
}
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-wire-late-settle-'))
resetHostTestOperationIds()
@@ -197,6 +204,36 @@ describe('settling a send the provider proves it received after the ack window',
expect(dispatch).toHaveBeenCalledTimes(1)
})
it('accepts from the durable echo row when the direct settlement write fails', async () => {
dispatch.mockResolvedValueOnce({ state: 'admitted' })
const params = sendParams('settle from provider echo')
await host.send(CALLER, params)
vi.spyOn(journal(), 'resolveDispatch').mockRejectedValueOnce(
new Error('direct settlement write failed')
)
await expect(
host.settleLateDispatch({
sessionId: SESSION,
clientMessageId: params.envelope.clientOperationId,
providerIdentity: { provider: 'claude', sessionId: THREAD, uuid: 'echo-row' }
})
).rejects.toThrow('direct settlement write failed')
await journal().appendItem(
{ provider: 'claude', sessionId: THREAD, uuid: 'echo-row' },
params.body,
{ fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1 }
)
expect(submissions()).toMatchObject([
{
clientMessageId: params.envelope.clientOperationId,
dispatchState: 'accepted',
providerItemId: `claude:${THREAD}:echo-row`
}
])
})
it('leaves an already accepted send alone', async () => {
const params = sendParams('ordinary send')
await host.send(CALLER, params)
@@ -43,7 +43,7 @@ describe('structured send idempotency', () => {
}
const input = { clientMessageId: 'retry-id', payloadFingerprint: 'fingerprint', body }
await journal.appendSubmission({ ...input, fence: 1 })
await journal.markPendingSubmissionsUnknown(2)
await journal.markPendingSubmissionsUnknown(2, 'provider_write_failed: broken pipe')
const originalItem = journal.snapshot().items[0]
const publish = vi.fn()
const dispatch = vi.fn(async () => {
@@ -0,0 +1,133 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { StructuredAgentSessionSendSettlement } from './structured-agent-session-send-settlement'
function journal(dispatchState: 'pending' | 'accepted' | 'unknown'): AgentSessionJournal {
return {
cursor: () => ({ epoch: 'epoch-1', sequence: dispatchState === 'pending' ? 1 : 2 }),
submissions: () => [
{
clientMessageId: 'client-1',
fence: 1,
payloadFingerprint: 'fingerprint',
dispatchState,
providerItemId: dispatchState === 'accepted' ? 'provider-1' : null,
reason: dispatchState === 'unknown' ? 'provider exited' : null,
submittedAt: 1,
resolvedAt: dispatchState === 'pending' ? null : 2
}
]
} as AgentSessionJournal
}
function emptyJournal(): AgentSessionJournal {
return {
cursor: () => ({ epoch: 'epoch-1', sequence: 2 }),
submissions: () => []
} as unknown as AgentSessionJournal
}
describe('structured send settlement compatibility wait', () => {
afterEach(() => vi.useRealTimers())
it('returns a settlement already present in the journal', async () => {
const settlements = new StructuredAgentSessionSendSettlement(() => journal('accepted'))
await expect(settlements.wait('session-1', 'client-1')).resolves.toMatchObject({
value: { submission: { dispatchState: 'accepted' } }
})
})
it('rejects when the send is absent from the current session generation', async () => {
const settlements = new StructuredAgentSessionSendSettlement(() => emptyJournal())
await expect(settlements.wait('session-1', 'client-1')).rejects.toThrow(
'agent session send disappeared before settlement'
)
})
it('resolves from a journal publication after durable admission', async () => {
const settlements = new StructuredAgentSessionSendSettlement(() => journal('pending'))
const pending = settlements.wait('session-1', 'client-1')
settlements.publish('session-1', journal('accepted'))
await expect(pending).resolves.toMatchObject({
cursor: { sequence: 2 },
value: { submission: { dispatchState: 'accepted' } }
})
})
it('removes an abandoned wait on transport cancellation', async () => {
const settlements = new StructuredAgentSessionSendSettlement(() => journal('pending'))
const controller = new AbortController()
const pending = settlements.wait('session-1', 'client-1', controller.signal)
controller.abort(new Error('transport closed'))
await expect(pending).rejects.toThrow('transport closed')
settlements.publish('session-1', journal('accepted'))
})
it('expires only the compatibility observer when the client leaves its socket open', async () => {
vi.useFakeTimers()
const settlements = new StructuredAgentSessionSendSettlement(() => journal('pending'))
const pending = settlements.wait('session-1', 'client-1')
await vi.advanceTimersByTimeAsync(30_000)
await expect(pending).resolves.toBeUndefined()
settlements.publish('session-1', journal('accepted'))
})
it('caps compatibility observers retained for one session', async () => {
const settlements = new StructuredAgentSessionSendSettlement(() => journal('pending'))
const retained = Array.from({ length: 64 }, () =>
settlements.wait('session-1', 'client-1').catch(() => undefined)
)
await expect(settlements.wait('session-1', 'client-1')).resolves.toBeUndefined()
settlements.closeAll()
await Promise.all(retained)
})
it('caps compatibility observers retained across sessions', async () => {
const settlements = new StructuredAgentSessionSendSettlement(() => journal('pending'))
const retained = Array.from({ length: 1_024 }, (_, index) =>
settlements.wait(`session-${index}`, 'client-1').catch(() => undefined)
)
await expect(settlements.wait('session-overflow', 'client-1')).resolves.toBeUndefined()
settlements.closeAll()
await Promise.all(retained)
})
it('ends only the compatibility observation when the session closes', async () => {
const settlements = new StructuredAgentSessionSendSettlement(() => journal('pending'))
const pending = settlements.wait('session-1', 'client-1')
settlements.closeSession('session-1')
await expect(pending).resolves.toBeUndefined()
settlements.publish('session-1', journal('accepted'))
})
it('rejects a wait when an authoritative publication drops the submission', async () => {
const settlements = new StructuredAgentSessionSendSettlement(() => journal('pending'))
const pending = settlements.wait('session-1', 'client-1')
settlements.publish('session-1', emptyJournal())
await expect(pending).rejects.toThrow('agent session send disappeared before settlement')
})
it('ends every compatibility observation when the host closes', async () => {
const settlements = new StructuredAgentSessionSendSettlement(() => journal('pending'))
const first = settlements.wait('session-1', 'client-1')
const second = settlements.wait('session-2', 'client-1')
settlements.closeAll()
await expect(first).resolves.toBeUndefined()
await expect(second).resolves.toBeUndefined()
})
})
@@ -0,0 +1,164 @@
import type {
AgentJournalCursor,
AgentJournalSubmission
} from '../../../shared/agent-session-journal-types'
import type { AgentSessionSendResult } from '../../../shared/agent-session-wire'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
type SettledSend = {
cursor: AgentJournalCursor
value: AgentSessionSendResult
}
type SendSettlement = SettledSend | 'pending' | 'missing'
type SendSettlementWaiter = {
clientMessageId: string
resolve: (result: SettledSend | undefined) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
signal?: AbortSignal
onAbort?: () => void
}
// Known legacy clients abandon the RPC after 15s without cancelling its socket dispatch.
const SEND_SETTLEMENT_WAIT_TIMEOUT_MS = 30_000
const MAX_SEND_SETTLEMENT_WAITERS_PER_SESSION = 64
const MAX_SEND_SETTLEMENT_WAITERS = 1_024
function settledSend(
journal: AgentSessionJournal,
clientMessageId: string,
submission: AgentJournalSubmission | undefined = journal
.submissions()
.find((candidate) => candidate.clientMessageId === clientMessageId)
): SendSettlement {
if (!submission) {
return 'missing'
}
return submission.dispatchState === 'pending'
? 'pending'
: { cursor: journal.cursor(), value: { clientMessageId, submission } }
}
function abortError(signal: AbortSignal): Error {
return signal.reason instanceof Error
? signal.reason
: new Error('agent session send settlement wait aborted')
}
/** Best-effort settlement observation for clients that predate admitted pending replies. */
export class StructuredAgentSessionSendSettlement {
private readonly waiters = new Map<string, Set<SendSettlementWaiter>>()
private waiterCount = 0
constructor(private readonly journalFor: (sessionId: string) => AgentSessionJournal) {}
wait = (
sessionId: string,
clientMessageId: string,
signal?: AbortSignal
): Promise<SettledSend | undefined> => {
if (signal?.aborted) {
return Promise.reject(abortError(signal))
}
const immediate = settledSend(this.journalFor(sessionId), clientMessageId)
if (immediate === 'missing') {
return Promise.reject(new Error('agent session send disappeared before settlement'))
}
if (immediate !== 'pending') {
return Promise.resolve(immediate)
}
const existingSession = this.waiters.get(sessionId)
if (
this.waiterCount >= MAX_SEND_SETTLEMENT_WAITERS ||
(existingSession?.size ?? 0) >= MAX_SEND_SETTLEMENT_WAITERS_PER_SESSION
) {
return Promise.resolve(undefined)
}
return new Promise((resolve, reject) => {
const waiter: SendSettlementWaiter = {
clientMessageId,
resolve,
reject,
timer: setTimeout(() => {
this.remove(sessionId, waiter)
resolve(undefined)
}, SEND_SETTLEMENT_WAIT_TIMEOUT_MS)
}
waiter.timer.unref?.()
const session = existingSession ?? new Set<SendSettlementWaiter>()
session.add(waiter)
this.waiters.set(sessionId, session)
this.waiterCount += 1
if (signal) {
const onAbort = (): void => {
this.remove(sessionId, waiter)
reject(abortError(signal))
}
waiter.signal = signal
waiter.onAbort = onAbort
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) {
onAbort()
}
}
})
}
publish(sessionId: string, journal: AgentSessionJournal): void {
const waiters = this.waiters.get(sessionId)
if (!waiters) {
return
}
const submissions = new Map(
journal.submissions().map((submission) => [submission.clientMessageId, submission])
)
for (const waiter of waiters) {
const result = settledSend(
journal,
waiter.clientMessageId,
submissions.get(waiter.clientMessageId)
)
if (result !== 'pending') {
this.remove(sessionId, waiter)
if (result === 'missing') {
waiter.reject(new Error('agent session send disappeared before settlement'))
} else {
waiter.resolve(result)
}
}
}
}
closeSession(sessionId: string): void {
const waiters = this.waiters.get(sessionId)
if (!waiters) {
return
}
for (const waiter of waiters) {
this.remove(sessionId, waiter)
waiter.resolve(undefined)
}
}
closeAll(): void {
for (const sessionId of this.waiters.keys()) {
this.closeSession(sessionId)
}
}
private remove(sessionId: string, waiter: SendSettlementWaiter): void {
clearTimeout(waiter.timer)
if (waiter.signal && waiter.onAbort) {
waiter.signal.removeEventListener('abort', waiter.onAbort)
}
const session = this.waiters.get(sessionId)
if (session?.delete(waiter)) {
this.waiterCount -= 1
}
if (session?.size === 0) {
this.waiters.delete(sessionId)
}
}
}
@@ -0,0 +1,332 @@
// What one `agentSession.send` writes, and when a user's Retry is allowed to
// put the same message on the wire a second time.
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { StructuredAgentSessionHost } from './structured-agent-session-host'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import {
accepted,
attach,
CALLER,
envelope,
hostTestState
} from './structured-agent-session-host-test-harness'
import {
HOST_TEST_NOW as NOW,
HOST_TEST_SESSION as SESSION,
HOST_TEST_THREAD as THREAD,
hostTestMessage
} from './structured-agent-session-host-test-data'
let store: AgentSessionRecordStore
let host: StructuredAgentSessionHost
let dispatch: Mock<StructuredAgentSessionAdapter['dispatch']>
beforeEach(() => {
;({ store, host, dispatch } = hostTestState())
})
describe('send', () => {
it('writes the submission before dispatching and resolves it accepted', async () => {
await attach()
const body = hostTestMessage('add a retry')
const result = await host.send(CALLER, {
envelope: envelope('agentSession.send', { body }),
body
})
if (!result.ok) {
throw new Error(`expected a send, got ${result.refusal.code}`)
}
expect(result.value.submission.dispatchState).toBe('accepted')
expect(dispatch).toHaveBeenCalledTimes(1)
const page = host.history({ sessionId: SESSION, direction: 'tail' })
expect(page.ok && page.page.items).toHaveLength(1)
expect(page.ok && page.page.fence).toBe(1)
expect(page.page.hostNow).toBe(NOW)
expect(page.providerSession).toEqual({ key: 'session_id', id: THREAD })
})
it('settles a thrown dispatch as unknown, never as a rejection', async () => {
await attach()
dispatch.mockRejectedValueOnce(new Error('socket closed'))
const body = hostTestMessage('add a retry')
const result = await host.send(CALLER, {
envelope: envelope('agentSession.send', { body }),
body
})
expect(result).toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } })
})
it('replays a retried send from the journal without dispatching twice', async () => {
await attach()
const body = hostTestMessage('add a retry')
const params = { envelope: envelope('agentSession.send', { body }), body }
await host.send(CALLER, params)
const retry = await host.send(CALLER, params)
expect(retry).toMatchObject({ ok: true, replayed: true })
expect(dispatch).toHaveBeenCalledTimes(1)
})
it('refuses to redeliver an explicitly retried unknown from a thrown adapter call', async () => {
await attach()
dispatch.mockRejectedValueOnce(new Error('socket closed'))
const body = hostTestMessage('possibly delivered')
const params = { envelope: envelope('agentSession.send', { body }), body }
const first = await host.send(CALLER, params)
expect(first).toMatchObject({
ok: true,
value: { submission: { dispatchState: 'unknown' } }
})
// A thrown adapter call is indistinguishable from a lost reply, so it is not
// on the allowlist: Retry replays the recorded outcome.
await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({
ok: true,
value: { submission: { dispatchState: 'unknown' } }
})
expect(dispatch).toHaveBeenCalledTimes(1)
const state = host.history({ sessionId: SESSION, direction: 'tail' })
expect(state.ok && state.page.submissions).toHaveLength(1)
})
it('redispatches an explicitly retried unknown the write itself refused', async () => {
await attach()
dispatch
.mockImplementationOnce(async () => ({
state: 'unknown' as const,
reason: 'provider_write_failed: broken pipe'
}))
.mockImplementationOnce(async () => accepted())
const body = hostTestMessage('never written')
const params = { envelope: envelope('agentSession.send', { body }), body }
await host.send(CALLER, params)
// The only doubt on the allowlist: the transport refused the frame, so this
// is a first delivery and not a second.
await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({
ok: true,
replayed: false,
value: { submission: { dispatchState: 'accepted' } }
})
expect(dispatch).toHaveBeenCalledTimes(2)
const state = host.history({ sessionId: SESSION, direction: 'tail' })
expect(state.ok && state.page.submissions).toHaveLength(1)
})
it('returns an admitted retry to pending until the provider echo accepts it', async () => {
await attach()
dispatch
.mockImplementationOnce(async () => ({
state: 'unknown' as const,
reason: 'provider_write_failed: connection closed before enqueue'
}))
.mockImplementationOnce(async () => ({ state: 'admitted' as const }))
const body = hostTestMessage('admitted on retry')
const params = { envelope: envelope('agentSession.send', { body }), body }
await host.send(CALLER, params)
await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({
ok: true,
replayed: false,
value: {
submission: { dispatchState: 'pending', reason: null, resolvedAt: null }
}
})
expect(dispatch).toHaveBeenCalledTimes(2)
})
it('refuses to redeliver a retry for a turn the provider already owns', async () => {
await attach()
dispatch.mockImplementationOnce(async () => ({
state: 'unknown' as const,
reason: 'codex app-server started a turn it did not name in time'
}))
const body = hostTestMessage('a turn codex owns but did not name')
const params = { envelope: envelope('agentSession.send', { body }), body }
const first = await host.send(CALLER, params)
expect(first).toMatchObject({
ok: true,
value: { submission: { dispatchState: 'unknown' } }
})
// The turn is running; a second delivery would be a duplicate, so Retry
// replays the recorded outcome instead of re-sending.
await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({
ok: true,
value: { submission: { dispatchState: 'unknown' } }
})
expect(dispatch).toHaveBeenCalledTimes(1)
})
it('never reopens a submission the provider already proved delivered', async () => {
await attach()
dispatch.mockImplementationOnce(async () => accepted())
const body = hostTestMessage('settled for good')
const params = { envelope: envelope('agentSession.send', { body }), body }
await host.send(CALLER, params)
const journal = (
host as unknown as { sessions: Map<string, { journal: AgentSessionJournal }> }
).sessions.get(SESSION)!.journal
const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 1
// Every later signal that could assert doubt: the attach sweep, and a
// direct unknown resolution. Neither may unsettle an accepted answer.
await journal.markPendingSubmissionsUnknown(fence)
await journal.resolveDispatch({
clientMessageId: params.envelope.clientOperationId,
state: 'unknown',
reason: 'provider_write_failed: late transport error',
fence,
recovered: true
})
expect(journal.submissions()).toMatchObject([{ dispatchState: 'accepted', reason: null }])
expect(journal.receiptFor(params.envelope.clientOperationId)).not.toBeNull()
})
it('leaves an admitted send pending and writes no dispatch row', async () => {
await attach()
dispatch.mockImplementationOnce(async () => ({ state: 'admitted' as const }))
const body = hostTestMessage('queued behind a running turn')
const params = { envelope: envelope('agentSession.send', { body }), body }
await expect(host.send(CALLER, params)).resolves.toMatchObject({
ok: true,
value: { submission: { dispatchState: 'pending', reason: null, resolvedAt: null } }
})
const journal = (
host as unknown as { sessions: Map<string, { journal: AgentSessionJournal }> }
).sessions.get(SESSION)!.journal
expect(journal.pendingSubmissions()).toHaveLength(1)
})
it('refuses to redeliver an admitted send a host restart left unanswered', async () => {
await attach()
dispatch.mockImplementationOnce(async () => ({ state: 'admitted' as const }))
const body = hostTestMessage('written, never acknowledged')
const params = { envelope: envelope('agentSession.send', { body }), body }
await host.send(CALLER, params)
const journal = (
host as unknown as { sessions: Map<string, { journal: AgentSessionJournal }> }
).sessions.get(SESSION)!.journal
await journal.markPendingSubmissionsUnknown(store.getRecord(SESSION)?.lease.runtimeFence ?? 1)
expect(journal.submissions()).toMatchObject([
{ dispatchState: 'unknown', reason: 'host_restarted_before_acknowledgement' }
])
// The frame was already written to the dead child's stdin, and Claude resumes
// the same provider session by id, so the restart ends the wait without
// proving non-delivery. Re-typing costs a message; redelivering costs a
// duplicate in the model's conversation.
await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({
ok: true,
value: { submission: { dispatchState: 'unknown' } }
})
expect(dispatch).toHaveBeenCalledTimes(1)
expect(journal.submissions()).toHaveLength(1)
})
it('refuses to redeliver an admitted send whose child exited first', async () => {
await attach()
dispatch.mockImplementationOnce(async () => ({ state: 'admitted' as const }))
const body = hostTestMessage('written, then the child died')
const params = { envelope: envelope('agentSession.send', { body }), body }
await host.send(CALLER, params)
const journal = (
host as unknown as { sessions: Map<string, { journal: AgentSessionJournal }> }
).sessions.get(SESSION)!.journal
await journal.markPendingSubmissionsUnknown(
store.getRecord(SESSION)?.lease.runtimeFence ?? 1,
'provider_exited_before_acknowledgement'
)
await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({
ok: true,
value: { submission: { dispatchState: 'unknown' } }
})
expect(dispatch).toHaveBeenCalledTimes(1)
})
it('advances an explicit retry after a ledger-unknown send is reconciled in the journal', async () => {
await attach()
const journal = (
host as unknown as { sessions: Map<string, { journal: AgentSessionJournal }> }
).sessions.get(SESSION)!.journal
vi.spyOn(journal, 'resolveDispatch').mockRejectedValueOnce(new Error('journal resolve failed'))
const body = hostTestMessage('possibly delivered before persistence failed')
const params = { envelope: envelope('agentSession.send', { body }), body }
await expect(host.send(CALLER, params)).rejects.toThrow('journal resolve failed')
expect(journal.submissions()).toMatchObject([
{ clientMessageId: params.envelope.clientOperationId, dispatchState: 'unknown' }
])
expect(
store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId)
?.outcome
).toEqual({ status: 'unknown' })
expect(dispatch).toHaveBeenCalledTimes(1)
await journal.markPendingSubmissionsUnknown(store.getRecord(SESSION)?.lease.runtimeFence ?? 1)
await expect(host.send(CALLER, params)).resolves.toMatchObject({
ok: false,
refusal: { code: 'agent_session_operation_unknown' }
})
expect(dispatch).toHaveBeenCalledTimes(1)
// The adapter took the message before the journal write failed, so the
// provider may already have it: an explicit retry replays, never redelivers.
await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({
ok: true,
value: { submission: { dispatchState: 'unknown' } }
})
expect(dispatch).toHaveBeenCalledTimes(1)
expect(journal.submissions()).toHaveLength(1)
})
it('refuses a stale fence and hands back the current one', async () => {
const record = await attach()
const body = hostTestMessage('add a retry')
const result = await host.send(CALLER, {
envelope: envelope(
'agentSession.send',
{ body },
{ expectedRuntimeFence: (record?.lease.runtimeFence ?? 1) + 5 }
),
body
})
expect(result).toMatchObject({
ok: false,
refusal: { code: 'agent_session_checkpoint_stale', currentFence: record?.lease.runtimeFence }
})
})
it('does not let a refused call leave a ledger row that replays past the fence', async () => {
const record = await attach()
const body = hostTestMessage('add a retry')
const params = {
envelope: envelope(
'agentSession.send',
{ body },
{ expectedRuntimeFence: (record?.lease.runtimeFence ?? 1) + 5 }
),
body
}
await host.send(CALLER, params)
expect(await host.send(CALLER, params)).toMatchObject({
ok: false,
refusal: { code: 'agent_session_checkpoint_stale' }
})
expect(dispatch).not.toHaveBeenCalled()
})
it('refuses any mutation against a session this host has not attached', async () => {
const body = hostTestMessage('add a retry')
expect(
await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
).toMatchObject({ ok: false, refusal: { code: 'agent_session_ownership_unknown' } })
})
})
@@ -310,16 +310,18 @@ describe('settled attach retry', () => {
})
})
it('restores an unknown submission without redispatch before a distinct send', async () => {
it('settles a submission the host restart left pending, and never redelivers it', async () => {
expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true)
dispatch.mockRejectedValueOnce(new Error('socket closed'))
const body = hostTestMessage('possibly delivered')
// Admitted: written to the child, acknowledgement still outstanding. The
// restart below is the process fact that ends the wait, not a stopwatch.
dispatch.mockImplementationOnce(async () => ({ state: 'admitted' as const }))
const body = hostTestMessage('written before the host died')
const unknownParams = {
envelope: envelope('agentSession.send', { body }),
body
}
const first = await host.send(CALLER, unknownParams)
expect(first).toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } })
expect(first).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } })
await host.flushAllStreamedEvents()
store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' })
@@ -360,6 +362,8 @@ describe('settled attach retry', () => {
)?.dispatchState
).toBe('unknown')
// A restart ends the wait without proving the dead child never took the
// frame, so even an explicit retry replays rather than sending a second copy.
const explicitRetry = await host.send(CALLER, {
...unknownParams,
envelope: {
@@ -370,9 +374,9 @@ describe('settled attach retry', () => {
})
expect(explicitRetry).toMatchObject({
ok: true,
value: { submission: { dispatchState: 'accepted' } }
value: { submission: { dispatchState: 'unknown' } }
})
expect(dispatch).toHaveBeenCalledTimes(3)
expect(dispatch).toHaveBeenCalledTimes(2)
})
it('records proven acquisition cleanup as durable death evidence', async () => {
@@ -193,6 +193,28 @@ describe('a chat that closes', () => {
expect(closeSession).not.toHaveBeenCalled()
expect(host.hasSession(SESSION)).toBe(true)
})
it('releases a compatibility wait when the session is evicted', async () => {
await attach()
dispatch.mockResolvedValueOnce({ state: 'admitted' })
const body = hostTestMessage('pending until close')
const result = await host.send(CALLER, {
envelope: envelope('agentSession.send', { body }),
body
})
expect(result).toMatchObject({
ok: true,
value: { submission: { dispatchState: 'pending' } }
})
if (!result.ok) {
throw new Error('send was refused')
}
const settlement = host.waitForSendSettlement(SESSION, result.value.clientMessageId)
await host.close(SESSION)
await expect(settlement).resolves.toBeUndefined()
})
})
describe('a session with a turn in flight', () => {
@@ -274,6 +296,38 @@ describe('a session evicted and opened again', () => {
})
describe('an unexpected provider exit', () => {
it('publishes terminal settlement to a waiting older client', async () => {
await attach()
dispatch.mockResolvedValueOnce({ state: 'admitted' })
const body = hostTestMessage('pending until provider exit')
const result = await host.send(CALLER, {
envelope: envelope('agentSession.send', { body }),
body
})
expect(result).toMatchObject({
ok: true,
value: { submission: { dispatchState: 'pending' } }
})
if (!result.ok) {
throw new Error('send was refused')
}
const settlement = host.waitForSendSettlement(SESSION, result.value.clientMessageId)
const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0
await host.handleAdapterEvent({
type: 'ended',
sessionId: SESSION,
reason: 'provider exited',
cause: 'unexpected-exit',
fence: exitedFence,
acquisitionGeneration: 'generation-1'
})
await expect(settlement).resolves.toMatchObject({
value: { submission: { dispatchState: 'unknown' } }
})
})
it('turns a journal sink failure into observed-exit settlement and lease release', async () => {
await attach()
const session = (
@@ -6,12 +6,20 @@
// row the next attach settles as `unknown`, whereas the reverse would lose a
// turn the provider already accepted.
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
import type {
AgentJournalMessageItem,
AgentJournalSubmission
} from '../../../shared/agent-session-journal-types'
import type {
AgentSessionCancelResult,
AgentSessionSendResult,
AgentSessionWireRefusal
} from '../../../shared/agent-session-wire'
import {
DISPATCH_DOUBT_PERSISTENCE_FAILED,
DISPATCH_DOUBT_RETRY_IN_PROGRESS,
dispatchDoubtProvesUndelivered
} from '../agent-session-journal/journal-dispatch-doubt-reasons'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import type {
AgentSessionDispatchOutcome,
@@ -73,6 +81,16 @@ async function appendStatus(
ctx.publish()
}
/**
* Whether a user's Retry may put this message on the wire again: only where the
* recorded doubt proves the frame never reached a provider. Everything else
* replays the recorded outcome instead — one message reached the model five
* times through this path. Orca never re-sends on its own either way.
*/
function retryWouldRedeliver(existing: AgentJournalSubmission | undefined): boolean {
return existing?.dispatchState === 'unknown' && dispatchDoubtProvesUndelivered(existing.reason)
}
export async function performSend(
ctx: AgentSessionTurnContext,
input: {
@@ -88,13 +106,14 @@ export async function performSend(
if (existing && existing.payloadFingerprint !== input.payloadFingerprint) {
return invalid(`Message id ${input.clientMessageId} was already used for another send.`)
}
if (existing && !(input.retryUnknown && existing.dispatchState === 'unknown')) {
const redeliver = input.retryUnknown === true && retryWouldRedeliver(existing)
if (existing && !redeliver) {
return {
ok: true,
value: { clientMessageId: input.clientMessageId, submission: existing }
}
}
if (!(input.retryUnknown && existing?.dispatchState === 'unknown')) {
if (!redeliver) {
await ctx.journal.appendSubmission({ ...input, fence: ctx.fence })
ctx.publish()
} else {
@@ -102,13 +121,33 @@ export async function performSend(
await ctx.journal.resolveDispatch({
clientMessageId: input.clientMessageId,
state: 'unknown',
reason: 'dispatch_retry_in_progress',
reason: DISPATCH_DOUBT_RETRY_IN_PROGRESS,
fence: ctx.fence
})
ctx.publish()
}
const outcome = await dispatchSafely(ctx, input.clientMessageId, input.body)
// A first admission needs no dispatch row: the submission is already pending.
// A retry must durably clear the old doubt so clients do not mistake a
// successful re-admission for a refused redelivery.
if (outcome.state === 'admitted') {
if (redeliver) {
await ctx.journal.resolveDispatch({
clientMessageId: input.clientMessageId,
state: 'pending',
fence: ctx.fence
})
}
ctx.publish()
return {
ok: true,
value: {
clientMessageId: input.clientMessageId,
submission: requireSubmission(ctx, input.clientMessageId)
}
}
}
try {
await ctx.journal.resolveDispatch(
outcome.state === 'accepted'
@@ -132,7 +171,7 @@ export async function performSend(
await ctx.journal.resolveDispatch({
clientMessageId: input.clientMessageId,
state: 'unknown',
reason: 'dispatch_result_persistence_failed',
reason: DISPATCH_DOUBT_PERSISTENCE_FAILED,
fence: ctx.fence
})
} catch {
@@ -142,14 +181,26 @@ export async function performSend(
throw error
}
ctx.publish()
return {
ok: true,
value: {
clientMessageId: input.clientMessageId,
submission: requireSubmission(ctx, input.clientMessageId)
}
}
}
function requireSubmission(
ctx: AgentSessionTurnContext,
clientMessageId: string
): AgentJournalSubmission {
const submission = ctx.journal
.submissions()
.find((entry) => entry.clientMessageId === input.clientMessageId)
.find((entry) => entry.clientMessageId === clientMessageId)
if (!submission) {
throw new Error('agent_session_submission_lost')
}
return { ok: true, value: { clientMessageId: input.clientMessageId, submission } }
return submission
}
export async function performCancel(
@@ -235,6 +235,58 @@ describe('provider-exit recovery tickets', () => {
})
})
it('settles a submission the dead child never acknowledged', async () => {
const markPendingSubmissionsUnknown = vi.fn(async () => ['client-1'])
const session = {
hasProviderChild: true,
fence: 7,
acquisitionGeneration: GENERATION,
journal: {
snapshot: () => ({ items: [] }),
appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })),
markPendingSubmissionsUnknown
}
} as unknown as StructuredAgentSessionHostSession
await settleUnexpectedStructuredAgentSessionExit(
{
store: {
getRecord: () => ({
lease: {
handoffStage: null,
runtimeFence: 7,
runtimeKind: 'native',
claimStatus: 'live',
ownerProcess: 'provider',
reservedSpawnToken: null,
processlessAt: null
}
}),
transitionHandoff: async () => ({ lease: { runtimeFence: 8 } })
},
sessions: new Map([[SESSION, session]]),
flushLifecycle: async () => ({ ok: true }),
publishFence: vi.fn(),
hasResumeCapableHolder: () => true,
serialize: async (_sessionId, task: () => Promise<unknown>) => task(),
now: () => 1
} as never,
{
type: 'ended',
sessionId: SESSION,
reason: 'provider exited',
cause: 'unexpected-exit',
fence: 7,
acquisitionGeneration: GENERATION
}
)
expect(markPendingSubmissionsUnknown).toHaveBeenCalledWith(
7,
'provider_exited_before_acknowledgement'
)
})
it('does not release or reacquire while terminal settlement retry is still failing', async () => {
const session = {
hasProviderChild: true,
@@ -12,7 +12,10 @@ import {
type StructuredAgentSessionStatusSubscriber
} from '../../../native-chat/agent-session-wire/structured-agent-session-status-feed'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import {
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
import type { RpcRequest, RpcResponse } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session'
@@ -141,7 +144,26 @@ export function hostStub(): StructuredAgentSessionHost {
}
})),
rewind: vi.fn(async () => ({ ok: true, value: { itemId: 'chosen', epoch: 'next' } })),
send: vi.fn(async () => ({ ok: true, replayed: false })),
send: vi.fn(async () => ({
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-a', sequence: 1 },
value: {
clientMessageId: OPERATION,
submission: {
clientMessageId: OPERATION,
fence: 1,
payloadFingerprint: FINGERPRINT,
dispatchState: 'accepted',
providerItemId: 'provider-1',
reason: null,
submittedAt: 1,
resolvedAt: 2
}
}
})),
waitForSendSettlement: vi.fn(),
cancel: vi.fn(async () => ({ ok: true, replayed: false })),
close: vi.fn(async () => undefined),
revealSession: vi.fn(async () => ({
@@ -237,6 +259,7 @@ export async function call(
clientId?: string
clientKind?: 'mobile' | 'runtime'
clientCapabilities?: string[]
signal?: AbortSignal
},
runtimeOverrides: Record<string, unknown> = {}
): Promise<RpcResponse> {
@@ -255,11 +278,17 @@ export async function call(
export const STRUCTURED_CLIENT = {
clientKind: 'runtime' as const,
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
clientCapabilities: [
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY
]
}
export const STRUCTURED_MOBILE_CLIENT = {
clientKind: 'mobile' as const,
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
clientCapabilities: [
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY
]
}
/** Every suite wants the same lifecycle: a fresh stub per test, no host left installed. */
@@ -0,0 +1,26 @@
import { AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
import type { RpcContext } from '../core'
import { requireStructuredHost, structuredCallerFor } from './structured-agent-session-gate'
export async function sendStructuredAgentSessionForClient(
params: Parameters<StructuredAgentSessionHost['send']>[1],
context: RpcContext
) {
const host = requireStructuredHost(context)
const result = await host.send(structuredCallerFor(context), params)
if (
!result.ok ||
result.value.submission.dispatchState !== 'pending' ||
context.clientKind === undefined ||
context.clientCapabilities?.includes(AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY)
) {
return result
}
const settled = await host.waitForSendSettlement(
params.envelope.sessionId,
result.value.clientMessageId,
context.signal
)
return settled ? { ...result, ...settled } : result
}
@@ -4,6 +4,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import {
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
RUNTIME_CAPABILITIES,
RUNTIME_PROTOCOL_VERSION,
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
@@ -146,6 +147,7 @@ describe('capability gating', () => {
it('advertises the capability without bumping the protocol version', () => {
expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)
expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY)
expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY)
expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY)
// Additive methods do not break an old client; bumping would strand every
@@ -207,6 +209,124 @@ describe('capability gating', () => {
expect(hostCalls.send).toHaveBeenCalledTimes(1)
})
it('returns a settlement to older structured clients when observed within the window', async () => {
const pendingSubmission = {
clientMessageId: 'client-1',
fence: 1,
payloadFingerprint: 'fingerprint',
dispatchState: 'pending' as const,
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null
}
hostCalls.send.mockResolvedValueOnce({
ok: true,
replayed: true,
fence: 7,
cursor: { epoch: 'epoch-a', sequence: 1 },
value: { clientMessageId: 'client-1', submission: pendingSubmission }
})
hostCalls.waitForSendSettlement.mockResolvedValueOnce({
cursor: { epoch: 'epoch-a', sequence: 2 },
value: {
clientMessageId: 'client-1',
submission: {
...pendingSubmission,
dispatchState: 'accepted',
providerItemId: 'provider-1',
resolvedAt: 2
}
}
})
const controller = new AbortController()
const response = await call('agentSession.send', sendParams(), {
clientKind: 'runtime',
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY],
signal: controller.signal
})
expect(hostCalls.waitForSendSettlement).toHaveBeenCalledWith(
SESSION,
'client-1',
controller.signal
)
expect(response).toMatchObject({
ok: true,
result: {
ok: true,
replayed: true,
fence: 7,
cursor: { sequence: 2 },
value: { submission: { dispatchState: 'accepted' } }
}
})
})
it('returns durable pending when an older-client settlement observer cannot be retained', async () => {
hostCalls.send.mockResolvedValueOnce({
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-a', sequence: 1 },
value: {
clientMessageId: 'client-1',
submission: {
clientMessageId: 'client-1',
fence: 1,
payloadFingerprint: 'fingerprint',
dispatchState: 'pending',
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null
}
}
})
hostCalls.waitForSendSettlement.mockResolvedValueOnce(undefined)
const response = await call('agentSession.send', sendParams(), {
clientKind: 'runtime',
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
})
expect(response).toMatchObject({
ok: true,
result: { value: { submission: { dispatchState: 'pending' } } }
})
})
it('returns durable pending immediately to clients that understand admission', async () => {
hostCalls.send.mockResolvedValueOnce({
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-a', sequence: 1 },
value: {
clientMessageId: 'client-1',
submission: {
clientMessageId: 'client-1',
fence: 1,
payloadFingerprint: 'fingerprint',
dispatchState: 'pending',
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null
}
}
})
const response = await call('agentSession.send', sendParams(), STRUCTURED_CLIENT)
expect(hostCalls.waitForSendSettlement).not.toHaveBeenCalled()
expect(response).toMatchObject({
ok: true,
result: { value: { submission: { dispatchState: 'pending' } } }
})
})
it('requires the host structured-chat setting for mobile clients', async () => {
const response = await call('agentSession.send', sendParams(), STRUCTURED_MOBILE_CLIENT, {
getClientSettings: () => ({ experimentalStructuredNativeChat: false })
@@ -60,6 +60,7 @@ import {
SubscribeParams,
UnsubscribeParams
} from './structured-agent-session-schemas'
import { sendStructuredAgentSessionForClient } from './structured-agent-session-send-compatibility'
/**
* The attach-shaped entries take the location from the client instead of resolving it from a
@@ -194,7 +195,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'agentSession.send',
params: SendParams,
handler: async (params, ctx) => requireHost(ctx).send(callerFor(ctx), params)
handler: sendStructuredAgentSessionForClient
}),
defineMethod({
// Stopping a turn, so it stays available after admission is revoked: see the gate's rule.
@@ -138,12 +138,17 @@ export function NativeChatStructuredSession(
}
]
: [])
// Only the head of the outbox is ever dispatched, so it is the only entry a
// Retry can act on and the only one whose state can be holding the queue.
// Scanning past it named a message the user was not looking at and re-sent
// one from earlier in the session while their newest sat behind it.
const outboxHead = controller.outbox[0] ?? null
const retryableOutboxEntry =
controller.outbox.find((entry) => entry.state === 'unconfirmed') ??
controller.outbox.find(
(entry) => entry.clientMessageId === controller.blockedClientMessageId
) ??
null
outboxHead &&
(outboxHead.state === 'unconfirmed' ||
outboxHead.clientMessageId === controller.blockedClientMessageId)
? outboxHead
: null
const structuredTransport = useMemo(
() => ({
send: (text: string, attachments: readonly { id: string; path: string }[]): boolean =>
@@ -0,0 +1,619 @@
// 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.
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, 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'
import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire'
import type { NativeChatQuestionCardProps } from './NativeChatQuestionCard'
const mocks = vi.hoisted(() => ({
call: vi.fn(),
fileLinkClick: vi.fn(),
mode: 'static' as 'static' | 'outbox',
messageListProps: null as null | {
allowFileUriLinks?: boolean
onLinkClick?: (...args: unknown[]) => void
showTurnStatus?: boolean
runtimeContext?: unknown
},
composerProps: null as null | {
structuredTransport?: Record<string, unknown>
isWorking?: boolean
},
questionCardProps: null as NativeChatQuestionCardProps | null,
promptItems: [] as AgentJournalRenderItem[],
respond: vi.fn(),
handlePasteEvent: vi.fn(),
pasteFromClipboard: vi.fn(),
submissions: [] as unknown[],
monitoringBackgroundTasks: false,
supportsBackgroundTaskStop: false,
supportsBackgroundTaskStopAll: true,
backgroundTasks: [] as AgentSessionBackgroundTask[],
stopBackgroundTask: vi.fn()
}))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: mocks.call
}))
vi.mock('./use-structured-agent-session', async () => {
const { useStructuredAgentSessionOutbox } = await import('./use-structured-agent-session-outbox')
return {
useStructuredAgentSession: (props: {
sessionId: string
target: { kind: 'local' } | { kind: 'environment'; environmentId: string }
}) => {
const outbox = useStructuredAgentSessionOutbox({
sessionId: props.sessionId,
target: props.target,
fence: 1,
submissions: mocks.submissions as never
})
return {
messages:
mocks.mode === 'outbox'
? []
: [
{
id: 'message-1',
role: 'assistant',
source: 'transcript',
timestamp: 1,
blocks: [{ type: 'text', text: '[file](file:///repo/src/main.ts)' }]
}
],
status: 'ready' as const,
error: outbox.error,
hasOlder: false,
loadingOlder: false,
loadOlder: vi.fn(),
prompts: mocks.promptItems,
outbox: outbox.outbox,
blockedClientMessageId: outbox.blockedClientMessageId,
send: outbox.send,
retry: outbox.retry,
isWorking: false,
isMonitoringBackgroundTasks: mocks.monitoringBackgroundTasks,
supportsBackgroundTaskStop: mocks.supportsBackgroundTaskStop,
supportsBackgroundTaskStopAll: mocks.supportsBackgroundTaskStopAll,
backgroundTasks: mocks.backgroundTasks,
turnId: null,
cancel: vi.fn(),
stopBackgroundTask: (taskId?: string) => mocks.stopBackgroundTask(props.sessionId, taskId),
respond: mocks.respond,
optionSnapshot: [
{
id: 'model',
label: 'Model',
category: 'model',
kind: {
type: 'select',
currentValue: 'gpt-live',
choices: [{ value: 'gpt-live', label: 'GPT Live' }]
},
valueSource: 'reported',
settable: true
}
],
optionSurface: {
getSnapshot: () => [],
setOption: vi.fn(),
invokeAction: vi.fn(),
subscribe: () => () => {}
},
setStructuredOption: vi.fn()
}
}
}
})
vi.mock('./use-native-chat-font-scale', () => ({
useNativeChatFontScale: () => ({ scale: 1 })
}))
vi.mock('./use-native-chat-file-link-context', () => ({
useNativeChatFileLinkContext: () => ({
worktreeId: 'wt-1',
worktreePath: '/repo',
runtimeEnvironmentId: null
})
}))
vi.mock('./use-native-chat-file-link-click', () => ({
useNativeChatFileLinkClick: (context: unknown) => (context ? mocks.fileLinkClick : undefined)
}))
vi.mock('./NativeChatMessageList', () => ({
NativeChatMessageList: (props: typeof mocks.messageListProps) => {
mocks.messageListProps = props
return <div data-testid="message-list" />
}
}))
vi.mock('./NativeChatComposer', () => ({
NativeChatComposer: forwardRef((props: typeof mocks.composerProps, ref) => {
mocks.composerProps = props
const fieldRef = useRef<HTMLTextAreaElement>(null)
useImperativeHandle(ref, () => ({
// Match the real composer so focus ownership is observable in this split suite.
focus: () => {
fieldRef.current?.focus()
return true
},
insertTypedText: () => true,
handlePasteEvent: mocks.handlePasteEvent,
pasteFromClipboard: mocks.pasteFromClipboard
}))
return <textarea ref={fieldRef} data-testid="structured-composer" />
})
}))
vi.mock('./NativeChatEmptyState', () => ({ NativeChatEmptyState: () => null }))
vi.mock('./NativeChatApprovalCard', () => ({ NativeChatApprovalCard: () => null }))
vi.mock('./NativeChatQuestionCard', () => ({
NativeChatQuestionCard: (props: NativeChatQuestionCardProps) => {
mocks.questionCardProps = props
return null
}
}))
import { NativeChatStructuredSession } from './NativeChatStructuredSession'
describe('NativeChatStructuredSession delivery', () => {
afterEach(() => {
cleanup()
mocks.call.mockReset()
mocks.mode = 'static'
mocks.messageListProps = null
mocks.composerProps = null
mocks.questionCardProps = null
mocks.promptItems = []
mocks.respond.mockReset()
mocks.handlePasteEvent.mockReset()
mocks.pasteFromClipboard.mockReset()
mocks.submissions = []
mocks.monitoringBackgroundTasks = false
mocks.supportsBackgroundTaskStop = false
mocks.supportsBackgroundTaskStopAll = true
mocks.stopBackgroundTask.mockReset()
mocks.backgroundTasks = []
})
function seededEntry(
sessionId: string,
clientMessageId: string,
text: string,
state: 'queued' | 'unconfirmed'
) {
return {
clientMessageId,
sessionId,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] },
previewUris: [],
state,
queuedAt: clientMessageId === 'op-head' ? 1 : 2,
lastAttemptAt: null,
// Already force-retried once, so the automatic probe leaves the head alone
// and only the user's Retry moves it.
retryAfterUnknownSubmittedAt: -1
}
}
function seedOutbox(sessionId: string, entries: unknown[]): void {
localStorage.setItem(
`orca:desktopStructuredAgentSessionOutbox:v1:${encodeURIComponent(sessionId)}`,
JSON.stringify(entries)
)
}
it('retries an unconfirmed transport send and clears the delivery notice', async () => {
mocks.mode = 'outbox'
mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValueOnce({
ok: true,
value: {
submission: {
clientMessageId: 'client-1',
dispatchState: 'accepted'
}
}
})
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-1"
sessionId="session-1"
target={{ kind: 'local' }}
agent="codex"
/>
)
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
expect(send?.('hello', [])).toBe(true)
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy())
fireEvent.click(screen.getByRole('button', { name: /Retry/ }))
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
await waitFor(() => expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull())
})
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('raises no delivery notice for a stuck message behind a healthy head', async () => {
mocks.mode = 'outbox'
mocks.submissions = []
// Admitted: written and awaiting the provider, so the head is not in doubt
// and a Retry could not act on the entry queued behind it anyway.
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(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()
expect(screen.queryByRole('button', { name: /Retry/ })).toBeNull()
})
it('resends a transport-unconfirmed head so later messages are not wedged', async () => {
mocks.mode = 'outbox'
mocks.submissions = []
mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({
ok: true,
value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } }
})
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-wedge"
sessionId="session-wedge"
target={{ kind: 'local' }}
agent="codex"
/>
)
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
expect(send?.('first', [])).toBe(true)
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy())
expect(send?.('second', [])).toBe(true)
// The head is probed automatically, clears, and the queue drains.
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(3), { timeout: 10000 })
await waitFor(() => expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull())
}, 20000)
it('probes without retryUnknown so the host cannot redispatch', async () => {
mocks.mode = 'outbox'
mocks.submissions = []
mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({
ok: true,
value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } }
})
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-probe-flag"
sessionId="session-probe-flag"
target={{ kind: 'local' }}
agent="codex"
/>
)
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
expect(send?.('first', [])).toBe(true)
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2), { timeout: 10000 })
const first = mocks.call.mock.calls[0]?.[2] as Record<string, unknown>
const probe = mocks.call.mock.calls[1]?.[2] as Record<string, unknown>
expect(probe.retryUnknown).toBeUndefined()
// Same operation id: both dedupe layers key off it.
expect((probe.envelope as { clientOperationId: string }).clientOperationId).toBe(
(first.envelope as { clientOperationId: string }).clientOperationId
)
}, 20000)
it('parks a host-confirmed unknown instead of probing it', async () => {
mocks.mode = 'outbox'
mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({
ok: true,
value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } }
})
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-parked"
sessionId="session-parked"
target={{ kind: 'local' }}
agent="codex"
/>
)
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
expect(send?.('first', [])).toBe(true)
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
const sent = mocks.call.mock.calls[0]?.[2] as { envelope: { clientOperationId: string } }
// The host now reports it as an unresolved unknown: redispatch is the user's call.
mocks.submissions = [
{
clientMessageId: sent.envelope.clientOperationId,
fence: 1,
payloadFingerprint: 'fp',
dispatchState: 'unknown',
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null
}
]
// Queue a second message purely to re-render so the effect observes the
// new submissions; it must stay wedged behind the parked head.
await act(async () => {
send?.('second', [])
})
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 3000))
})
expect(mocks.call).toHaveBeenCalledOnce()
}, 20000)
it('still probes while streaming batches rebuild the submissions array', async () => {
mocks.mode = 'outbox'
mocks.submissions = []
mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({
ok: true,
value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } }
})
const makeView = (): React.ReactElement => (
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-churn"
sessionId="session-churn"
target={{ kind: 'local' }}
agent="codex"
/>
)
const { rerender } = render(makeView())
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
expect(send?.('first', [])).toBe(true)
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
// Each batch mints a fresh submissions array for an unrelated message. An
// array-identity dependency restarts the backoff on every one of these, so a
// stream that outlasts the delay would never let the probe fire.
for (let index = 0; index < 12; index += 1) {
mocks.submissions = [
{
clientMessageId: `other-${index}`,
fence: 1,
payloadFingerprint: 'fp',
dispatchState: 'accepted',
providerItemId: null,
reason: null,
submittedAt: index,
resolvedAt: index
}
]
await act(async () => {
rerender(makeView())
await new Promise((resolve) => setTimeout(resolve, 250))
})
}
// Asserted with no trailing grace period: the probe must have fired *during*
// the stream, not after it went quiet.
expect(mocks.call).toHaveBeenCalledTimes(2)
}, 20000)
it('restarts probe delay when the runtime target changes', async () => {
mocks.mode = 'outbox'
mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({
ok: true,
value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } }
})
const makeView = (
target: { kind: 'local' } | { kind: 'environment'; environmentId: string }
) => (
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-target-switch"
sessionId="session-target-switch"
target={target}
agent="codex"
/>
)
const { rerender } = render(makeView({ kind: 'local' }))
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
expect(send?.('first', [])).toBe(true)
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy())
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300))
})
rerender(makeView({ kind: 'environment', environmentId: 'env-1' }))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 600))
})
expect(mocks.call).toHaveBeenCalledOnce()
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 = []
// Both the original send and the user's explicit Retry fail at the transport.
mocks.call.mockRejectedValue(new Error('socket closed'))
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-forced"
sessionId="session-forced"
target={{ kind: 'local' }}
agent="codex"
/>
)
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
expect(send?.('first', [])).toBe(true)
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy())
// User force-retries: this request legitimately carries retryUnknown.
fireEvent.click(screen.getByRole('button', { name: /Retry/ }))
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
const forcedRequest = mocks.call.mock.calls[1]?.[2] as Record<string, unknown> | undefined
expect(forcedRequest?.retryUnknown).toBe(true)
// That retry also failed at the transport. The probe must NOT pick it up, or it
// would re-send retryUnknown automatically and redispatch to the agent.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 3000))
})
expect(mocks.call).toHaveBeenCalledTimes(2)
}, 20000)
it('does not hot-loop when the host answers pending', async () => {
mocks.mode = 'outbox'
mocks.call.mockResolvedValue({
ok: true,
value: { submission: { clientMessageId: 'client-1', dispatchState: 'pending' } }
})
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-pending"
sessionId="session-pending"
target={{ kind: 'local' }}
agent="codex"
/>
)
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
expect(send?.('first', [])).toBe(true)
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
// A pending row parks the entry under the backoff instead of re-dispatching
// immediately. Without that, this window is an unbounded back-to-back RPC flood.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 2500))
})
expect(mocks.call.mock.calls.length).toBeLessThanOrEqual(3)
}, 20000)
it('keeps probing past the old five-attempt budget', async () => {
mocks.mode = 'outbox'
mocks.call.mockRejectedValue(new Error('socket closed'))
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-budget"
sessionId="session-budget"
target={{ kind: 'local' }}
agent="codex"
/>
)
const send = mocks.composerProps?.structuredTransport?.send as
| ((text: string, attachments: readonly { id: string; path: string }[]) => boolean)
| undefined
expect(send?.('first', [])).toBe(true)
// Backoff is 1+2+4+8+16 = 31s for five probes, which was the old hard budget.
// Step past it; a seventh call proves the probe re-arms instead of giving up.
for (let step = 0; step < 12; step += 1) {
await act(async () => {
await vi.advanceTimersByTimeAsync(8_000)
})
}
expect(mocks.call.mock.calls.length).toBeGreaterThanOrEqual(7)
} finally {
vi.useRealTimers()
}
}, 30000)
})
@@ -21,10 +21,12 @@ const LOCAL_TARGET = { kind: 'local' } as const
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((next) => {
let reject!: (reason: unknown) => void
const promise = new Promise<T>((next, fail) => {
resolve = next
reject = fail
})
return { promise, resolve }
return { promise, reject, resolve }
}
function acceptedResult(fence: number) {
@@ -93,6 +95,28 @@ function unknownResultFor(clientMessageId: string, submittedAt: number) {
}
}
function pendingResultFor(clientMessageId: string, submittedAt: number) {
return {
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-1', sequence: submittedAt },
value: {
clientMessageId,
submission: {
clientMessageId,
fence: 1,
payloadFingerprint: 'fingerprint',
dispatchState: 'pending' as const,
providerItemId: null,
reason: null,
submittedAt,
resolvedAt: null
}
}
}
}
function refusedResult(code: AgentSessionWireRefusalCode) {
return { ok: false, refusal: { code, message: code } }
}
@@ -169,6 +193,246 @@ describe('useStructuredAgentSessionOutbox', () => {
}
)
it('leaves an admitted send dispatching, never unconfirmed', async () => {
mocks.call.mockImplementationOnce(async (_target, _method, params) => {
const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope
.clientOperationId
return pendingResultFor(clientMessageId, 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('queued behind a running turn')).toBe(true))
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1))
const id = result.current.outbox[0]!.clientMessageId
// Written and awaiting the provider's acknowledgement: no doubt, no banner.
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching'))
expect(result.current.error).toBeNull()
// A long-lived `pending` submission republished keeps it out of doubt.
rerender({
submissions: [pendingResultFor(id, 10).value.submission]
})
expect(result.current.outbox[0]?.state).toBe('dispatching')
expect(mocks.call).toHaveBeenCalledTimes(1)
// The provider's echo lands and settles it; the entry leaves the outbox.
rerender({
submissions: [
{ ...pendingResultFor(id, 10).value.submission, dispatchState: 'accepted' as const }
]
})
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
expect(result.current.error).toBeNull()
})
it('lets no transport error reopen a send the journal already settled', async () => {
// The RPC fails while the host has already accepted: the journal is the
// authority, so the entry leaves the outbox and no Retry is offered for it.
mocks.call.mockRejectedValue(new Error('socket closed'))
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('settled for good')).toBe(true))
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1))
const id = result.current.outbox[0]!.clientMessageId
await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed'))
expect(result.current.error).toBe('Message delivery is unconfirmed')
rerender({
submissions: [
{ ...pendingResultFor(id, 10).value.submission, dispatchState: 'accepted' as const }
]
})
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
expect(result.current.error).toBeNull()
})
it('ignores a transport failure after the journal already settled the send', async () => {
const inFlight = deferred<ReturnType<typeof acceptedResult>>()
mocks.call.mockReturnValueOnce(inFlight.promise)
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('settled before the RPC')).toBe(true))
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
const id = result.current.outbox[0]!.clientMessageId
rerender({
submissions: [
{ ...pendingResultFor(id, 10).value.submission, dispatchState: 'accepted' as const }
]
})
await waitFor(() => expect(result.current.outbox).toHaveLength(0))
await act(async () => inFlight.reject(new Error('socket closed')))
expect(result.current.outbox).toHaveLength(0)
expect(result.current.error).toBeNull()
})
it('ignores a transport failure after the host admitted the send', async () => {
const inFlight = deferred<ReturnType<typeof acceptedResult>>()
mocks.call.mockReturnValueOnce(inFlight.promise)
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('admitted before the RPC')).toBe(true))
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
const id = result.current.outbox[0]!.clientMessageId
rerender({ submissions: [pendingResultFor(id, 10).value.submission] })
expect(result.current.outbox[0]?.state).toBe('dispatching')
await act(async () => inFlight.reject(new Error('socket closed')))
expect(result.current.outbox[0]?.state).toBe('dispatching')
expect(result.current.error).toBeNull()
})
it('keeps a failed tail-save error when the admitted head is republished', async () => {
const inFlight = deferred<ReturnType<typeof acceptedResult>>()
mocks.call.mockReturnValueOnce(inFlight.promise)
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('admitted head')).toBe(true))
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
const id = result.current.outbox[0]!.clientMessageId
rerender({ submissions: [pendingResultFor(id, 10).value.submission] })
const setItem = vi.spyOn(localStorage, 'setItem').mockImplementationOnce(() => {
throw new Error('storage full')
})
act(() => expect(result.current.send('tail that cannot be saved')).toBe(false))
expect(result.current.error).toBe('Message could not be saved to the outbox')
rerender({ submissions: [{ ...pendingResultFor(id, 10).value.submission }] })
expect(result.current.error).toBe('Message could not be saved to the outbox')
setItem.mockRestore()
})
it('restores a persisted admitted send from host pending state', async () => {
mocks.call.mockImplementationOnce(async (_target, _method, params) => {
const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope
.clientOperationId
return pendingResultFor(clientMessageId, 10)
})
const first = renderHook(() =>
useStructuredAgentSessionOutbox({
sessionId: 'session-1',
target: LOCAL_TARGET,
fence: 1,
submissions: []
})
)
act(() => expect(first.result.current.send('still waiting behind a turn')).toBe(true))
await waitFor(() => expect(first.result.current.outbox[0]?.state).toBe('dispatching'))
const id = first.result.current.outbox[0]!.clientMessageId
first.unmount()
const restored = renderHook(() =>
useStructuredAgentSessionOutbox({
sessionId: 'session-1',
target: LOCAL_TARGET,
fence: 1,
submissions: [pendingResultFor(id, 10).value.submission]
})
)
await waitFor(() => expect(restored.result.current.outbox[0]?.state).toBe('dispatching'))
expect(restored.result.current.error).toBeNull()
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 Orca will not send 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.
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 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(
'Message delivery is unconfirmed and Orca will not send it again'
)
})
it('retains a send operation after a pending-admission refusal', async () => {
mocks.call
.mockResolvedValueOnce(refusedResult('agent_session_checkpoint_stale'))
@@ -6,13 +6,16 @@ import type {
} from '../../../../shared/agent-session-wire'
import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation'
import {
classifyStructuredAgentSessionSendFailure,
createStructuredAgentSessionOutboxEntry,
reconcileStructuredAgentSessionOutbox,
requeueStructuredAgentSessionSendRefusal,
structuredAgentSessionSendRequest,
type StructuredAgentSessionOutboxEntry
} from '../../../../shared/structured-agent-session-outbox'
import {
disposeStructuredAgentSessionSendFailure,
disposeStructuredAgentSessionSendResult,
type StructuredAgentSessionSendDisposition
} from '../../../../shared/structured-agent-session-send-disposition'
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import { readOutbox, writeOutbox } from './structured-agent-session-outbox-storage'
@@ -89,17 +92,47 @@ export function useStructuredAgentSessionOutbox(args: {
}, [fence, sessionId, target])
useEffect(() => {
const next = reconcileStructuredAgentSessionOutbox(outboxRef.current, submissions)
if (
next.some((entry, index) => entry !== outboxRef.current[index]) ||
next.length !== outboxRef.current.length
) {
const current = outboxRef.current
const headSubmission = submissions.find(
(submission) => submission.clientMessageId === current[0]?.clientMessageId
)
const hostOwnsHead =
headSubmission?.dispatchState === 'pending' || headSubmission?.dispatchState === 'accepted'
const hostSettledHeadError =
current[0]?.state === 'unconfirmed' ||
blockedIdRef.current === headSubmission?.clientMessageId
const next = reconcileStructuredAgentSessionOutbox(current, submissions)
if (next.some((entry, index) => entry !== current[index]) || next.length !== current.length) {
outboxRef.current = next
setOutbox(next)
writeOutbox(sessionId, next)
}
if (hostOwnsHead) {
if (dispatchingRef.current) {
dispatchGenerationRef.current += 1
dispatchingRef.current = false
}
if (blockedIdRef.current === headSubmission.clientMessageId) {
blockedIdRef.current = null
}
if (hostSettledHeadError) {
setError(null)
}
}
}, [sessionId, submissions])
// The one place that owns the refs, the React state and the storage write.
const applyDisposition = useCallback(
(disposition: StructuredAgentSessionSendDisposition): void => {
blockedIdRef.current = disposition.blockedClientMessageId
setError(disposition.error)
outboxRef.current = disposition.entries
setOutbox(disposition.entries)
writeOutbox(sessionId, disposition.entries)
},
[sessionId]
)
useEffect(() => {
const next = outbox[0]
if (
@@ -135,79 +168,36 @@ export function useStructuredAgentSessionOutbox(args: {
if (dispatchGenerationRef.current !== dispatchGeneration) {
return
}
if (!result.ok) {
setError(result.refusal.message)
const updated = outboxRef.current.map((entry) =>
entry.clientMessageId === next.clientMessageId
? requeueStructuredAgentSessionSendRefusal(
entry,
result.refusal.code,
structuredSessionOperationId
)
: entry
)
blockedIdRef.current = updated[0]?.clientMessageId ?? null
outboxRef.current = updated
setOutbox(updated)
writeOutbox(sessionId, updated)
return
}
const submission = result.value.submission
if (submission.dispatchState === 'rejected') {
blockedIdRef.current = next.clientMessageId
setError(submission.reason ?? 'Message was not accepted')
} else {
setError(null)
}
const updated =
submission.dispatchState === 'accepted'
? outboxRef.current.filter((entry) => entry.clientMessageId !== next.clientMessageId)
: outboxRef.current.map((entry) =>
entry.clientMessageId === next.clientMessageId
? {
...entry,
state:
submission.dispatchState === 'unknown' ||
submission.dispatchState === 'pending'
? ('unconfirmed' as const)
: ('queued' as const)
}
: entry
)
outboxRef.current = updated
setOutbox(updated)
writeOutbox(sessionId, updated)
applyDisposition(
disposeStructuredAgentSessionSendResult({
entries: outboxRef.current,
entry: next,
blockedClientMessageId: blockedIdRef.current,
result,
createOperationId: structuredSessionOperationId
})
)
})
.catch((caught) => {
if (dispatchGenerationRef.current !== dispatchGeneration) {
return
}
const failure = classifyStructuredAgentSessionSendFailure(caught, isDesktopDeliveryUnknown)
if (failure === 'failed') {
blockedIdRef.current = next.clientMessageId
}
const updated = outboxRef.current.map((entry) =>
entry.clientMessageId === next.clientMessageId
? {
...entry,
state:
failure === 'delivery-unknown' ? ('unconfirmed' as const) : ('queued' as const)
}
: entry
applyDisposition(
disposeStructuredAgentSessionSendFailure({
entries: outboxRef.current,
entry: next,
blockedClientMessageId: blockedIdRef.current,
cause: caught,
isDeliveryUnknown: isDesktopDeliveryUnknown
})
)
setError(
failure === 'delivery-unknown' ? 'Message delivery is unconfirmed' : String(caught)
)
outboxRef.current = updated
setOutbox(updated)
writeOutbox(sessionId, updated)
})
.finally(() => {
if (dispatchGenerationRef.current === dispatchGeneration) {
dispatchingRef.current = false
}
})
}, [fence, outbox, sessionId, target])
}, [applyDisposition, fence, outbox, sessionId, target])
// 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
@@ -0,0 +1,60 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { enqueueStructuredAgentSessionLaunchPrompt } from '@/components/native-chat/structured-agent-session-outbox-storage'
const mocks = vi.hoisted(() => ({ call: vi.fn() }))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: mocks.call
}))
import { settleStructuredAgentLaunchPrompt } from './structured-agent-session-launch-prompt'
describe('settleStructuredAgentLaunchPrompt', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue(
'11111111-1111-4111-8111-111111111111'
)
})
it('reports an admitted launch prompt delivered while retaining it for the provider echo', async () => {
const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this')
const onPromptDelivered = vi.fn()
mocks.call.mockResolvedValue({
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-1', sequence: 1 },
value: {
clientMessageId: stagedEntry!.clientMessageId,
submission: {
clientMessageId: stagedEntry!.clientMessageId,
fence: 1,
payloadFingerprint: 'fingerprint',
dispatchState: 'pending',
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null
}
}
})
await expect(
settleStructuredAgentLaunchPrompt({
launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }),
options: { prompt: 'review this', onPromptDelivered },
stagedEntry
})
).resolves.toEqual({ delivered: true, failureNotified: false })
expect(onPromptDelivered).toHaveBeenCalledOnce()
const persisted = JSON.parse(localStorage.getItem(localStorage.key(0)!) ?? '[]') as {
state: string
}[]
expect(persisted).toMatchObject([{ state: 'dispatching' }])
})
})
@@ -69,10 +69,15 @@ async function dispatchStructuredLaunchPrompt(
? null
: {
...current,
state: dispatchState === 'unknown' ? 'unconfirmed' : 'queued'
state:
dispatchState === 'unknown'
? 'unconfirmed'
: dispatchState === 'pending'
? 'dispatching'
: 'queued'
}
)
return dispatchState === 'accepted'
return dispatchState === 'accepted' || dispatchState === 'pending'
} catch {
mutateEntry(entry, (current) => ({ ...current, state: 'unconfirmed' }))
return false
+6
View File
@@ -138,6 +138,10 @@ export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY =
// receive their journal or drive their lifecycle. Mobile may receive a metadata-only placeholder;
// the host still refuses agentSession.* methods and destructive tab mutations without capability.
export const STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.v1' as const
// Why: older structured clients render durable pending replies as uncertain delivery. Capable
// clients skip the host's bounded best-effort settlement observation.
export const AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY =
'agent-session.pending-send-result.v1' as const
// Why: paired clients advertise Claude-structured support so the host can gate its agent-specific
// journal and lifecycle surfaces independently from Codex support.
export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY =
@@ -225,6 +229,7 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [
// host still requires the separate authenticated browser-client lease.
export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [
...NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES,
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY,
BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY,
// Why: only the renderer runs the retirement-proof ledger; CLI and mobile must keep full lists.
@@ -279,6 +284,7 @@ export const RUNTIME_CAPABILITIES = [
AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY,
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY,
@@ -94,6 +94,9 @@ export function reconcileStructuredAgentSessionOutbox(
if (submission?.dispatchState === 'accepted') {
return []
}
if (submission?.dispatchState === 'pending') {
return entry.state === 'dispatching' ? [entry] : [{ ...entry, state: 'dispatching' as const }]
}
if (
submission?.dispatchState === 'unknown' &&
entry.retryAfterUnknownSubmittedAt !== -1 &&
@@ -187,10 +187,9 @@ export function hasPersistedStructuredAgentSessionTurn(
* on that echo to call a session working leaves the whole gap reading idle in the chat and in
* every session list, so the send itself is the evidence.
*
* `unknown` still counts: it only means the ack budget elapsed, which happens on 30% of Claude
* sends whose turn then arrives anyway, and delivery confidence is a separate question from
* whether work is owed. A recovered `unknown` does not — that one outlived the host generation
* that sent it, so there is nothing still running to report.
* A live `unknown` still counts because an ambiguous adapter reply does not prove the provider
* stopped. A recovered `unknown` does not — it outlived the host generation that sent it, so
* there is nothing still running to report.
*/
export function hasUnansweredStructuredAgentSessionDispatch(
submissions: readonly AgentJournalSubmission[],
@@ -0,0 +1,138 @@
// 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 { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire'
import {
classifyStructuredAgentSessionSendFailure,
requeueStructuredAgentSessionSendRefusal,
type StructuredAgentSessionOutboxEntry
} from './structured-agent-session-outbox'
export type StructuredAgentSessionSendDisposition = {
entries: StructuredAgentSessionOutboxEntry[]
error: string | null
/** The entry the queue is stuck on, or null when nothing blocks it. Always the
* next value, never "unchanged": the caller assigns it verbatim. */
blockedClientMessageId: string | null
}
type SendDispositionInput = {
entries: readonly StructuredAgentSessionOutboxEntry[]
entry: StructuredAgentSessionOutboxEntry
blockedClientMessageId: string | null
}
function replaceEntryState(
input: SendDispositionInput,
state: StructuredAgentSessionOutboxEntry['state']
): StructuredAgentSessionOutboxEntry[] {
return input.entries.map((candidate) =>
candidate.clientMessageId === input.entry.clientMessageId ? { ...candidate, state } : candidate
)
}
function dropEntry(input: SendDispositionInput): StructuredAgentSessionOutboxEntry[] {
return input.entries.filter(
(candidate) => candidate.clientMessageId !== input.entry.clientMessageId
)
}
/**
* The user force-retried and got the same observation back, so the host will not
* put this message on the wire again — it cannot prove doing so would be a first
* delivery. 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.
*/
function refusedRedelivery(
entry: StructuredAgentSessionOutboxEntry,
submission: AgentSessionSendResult['submission']
): boolean {
return (
entry.retryAfterUnknownSubmittedAt !== null &&
submission.dispatchState === 'unknown' &&
submission.submittedAt === entry.retryAfterUnknownSubmittedAt
)
}
export function disposeStructuredAgentSessionSendResult(
input: SendDispositionInput & {
result: AgentSessionMutationResult<AgentSessionSendResult>
createOperationId: () => string
}
): StructuredAgentSessionSendDisposition {
const result = input.result
if (!result.ok) {
const entries = input.entries.map((candidate) =>
candidate.clientMessageId === input.entry.clientMessageId
? requeueStructuredAgentSessionSendRefusal(
candidate,
result.refusal.code,
input.createOperationId
)
: candidate
)
return {
entries,
error: result.refusal.message,
blockedClientMessageId: entries[0]?.clientMessageId ?? null
}
}
const submission = result.value.submission
if (refusedRedelivery(input.entry, submission)) {
return {
entries: dropEntry(input),
error: 'Message delivery is unconfirmed and Orca will not send it again',
blockedClientMessageId: input.blockedClientMessageId
}
}
if (submission.dispatchState === 'accepted') {
return {
entries: dropEntry(input),
error: null,
blockedClientMessageId: input.blockedClientMessageId
}
}
if (submission.dispatchState === 'rejected') {
return {
entries: replaceEntryState(input, 'queued'),
error: submission.reason ?? 'Message was not accepted',
blockedClientMessageId: input.entry.clientMessageId
}
}
// `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: the entry stays `dispatching` and the queue behind
// it keeps its order until the echo settles it.
return {
entries: replaceEntryState(
input,
submission.dispatchState === 'unknown' ? 'unconfirmed' : 'dispatching'
),
error: null,
blockedClientMessageId: input.blockedClientMessageId
}
}
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 {
entries: replaceEntryState(input, deliveryUnknown ? 'unconfirmed' : 'queued'),
error: deliveryUnknown ? 'Message delivery is unconfirmed' : String(input.cause),
blockedClientMessageId: deliveryUnknown
? input.blockedClientMessageId
: input.entry.clientMessageId
}
}
@@ -24,6 +24,7 @@ import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session
import { computeAgentSessionPayloadFingerprint } from '../../../src/shared/agent-session-mutation-envelope'
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
import {
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
AGENT_SESSION_REWIND_RUNTIME_CAPABILITY,
AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
@@ -460,6 +461,7 @@ describe('cross-version structured agent sessions', () => {
describe('a new client against an old host', () => {
it('registers the whole surface on the new build', () => {
expect(current.capabilities).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)
expect(current.capabilities).toContain(AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY)
expect(current.methodNames.filter((name) => name.startsWith('agentSession.'))).toHaveLength(
STRUCTURED_CALLS.length
)
@@ -23,7 +23,26 @@ export function structuredHostStub(
ok: true,
value: { command: 'compact', state: 'completed' }
})),
send: vi.fn(async () => ({ ok: true, replayed: false })),
send: vi.fn(async () => ({
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-a', sequence: 2 },
value: {
clientMessageId: 'client-1',
submission: {
clientMessageId: 'client-1',
fence: 1,
payloadFingerprint: 'fingerprint',
dispatchState: 'accepted',
providerItemId: 'provider-1',
reason: null,
submittedAt: 1,
resolvedAt: 2
}
}
})),
waitForSendSettlement: vi.fn(),
cancel: vi.fn(async () => ({ ok: true, replayed: false })),
rewind: vi.fn(async () => ({
ok: true,