mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 08:02:43 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/reject-unknown-model-at-worker-start
This commit is contained in:
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }] }
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+197
@@ -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)
|
||||
|
||||
+37
@@ -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)
|
||||
|
||||
+1
-1
@@ -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 () => {
|
||||
|
||||
+133
@@ -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' } })
|
||||
})
|
||||
})
|
||||
+10
-6
@@ -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 () => {
|
||||
|
||||
+54
@@ -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(
|
||||
|
||||
+52
@@ -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)
|
||||
})
|
||||
+266
-2
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user