Skip to content
Closed
2 changes: 1 addition & 1 deletion src/main/claude/claude-api-retry-row.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ async function statusRowsFor(frames: Record<string, unknown>[]) {
now: () => 1_700_000_000_000,
mintEpoch: () => 'epoch-1'
})
const deferred = createDeferredStructuredAgentSessionEventSink()
const deferred = createDeferredStructuredAgentSessionEventSink({ onFailure: () => {} })
deferred.bind({ journal, fence: 1, publish: vi.fn() })
const translator = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: '1' })
for (const frame of frames) {
Expand Down
2 changes: 1 addition & 1 deletion src/main/claude/claude-context-usage-restart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ async function openJournal(): Promise<AgentSessionJournal> {

/** One acquisition: a fresh sink and translator over the session's journal. */
function acquire(journal: AgentSessionJournal) {
const deferred = createDeferredStructuredAgentSessionEventSink()
const deferred = createDeferredStructuredAgentSessionEventSink({ onFailure: () => {} })
const translator = createClaudeJournalTranslator({ sink: deferred.sink, coalesceMs: 0 })
deferred.bind({ journal, fence: 1, publish: () => {} })
const answers: ((value: unknown) => void)[] = []
Expand Down
2 changes: 1 addition & 1 deletion src/main/claude/claude-context-usage-unloaded-turn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ async function openJournal(): Promise<AgentSessionJournal> {
}

function translate(journal: AgentSessionJournal) {
const deferred = createDeferredStructuredAgentSessionEventSink()
const deferred = createDeferredStructuredAgentSessionEventSink({ onFailure: () => {} })
const translator = createClaudeJournalTranslator({ sink: deferred.sink, coalesceMs: 0 })
deferred.bind({ journal, fence: 1, publish: () => {} })
const settle = async (): Promise<void> => {
Expand Down
2 changes: 1 addition & 1 deletion src/main/claude/claude-stream-json-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ describe('Claude stream-json connection', () => {
now: () => 1_700_000_000_000,
mintEpoch: () => 'epoch-1'
})
const deferred = createDeferredStructuredAgentSessionEventSink()
const deferred = createDeferredStructuredAgentSessionEventSink({ onFailure: () => {} })
deferred.bind({ journal, fence: 1, publish: vi.fn() })
const translator = createClaudeJournalTranslator({ sink: deferred.sink })
let settled = false
Expand Down
4 changes: 3 additions & 1 deletion src/main/claude/claude-structured-command-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import type { ClaudeSession } from './claude-structured-session-state'
* makes the command's turn the open one before the send, and that same result ends it. */
export async function dispatchClaudeCommand(
session: ClaudeSession,
command: StructuredAgentSessionCommandRun
command: StructuredAgentSessionCommandRun,
sessionId: string
): Promise<AgentSessionCommandAdmission> {
const sentUuid = randomUUID()
session.translator?.beginCommand({
Expand All @@ -20,6 +21,7 @@ export async function dispatchClaudeCommand(
})
try {
const admission = await dispatchClaudeTurn(session, {
sessionId,
clientMessageId: command.clientMessageId,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] },
...(command.running.requestedAt === undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ describe('Claude structured dispatch attachment rejections', () => {

await expect(
dispatchClaudeTurn(session, {
sessionId: 'session-1',
clientMessageId: 'client-1',
body: userMessage([{ type: 'image-ref', path }])
})
Expand All @@ -167,9 +168,10 @@ describe('Claude structured dispatch attachment rejections', () => {
rejection: { kind: 'attachmentUnreadable' }
})
expect(session.connection.send).not.toHaveBeenCalled()
// The row drops the error, so the log is the only place left to find why.
// The row drops the error, so the trace is the only place left to find why.
expect(warn).toHaveBeenCalledWith(
'[claude-dispatch] attachment could not be read:',
'[agent-session] claude-attachment-read failed',
{ sessionId: 'session-1', providerSessionId: session.providerSessionId },
expect.objectContaining({ code: 'ENOENT' })
)
warn.mockRestore()
Expand Down
15 changes: 12 additions & 3 deletions src/main/claude/claude-structured-dispatch-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '../../shared/agent-session-failure-words'
import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names'
import { claudeRecord } from './claude-structured-item-translation'
import { traceAgentSessionError } from '../observability/agent-session-error-trace'

/** Orca refused the message's content, as opposed to failing to read an attachment. */
export class ClaudeDispatchContentError extends Error {
Expand Down Expand Up @@ -47,12 +48,20 @@ export function claudeDispatchRejection(

/** Why a message whose content could not be built was not sent: Orca's own refusal of it, or an
* attachment it could not read. Never the provider. */
export function claudeDispatchContentRejection(error: unknown): AgentJournalDispatchRejection {
export function claudeDispatchContentRejection(
error: unknown,
ids: { sessionId?: string; providerSessionId: string }
): AgentJournalDispatchRejection {
if (error instanceof ClaudeDispatchContentError) {
return claudeDispatchRejection(error.failure)
}
// The row says only that it could not be read; why belongs in the log.
console.warn('[claude-dispatch] attachment could not be read:', error)
// The row says only that it could not be read; why belongs in the trace.
traceAgentSessionError({
step: 'claude-attachment-read',
sessionId: ids.sessionId,
error,
detail: { providerSessionId: ids.providerSessionId }
})
return claudeDispatchRejection(agentSessionFailureFact('attachmentUnreadable'))
}

Expand Down
7 changes: 4 additions & 3 deletions src/main/claude/claude-structured-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,15 +406,16 @@ describe('Claude structured dispatch image limits', () => {
const body = userMessage([{ type: 'text', text: 'retry me' }])

await expect(
dispatchClaudeTurn(session, { clientMessageId: 'client-1', body })
dispatchClaudeTurn(session, { sessionId: 'session-1', clientMessageId: 'client-1', body })
).resolves.toEqual({
state: 'rejected',
reason: 'provider_write_failed',
rejection: { kind: 'writeFailed' }
})
// The row keeps only the marker; why the write failed goes to the log.
// The row keeps only the marker; why the write failed goes to the trace.
expect(warn).toHaveBeenCalledWith(
'[claude-dispatch] message could not be handed to Claude:',
'[agent-session] claude-dispatch-write failed',
{ sessionId: 'session-1', providerSessionId: session.providerSessionId },
expect.objectContaining({ message: expect.stringContaining('broken pipe') })
)
warn.mockRestore()
Expand Down
30 changes: 24 additions & 6 deletions src/main/claude/claude-structured-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { dispatchWriteOutcomeUnknownReason } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons'
import { DISPATCH_REJECTED_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection'
import { agentSessionFailureFact } from '../../shared/agent-session-failure'
import { traceAgentSessionError } from '../observability/agent-session-error-trace'
import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words'
import {
claudeUnwrittenUserMessageError,
Expand Down Expand Up @@ -71,15 +72,26 @@ export function retireClaudeDispatchWaiters(session: ClaudeSession): void {
}
}

/** The row keeps only the marker released clients hide; why the write failed belongs in the log. */
function claudeWriteFailureRejection(error: unknown): AgentJournalDispatchRejection {
console.warn('[claude-dispatch] message could not be handed to Claude:', error)
/** The row keeps only the marker released clients hide; why the write failed belongs in the trace. */
function claudeWriteFailureRejection(
session: ClaudeSession,
sessionId: string | undefined,
error: unknown
): AgentJournalDispatchRejection {
traceAgentSessionError({
step: 'claude-dispatch-write',
sessionId,
error,
detail: { providerSessionId: session.providerSessionId }
})
return claudeDispatchRejection(agentSessionFailureFact('writeFailed'))
}

export async function dispatchClaudeTurn(
session: ClaudeSession,
input: {
/** Orca's session, which a trace record is filed under. */
sessionId?: string
clientMessageId?: string
body: AgentJournalMessageItem
requestedAt?: number
Expand All @@ -92,7 +104,13 @@ export async function dispatchClaudeTurn(
try {
content = await claudeDispatchMessageContent(input.body)
} catch (error) {
return { state: 'rejected', ...claudeDispatchContentRejection(error) }
return {
state: 'rejected',
...claudeDispatchContentRejection(error, {
sessionId: input.sessionId,
providerSessionId: session.providerSessionId
})
}
}
if (session.dispatchWaiters.length >= MAX_ACTIVE_DISPATCH_WAITERS) {
return { state: 'rejected', ...claudeDispatchRejection(agentSessionFailureFact('queueFull')) }
Expand Down Expand Up @@ -145,7 +163,7 @@ export async function dispatchClaudeTurn(
if (error instanceof AgentSessionPreDispatchError) {
throw error
}
return { state: 'rejected', ...claudeWriteFailureRejection(error) }
return { state: 'rejected', ...claudeWriteFailureRejection(session, input.sessionId, error) }
}
const waiter = replay.waiter
if (waiter.settledUuid) {
Expand All @@ -163,7 +181,7 @@ export async function dispatchClaudeTurn(
waiter.resolve(null)
// The frame was never handed to the SDK's input pump, so this is not doubt:
// the message provably did not happen, which is what `rejected` means.
return { state: 'rejected', ...claudeWriteFailureRejection(error) }
return { state: 'rejected', ...claudeWriteFailureRejection(session, input.sessionId, error) }
}
if (!waiter.retired) {
retireWaiter(session, waiter)
Expand Down
4 changes: 2 additions & 2 deletions src/main/claude/claude-structured-effort-reporting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ describe('Claude effort against the model that must run it', () => {
const { session } = sessionWith('high', calls, { model: 'sonnet', catalog: [SONNET] })
session.options.set('effort', 'low')

await restoreClaudeStructuredSessionOptions(session, undefined)
await restoreClaudeStructuredSessionOptions(session, undefined, 'session-1')

expect(session.options.get('effort')).toBe('low')
expect(session.restoreSkippedOptions.has('effort')).toBe(false)
Expand All @@ -257,7 +257,7 @@ describe('Claude effort against the model that must run it', () => {
session.options.set('model', 'haiku')
session.options.set('effort', 'high')

await restoreClaudeStructuredSessionOptions(session, undefined)
await restoreClaudeStructuredSessionOptions(session, undefined, 'session-1')

expect(session.options.has('effort')).toBe(false)
expect(session.restoreSkippedOptions.has('effort')).toBe(true)
Expand Down
9 changes: 3 additions & 6 deletions src/main/claude/claude-structured-history-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import { join } from 'node:path'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import { resolveSessionFilePath } from '../native-chat/session-file-resolver'
import { traceAgentSessionError } from '../observability/agent-session-error-trace'
import type {
ProviderHistoryItem,
ProviderHistoryWindow
Expand Down Expand Up @@ -281,12 +282,8 @@ export async function readClaudeProviderHistoryWindow(
} catch (error) {
// Unreadable, unprovable, or a single record too large to frame: all of them
// leave the boundary unvouched for, which is not the same as an empty window.
// Oversize is no longer among them, so only the log separates what is left.
console.warn('[claude-history-window] transcript unprovable; boundary inconsistent:', {
transcriptPath: input.transcriptPath,
sessionId: input.sessionId,
error
})
// Oversize is no longer among them, so only the trace separates what is left.
traceAgentSessionError({ step: 'claude-history-window', sessionId: input.sessionId, error })
return INCONSISTENT
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ describe('claude journal translation — background task rows', () => {
return appendItem(...args)
})
const deferred = createDeferredStructuredAgentSessionEventSink({
onFailure: () => {},
watermarks: {
pauseQueuedOperations: 1,
maxQueuedOperations: 4,
Expand Down Expand Up @@ -252,7 +253,7 @@ describe('claude journal translation — background task rows', () => {

it('coalesces an unbound overflow patch and aliased final notification', async () => {
const persisted = new Map<string, AgentJournalItemBody>()
const deferred = createDeferredStructuredAgentSessionEventSink()
const deferred = createDeferredStructuredAgentSessionEventSink({ onFailure: () => {} })
const translator = createClaudeJournalTranslator({
sink: deferred.sink,
fallbackIdPrefix: 'test'
Expand Down Expand Up @@ -292,7 +293,7 @@ describe('claude journal translation — background task rows', () => {

it('reconciles an aliased notification after a parentless overflow patch was persisted', async () => {
const persisted = new Map<string, AgentJournalItemBody>()
const deferred = createDeferredStructuredAgentSessionEventSink()
const deferred = createDeferredStructuredAgentSessionEventSink({ onFailure: () => {} })
deferred.bind(persistedTarget(persisted))
const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: 'first' })
fillLiveTaskRows(first)
Expand Down Expand Up @@ -353,7 +354,7 @@ describe('claude journal translation — background task rows', () => {

it('resolves a queued restart identity after the sink rebinds', async () => {
const persisted = new Map<string, AgentJournalItemBody>()
const deferred = createDeferredStructuredAgentSessionEventSink()
const deferred = createDeferredStructuredAgentSessionEventSink({ onFailure: () => {} })
deferred.bind(persistedTarget(persisted))

const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: 'first' })
Expand All @@ -371,7 +372,7 @@ describe('claude journal translation — background task rows', () => {
first.dispose()
await deferred.drained()

const restarted = createDeferredStructuredAgentSessionEventSink()
const restarted = createDeferredStructuredAgentSessionEventSink({ onFailure: () => {} })
const second = createClaudeJournalTranslator({
sink: restarted.sink,
fallbackIdPrefix: 'second'
Expand All @@ -397,7 +398,7 @@ describe('claude journal translation — background task rows', () => {

it('keeps pending writes from distinct runs when a translator is recreated', async () => {
const persisted = new Map<string, AgentJournalItemBody>()
const deferred = createDeferredStructuredAgentSessionEventSink()
const deferred = createDeferredStructuredAgentSessionEventSink({ onFailure: () => {} })

const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: 'first' })
spawnToolCall(first, 'toolu-first')
Expand Down
4 changes: 2 additions & 2 deletions src/main/claude/claude-structured-journal-translation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ describe('Claude structured journal translation', () => {
now: () => 1_700_000_000_000,
mintEpoch: () => 'epoch-1'
})
const deferred = createDeferredStructuredAgentSessionEventSink()
const deferred = createDeferredStructuredAgentSessionEventSink({ onFailure: () => {} })
deferred.bind({ journal, fence: 1, publish: vi.fn() })
let scheduled: (() => void) | null = null
const translator = createClaudeJournalTranslator({
Expand Down Expand Up @@ -317,7 +317,7 @@ describe('Claude structured journal translation', () => {
now: () => 1_700_000_000_000,
mintEpoch: () => 'epoch-1'
})
const deferred = createDeferredStructuredAgentSessionEventSink()
const deferred = createDeferredStructuredAgentSessionEventSink({ onFailure: () => {} })
deferred.bind({ journal, fence: 1, publish: vi.fn() })
const translator = createClaudeJournalTranslator({ sink: deferred.sink })
const approval = prompt({
Expand Down
4 changes: 2 additions & 2 deletions src/main/claude/claude-structured-model-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ describe('Claude model pre-flight against the catalog the CLI listed', () => {
const { session, calls } = sessionWith([DEFAULT_ROW, SONNET])
session.options.set('model', 'claude-opus-4-retired')

await restoreClaudeStructuredSessionOptions(session, undefined)
await restoreClaudeStructuredSessionOptions(session, undefined, 'session-1')

expect(calls).toEqual(['list_models'])
expect(session.options.has('model')).toBe(false)
Expand Down Expand Up @@ -123,7 +123,7 @@ describe('Claude model pre-flight against the catalog the CLI listed', () => {
const { session, calls } = sessionWith([DEFAULT_ROW, SONNET])
session.options.set('model', 'sonnet')

await restoreClaudeStructuredSessionOptions(session, undefined)
await restoreClaudeStructuredSessionOptions(session, undefined, 'session-1')

expect(calls).toEqual(['list_models', 'set_model:sonnet'])
expect(session.options.get('model')).toBe('sonnet')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,23 +66,23 @@ describe('the context ring after a session option write', () => {
const s = ringSession()
s.session.options.set('model', 'opusplan')
s.session.options.set('permissionMode', 'plan')
await restoreClaudeStructuredSessionOptions(s.session, undefined)
await restoreClaudeStructuredSessionOptions(s.session, undefined, 'session-1')
expect(s.setModel).toHaveBeenCalledWith('opusplan', { timeoutMs: undefined })
expect(s.modelMayHaveChanged).not.toHaveBeenCalled()
})

it('asks for the new window when a restore cannot put the stored model back', async () => {
const s = ringSession([{ value: 'sonnet', displayName: 'Sonnet' }])
s.session.options.set('model', 'retired-model')
await restoreClaudeStructuredSessionOptions(s.session, undefined)
await restoreClaudeStructuredSessionOptions(s.session, undefined, 'session-1')
expect(s.session.restoreSkippedOptions).toEqual(new Set(['model']))
expect(s.modelMayHaveChanged).toHaveBeenCalledTimes(1)
})

it('sizes a new session from the model its restore applied', async () => {
const s = ringSession([{ value: 'opus[1m]', displayName: 'Opus (1M)' }])
s.session.options.set('model', 'opus[1m]')
await restoreClaudeStructuredSessionOptions(s.session, undefined)
await restoreClaudeStructuredSessionOptions(s.session, undefined, 'session-1')
expect(s.respond('turn-a', 1_000)).toMatchObject({ windowTokens: 1_000_000, percentage: 10 })
})

Expand All @@ -102,7 +102,7 @@ describe('the context ring after a session option write', () => {

const skipped = ringSession([{ value: 'sonnet', displayName: 'Sonnet' }])
skipped.session.options.set('model', 'retired-model[1m]')
await restoreClaudeStructuredSessionOptions(skipped.session, undefined)
await restoreClaudeStructuredSessionOptions(skipped.session, undefined, 'session-1')
expect(skipped.respond('turn-a', 1_000)).toBeNull()
})

Expand Down
13 changes: 10 additions & 3 deletions src/main/claude/claude-structured-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ describe('Claude structured Fast mode', () => {
const { session, applyFlagSettings } = fastModeSession(undefined)
session.options.set('fastMode', 'false')

await restoreClaudeStructuredSessionOptions(session, undefined)
await restoreClaudeStructuredSessionOptions(session, undefined, 'session-1')

expect(session.options.get('fastMode')).toBe('false')
expect(session.restoreSkippedOptions.has('fastMode')).toBe(false)
Expand Down Expand Up @@ -467,9 +467,16 @@ describe('Claude structured option restore under the request deadline', () => {
['effort', 'high'],
['permissionMode', 'plan']
])
vi.spyOn(console, 'warn').mockImplementation(() => {})
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})

await expect(restoreClaudeStructuredSessionOptions(session, 10)).resolves.toBeUndefined()
await expect(
restoreClaudeStructuredSessionOptions(session, 10, 'session-1')
).resolves.toBeUndefined()
expect(warn).toHaveBeenCalledWith(
'[agent-session] claude-option-restore failed',
expect.objectContaining({ sessionId: 'session-1' }),
expect.any(ClaudeControlRequestTimeoutError)
)

expect(Object.fromEntries(session.options)).toEqual({ model: 'sonnet', effort: 'high' })
expect([...session.restoreSkippedOptions]).toEqual(['permissionMode'])
Expand Down
Loading
Loading