diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts index 712b181cbbc..bca14e293f7 100644 --- a/mobile/src/session/mobile-structured-agent-session-rpc.ts +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -10,13 +10,15 @@ import type { import { structuredAgentSessionPayloadFingerprint } from '../../../src/shared/structured-agent-session-mutation' import { agentSessionRefusalNotice, - agentSessionRpcErrorFailure, agentSessionWriteFailureNotice, - agentSessionWriteKindForMethod, agentSessionWriteNoticeEnglish, - agentSessionWriteNoticeParts, - type AgentSessionWriteKind + agentSessionWriteNoticeParts } from '../../../src/shared/agent-session-refusal-notice' +import { + agentSessionRpcErrorFailure, + agentSessionWriteKindForMethod, + type AgentSessionWriteKind +} from '../../../src/shared/agent-session-write-failure' import { structuredSessionOperationId } from './structured-session-operation-id' import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import type { RpcClient } from '../transport/rpc-client' diff --git a/mobile/src/session/mobile-structured-operation-id-retirement.test.ts b/mobile/src/session/mobile-structured-operation-id-retirement.test.ts index 887aeb7e208..82ccd1e19dc 100644 --- a/mobile/src/session/mobile-structured-operation-id-retirement.test.ts +++ b/mobile/src/session/mobile-structured-operation-id-retirement.test.ts @@ -161,6 +161,36 @@ describe('what a structured refusal says on the phone', () => { }) }) + it('says what the host named as the reason, in the same words as desktop', async () => { + const result = await requestStructuredAgentSessionMutation({ + client: fakeClient(async () => ({ + ok: true, + result: { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: 'This conversation has been cleared. Use the current conversation.', + details: { reason: 'conversationCleared' } + } + }, + _meta: { runtimeId: 'runtime-1' } + })), + method: 'agentSession.send', + fingerprintMethod: 'agentSession.send', + sessionId: 'session-1', + expectedRuntimeFence: 1, + fields: { body: 'hello' }, + clientOperationId: `1900000000000-${'e'.repeat(32)}` + }) + + expect(result).toEqual({ + status: 'refused', + code: 'agent_session_operation_invalid', + message: + 'This conversation has been cleared. Your message was not sent. Open the current conversation to continue.' + }) + }) + const stop = { method: 'agentSession.cancel', fingerprintMethod: 'agentSession.cancel', diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts new file mode 100644 index 00000000000..fc5d234ec87 --- /dev/null +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -0,0 +1,132 @@ +// A Claude that keeps retrying a refused request is working, so the idle sweep must not stop it. +// Every retry frame's publish is the activity the sweep reads, even though a run writes one row. +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 { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { STRUCTURED_AGENT_SESSION_IDLE_MS } from '../native-chat/agent-session-wire/structured-agent-session-idle-sweep' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + resetHostTestOperationIds +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +const SWEEP_MS = 5 +const RETRY_GAP_MS = 10 * 60_000 + +let root: string +let host: StructuredAgentSessionHost +let sink: StructuredAgentSessionEventSink | null +let closeSession: Mock> +let clock: number + +function apiRetry(attempt: number): Record { + return { + type: 'system', + subtype: 'api_retry', + attempt, + max_retries: 10, + retry_delay_ms: 600_000, + error_status: 429, + error: 'rate_limit', + session_id: 'provider-1', + uuid: `9b2f6a1e-0c4d-4e7a-8f3b-00000000000${attempt}` + } +} + +/** Long enough for many sweep ticks, so "still open" means the sweep declined. */ +function waitOutSeveralSweeps(): Promise { + return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20)) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-retry-sweep-')) + resetHostTestOperationIds() + sink = null + clock = NOW + closeSession = vi.fn(async () => true) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const adapter: StructuredAgentSessionAdapter = { + acquire: async ({ fence, spawnToken, events }) => { + sink = events ?? null + return { + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + } + }, + closeSession, + releaseAcquisition: async () => true, + dispatch: async () => ({ state: 'admitted' }), + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => undefined, + setOption: async () => undefined + } + host = new StructuredAgentSessionHost({ + store, + adapter, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + idleSweep: { intervalMs: SWEEP_MS, idleMs: STRUCTURED_AGENT_SESSION_IDLE_MS }, + now: () => clock + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('a Claude retrying a refused request', () => { + it('keeps the conversation open past the idle window while retry frames arrive', async () => { + expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) + if (!sink) { + throw new Error('the host never handed the provider its event sink') + } + const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: '1' }) + + // Five frames ten minutes apart: fifty minutes, well past the thirty-minute idle window. + for (let attempt = 1; attempt <= 5; attempt += 1) { + clock += RETRY_GAP_MS + translator.handle({ type: 'message', sessionId: SESSION, message: apiRetry(attempt) }) + await waitOutSeveralSweeps() + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) + } + + const items = host['sessions'].get(SESSION)?.journal.snapshot().items ?? [] + const retryRows = items.filter( + (item) => item.body.kind === 'status' && item.body.failure?.kind === 'providerRetrying' + ) + expect(retryRows).toHaveLength(1) + expect(retryRows[0]?.body).toMatchObject({ + failure: { detail: { text: expect.stringContaining('"attempt":5') } } + }) + + // Once the frames stop, the same clock does let the sweep close it. + clock += STRUCTURED_AGENT_SESSION_IDLE_MS + await vi.waitFor(() => { + expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(host.hasSession(SESSION)).toBe(false) + }) + }) +}) diff --git a/src/main/claude/claude-api-retry-row.test.ts b/src/main/claude/claude-api-retry-row.test.ts new file mode 100644 index 00000000000..becadff90d3 --- /dev/null +++ b/src/main/claude/claude-api-retry-row.test.ts @@ -0,0 +1,133 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import { MAX_PROVIDER_DIAGNOSTIC_CHARS } from '../../shared/agent-session-failure' +import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' +import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: 'leaf-1' } +} + +/** A frame as Claude Code sends it while it retries a refused request. */ +function apiRetry(attempt: number, fields: Record = {}): Record { + return { + type: 'system', + subtype: 'api_retry', + attempt, + max_retries: 10, + retry_delay_ms: 622, + error_status: 429, + error: 'rate_limit', + session_id: 'provider-1', + uuid: `9b2f6a1e-0c4d-4e7a-8f3b-00000000000${attempt}`, + ...fields + } +} + +let root = '' + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-api-retry-')) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +async function statusRowsFor(frames: Record[]) { + const journal = await openAgentSessionJournal({ + identity: IDENTITY, + journalDir: root, + now: () => 1_700_000_000_000, + mintEpoch: () => 'epoch-1' + }) + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind({ journal, fence: 1, publish: vi.fn() }) + const translator = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: '1' }) + for (const frame of frames) { + translator.handle({ type: 'message', sessionId: 'orca-session', message: frame }) + await deferred.drained() + } + return journal + .snapshot() + .items.flatMap((item): Extract[] => + item.body.kind === 'status' ? [item.body] : [] + ) +} + +describe('a Claude api_retry frame', () => { + it('writes one sentence row per retry run, revised by each attempt, not the frame', async () => { + const rows = await statusRowsFor([apiRetry(1), apiRetry(2), apiRetry(3)]) + + expect(rows).toHaveLength(1) + const [row] = rows + expect(row).toMatchObject({ + text: 'Claude is rate-limited and retrying.', + tone: 'warning', + failure: { + kind: 'providerRetrying', + retry: { error: 'rate_limit', status: 429 }, + detail: { audience: 'log' } + } + }) + expect(row).not.toHaveProperty('providerFrame') + // The latest attempt's frame, as the log detail. + expect(row.failure?.detail?.text).toContain('"attempt":3') + }) + + it('starts a new row when a later run starts over', async () => { + const rows = await statusRowsFor([apiRetry(1), apiRetry(2), apiRetry(1)]) + expect(rows.map((row) => row.text)).toEqual([ + 'Claude is rate-limited and retrying.', + 'Claude is rate-limited and retrying.' + ]) + }) + + it('does not call a retry a rate limit unless the frame says so', async () => { + const rows = await statusRowsFor([ + apiRetry(1, { error: 'overloaded', error_status: 529 }), + apiRetry(1, { error: 'server_error', error_status: 500 }), + apiRetry(1, { error: undefined, error_status: undefined }), + apiRetry(1, { error: 'something_new', error_status: 429 }) + ]) + expect(rows.map((row) => row.text)).toEqual([ + 'Claude hit a temporary problem and is retrying.', + 'Claude hit a temporary problem and is retrying.', + 'Claude hit a temporary problem and is retrying.', + 'Claude is rate-limited and retrying.' + ]) + expect(rows[0]?.failure).toMatchObject({ retry: { error: 'overloaded', status: 529 } }) + }) + + it('caps the frame it keeps as the detail', async () => { + const [row] = await statusRowsFor([apiRetry(1, { padding: 'x'.repeat(5_000) })]) + expect(row?.failure?.detail?.text.length).toBe(MAX_PROVIDER_DIAGNOSTIC_CHARS) + }) + + it('leaves a frame kind no one catalogued to the provider fallback, as before', async () => { + const unknown = { + type: 'system', + subtype: 'future_notice', + error: 'rate_limit', + session_id: 'provider-1', + uuid: 'future-1' + } + const rows = await statusRowsFor([unknown]) + expect(rows).toEqual([ + unhandledProviderFrameJournalItem('claude', 'message:system:future_notice', unknown)?.body + ]) + expect(rows[0]).toMatchObject({ text: 'rate_limit', tone: 'error' }) + }) +}) diff --git a/src/main/claude/claude-api-retry-row.ts b/src/main/claude/claude-api-retry-row.ts new file mode 100644 index 00000000000..195e30461a7 --- /dev/null +++ b/src/main/claude/claude-api-retry-row.ts @@ -0,0 +1,39 @@ +// The one row a Claude `system/api_retry` frame writes: a sentence and a `providerRetrying` fact, +// revised in place for every attempt of the same retry run. + +import { + agentSessionFailureFact, + providerDiagnostic, + readProviderRetry +} from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { AgentJournalStatusItem } from '../../shared/agent-session-journal-types' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' + +export const CLAUDE_API_RETRY_FRAME_KIND = 'message:system:api_retry' + +export function claudeApiRetryRowBody(message: Record): AgentJournalStatusItem { + const retry = readProviderRetry({ error: message.error, status: message.error_status }) + const words = agentSessionFailureWords( + agentSessionFailureFact('providerRetrying', { + detail: providerDiagnostic(JSON.stringify(message), 'log'), + ...(retry ? { retry } : {}) + }), + { surface: 'row', agentName: TUI_AGENT_DISPLAY_NAMES.claude } + ) + return { kind: 'status', tone: 'warning', ...words } +} + +/** Numbers retry runs: a frame whose attempt does not follow the last one starts a new run. */ +export function createClaudeApiRetryRuns(): (message: Record) => number { + let run = 0 + let lastAttempt: number | null = null + return (message) => { + const attempt = typeof message.attempt === 'number' ? message.attempt : null + if (attempt === null || lastAttempt === null || attempt <= lastAttempt) { + run += 1 + } + lastAttempt = attempt + return run + } +} diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 51ffa085163..946cf08e1c4 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { providerDiagnosticOf } from '../../shared/agent-session-failure' import { afterEach, describe, expect, it, vi } from 'vitest' import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' import { hasLiveClaudePtys } from '../claude-accounts/live-pty-gate' @@ -592,6 +593,11 @@ describe('Claude stream-json connection', () => { await until(() => exit, 'the exit error') // The status and stderr are the only diagnostic a refused start leaves behind. expect((exit as unknown as Error).message).toMatch(/exited \(code 1\): claude: not signed in/) + // Kept apart from Orca's wording where it is composed, and marked as log text. + expect(providerDiagnosticOf(exit)).toEqual({ + text: expect.stringMatching(/^\(code 1\)\n.*claude: not signed in/s), + audience: 'log' + }) expect(connection.closed).toBe(true) // Stderr-triggered capture can win or lose the race with this real child's exit. const closed = await connection.close() diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index c6da4419f79..486407c0137 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto' +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import type * as ClaudeAgentSdk from '@anthropic-ai/claude-agent-sdk' import type { CanUseTool, OnUserDialog, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' import { spawnProcess } from '../../shared/child-process/run-process' @@ -103,7 +104,12 @@ function exitError(stderrTail: string, status: ExitStatus | null, cause?: Error) ? ` (code ${status.code})` : '' const message = `claude stream-json exited${how}${detail ? `: ${detail}` : ''}` - return cause ? new Error(message, { cause }) : new Error(message) + // Written for a log, not a person: the chat keeps it behind Details. + const diagnostic = providerDiagnostic([how.trim(), detail].filter(Boolean).join('\n'), 'log') + return withProviderDiagnostic( + cause ? new Error(message, { cause }) : new Error(message), + diagnostic + ) } export async function openClaudeStreamJsonConnection( diff --git a/src/main/claude/claude-structured-auth-parity.test.ts b/src/main/claude/claude-structured-auth-parity.test.ts index ddc69366aad..002240e4dd7 100644 --- a/src/main/claude/claude-structured-auth-parity.test.ts +++ b/src/main/claude/claude-structured-auth-parity.test.ts @@ -105,7 +105,10 @@ describe('claude structured auth parity with the terminal preflight', () => { resolverFor({ stripAuthEnv: true, overlay: { ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' } })({ identity: IDENTITY }) - ).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + reason: 'managedAccountEnvOverride' + }) }) it('refuses an auth-like ANTHROPIC_CUSTOM_HEADERS override while a managed account is pinned', async () => { @@ -114,7 +117,10 @@ describe('claude structured auth parity with the terminal preflight', () => { stripAuthEnv: true, overlay: { ANTHROPIC_CUSTOM_HEADERS: 'Authorization: Bearer sk-ant-CONFIGURED' } })({ identity: IDENTITY }) - ).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + reason: 'managedAccountEnvOverride' + }) }) it('still admits a non-auth env overlay under a managed account', async () => { @@ -161,7 +167,10 @@ describe('claude structured auth parity with the terminal preflight', () => { await expect( resolverFor({ stripAuthEnv: true, authSwitchSettleTimeoutMs: 20 })({ identity: IDENTITY }) - ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + reason: 'accountSwitchInProgress' + }) }) it('waits a settling account switch out rather than refusing a resolved launch', async () => { @@ -183,7 +192,10 @@ describe('claude structured auth parity with the terminal preflight', () => { await expect( adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + reason: 'accountSwitchInProgress' + }) // Nothing was spawned, so the refusal must not have opened a connection. expect(claude.connections).toHaveLength(0) }) @@ -227,7 +239,10 @@ describe('claude structured auth parity with the terminal preflight', () => { await expect( adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) - ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + reason: 'accountSwitchInProgress' + }) // No replacement child was opened, so nothing is left running unowned. expect(claude.connections).toHaveLength(1) await adapter.closeAll() diff --git a/src/main/claude/claude-structured-compaction.test.ts b/src/main/claude/claude-structured-compaction.test.ts index 5255ce37ea1..70aabc5ae50 100644 --- a/src/main/claude/claude-structured-compaction.test.ts +++ b/src/main/claude/claude-structured-compaction.test.ts @@ -22,7 +22,7 @@ describe('Claude compaction transcript content', () => { } } expect(isClaudeCompactionContent(tracker, event)).toBe(false) - const completion = tracker.run('orca-session', 'provider', async () => ({})) + const completion = tracker.run('orca-session', 'provider', async () => undefined) expect(isClaudeCompactionContent(tracker, event)).toBe(true) expect(isClaudeCompactionContent(tracker, { ...event, sessionId: 'other' })).toBe(false) expect(isClaudeCompactionContent(tracker, { ...event, message: { type: 'result' } })).toBe( @@ -46,7 +46,7 @@ describe('Claude compaction transcript content', () => { await vi.advanceTimersByTimeAsync(1) - await expect(pending).resolves.toEqual({ error: 'provider_write_failed: input closed' }) + await expect(pending).resolves.toEqual({ outcome: 'failed' }) }) it('keeps waiting when the command write outcome is ambiguous', async () => { diff --git a/src/main/claude/claude-structured-compaction.ts b/src/main/claude/claude-structured-compaction.ts index 6c3155739c2..5b1f67e2e11 100644 --- a/src/main/claude/claude-structured-compaction.ts +++ b/src/main/claude/claude-structured-compaction.ts @@ -1,5 +1,8 @@ import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' +import type { + StructuredSessionCompaction, + StructuredSessionCompactionResult +} 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' /** Compaction needs no ack deadline of its own: `compactions.run` keeps its own @@ -9,7 +12,7 @@ export function compactClaudeSession( session: ClaudeSession, compactions: StructuredSessionCompaction, input: Parameters>[0] -): Promise<{ error?: string }> { +): Promise { return compactions.run( input.sessionId, session.providerSessionId, @@ -18,7 +21,7 @@ export function compactClaudeSession( body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] } }) if (result.state === 'rejected') { - return { error: result.reason } + return { outcome: 'failed' as const } } return undefined }, diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index a04273ac126..4aaf57bf54c 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -85,7 +85,8 @@ describe('cancelClaudeTurn', () => { expect(settled).toHaveBeenNthCalledWith(1, { clientMessageId: 'client-0', state: 'rejected', - reason: 'provider_cancelled_before_start' + reason: 'provider_cancelled_before_start', + rejection: { kind: 'cancelled' } }) }) @@ -118,7 +119,8 @@ describe('cancelClaudeTurn', () => { expect(settled).toHaveBeenCalledWith({ clientMessageId: 'client-ambiguous', state: 'rejected', - reason: 'provider_cancelled_before_start' + reason: 'provider_cancelled_before_start', + rejection: { kind: 'cancelled' } }) }) diff --git a/src/main/claude/claude-structured-dispatch-admission.test.ts b/src/main/claude/claude-structured-dispatch-admission.test.ts index 9a4d60a824c..75553addd3c 100644 --- a/src/main/claude/claude-structured-dispatch-admission.test.ts +++ b/src/main/claude/claude-structured-dispatch-admission.test.ts @@ -139,7 +139,11 @@ describe('Claude structured dispatch admission', () => { clientMessageId: 'client-over-capacity', body: userMessage([{ type: 'text', text: 'one too many' }]) }) - ).resolves.toEqual({ state: 'rejected', reason: 'claude structured dispatch queue is full' }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'claude structured dispatch queue is full', + rejection: { kind: 'queueFull' } + }) expect(session.dispatchWaiters).toHaveLength(64) expect(session.connection.send).toHaveBeenCalledTimes(64) }) diff --git a/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts b/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts new file mode 100644 index 00000000000..a23767c0968 --- /dev/null +++ b/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts @@ -0,0 +1,177 @@ +// What a person reads when Orca refuses a message's content before sending it. + +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 { dispatchClaudeTurn } from './claude-structured-dispatch' +import { sessionFor, userMessage } from './claude-structured-dispatch-test-support' + +describe('Claude structured dispatch attachment rejections', () => { + it('rejects more than twenty URL images before sending', async () => { + const session = sessionFor() + const body = userMessage( + Array.from({ length: 21 }, (_, index) => ({ + type: 'image-ref' as const, + url: `https://example.test/${index}.png` + })) + ) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'Claude accepts at most 20 images in one message, so this message was not sent.', + rejection: { kind: 'attachmentInvalid', attachment: { reason: 'tooMany', limit: 20 } } + }) + expect(session.connection.send).not.toHaveBeenCalled() + }) + + it('rejects local images whose aggregate size exceeds twenty MiB', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-images-')) + try { + const paths = await Promise.all( + Array.from({ length: 5 }, async (_, index) => { + const path = join(directory, `${index}.png`) + await writeFile(path, Buffer.alloc(5 * 1024 * 1024)) + return path + }) + ) + const session = sessionFor() + const body = userMessage(paths.map((path) => ({ type: 'image-ref' as const, path }))) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) + ).resolves.toEqual({ + state: 'rejected', + reason: + 'The images on this message add up to more than 20 MB, so the message was not sent.', + rejection: { + kind: 'attachmentInvalid', + attachment: { reason: 'totalTooLarge', limit: 20 * 1024 * 1024 } + } + }) + expect(session.connection.send).not.toHaveBeenCalled() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('rejects a local image by actual bytes read beyond the per-image cap', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) + try { + const path = join(directory, 'oversized.png') + await writeFile(path, Buffer.alloc(5 * 1024 * 1024 + 1)) + const session = sessionFor() + const body = userMessage([{ type: 'image-ref', path }]) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'An image on this message is larger than 5 MB, so the message was not sent.', + rejection: { + kind: 'attachmentInvalid', + attachment: { reason: 'tooLarge', limit: 5 * 1024 * 1024 } + } + }) + expect(session.connection.send).not.toHaveBeenCalled() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('names an empty image as empty, not as too large', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) + try { + const path = join(directory, 'empty.png') + await writeFile(path, Buffer.alloc(0)) + const session = sessionFor() + + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'image-ref', path }]) + }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'An image on this message is empty, so the message was not sent.', + rejection: { kind: 'attachmentInvalid', attachment: { reason: 'empty' } } + }) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('names an unsupported image type in words a person can act on', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) + try { + const path = join(directory, 'picture.bmp') + await writeFile(path, Buffer.alloc(64)) + const session = sessionFor() + + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'image-ref', path }]) + }) + ).resolves.toEqual({ + state: 'rejected', + reason: + 'Claude accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent.', + rejection: { kind: 'attachmentInvalid', attachment: { reason: 'unsupportedType' } } + }) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('does not blame an attachment for a message no Orca client sends', async () => { + const session = sessionFor() + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: '' }]) + }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'This message is empty, so it was not sent.', + rejection: { kind: 'emptyMessage' } + }) + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-2', + body: { ...userMessage([{ type: 'text', text: 'hi' }]), role: 'assistant' } + }) + ).resolves.toEqual({ + state: 'rejected', + reason: "Orca ran into a problem, so this didn't go through. Try again.", + rejection: { kind: 'hostFault' } + }) + expect(session.connection.send).not.toHaveBeenCalled() + }) + + it('rejects an attachment it cannot read with the generic sentence and no path', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const session = sessionFor() + const path = join(tmpdir(), 'orca-claude-image-missing', 'gone.png') + + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'image-ref', path }]) + }) + ).resolves.toEqual({ + state: 'rejected', + reason: "An attachment on this message couldn't be read, so the message was not sent.", + 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. + expect(warn).toHaveBeenCalledWith( + '[claude-dispatch] attachment could not be read:', + expect.objectContaining({ code: 'ENOENT' }) + ) + warn.mockRestore() + }) +}) diff --git a/src/main/claude/claude-structured-dispatch-content.ts b/src/main/claude/claude-structured-dispatch-content.ts index 7df84091b7c..035b0a59492 100644 --- a/src/main/claude/claude-structured-dispatch-content.ts +++ b/src/main/claude/claude-structured-dispatch-content.ts @@ -3,11 +3,63 @@ import { open } from 'node:fs/promises' import { extname } from 'node:path' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' +import { + agentSessionFailureFact, + type AgentSessionAttachmentProblem, + type SubmissionRejectionFact +} from '../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection +} from '../../shared/agent-session-failure-words' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' import { claudeRecord } from './claude-structured-item-translation' -const MAX_IMAGE_BYTES = 5 * 1024 * 1024 +/** Orca refused the message's content, as opposed to failing to read an attachment. */ +export class ClaudeDispatchContentError extends Error { + /** What was wrong, typed where the check saw it; `message` stays for logs. */ + readonly failure: SubmissionRejectionFact + + constructor(message: string, failure: SubmissionRejectionFact) { + super(message) + this.name = 'ClaudeDispatchContentError' + this.failure = failure + } +} + +function attachmentError(message: string, attachment: AgentSessionAttachmentProblem): Error { + return new ClaudeDispatchContentError( + message, + agentSessionFailureFact('attachmentInvalid', { attachment }) + ) +} + +/** A message Claude rejected, in the words that name Claude and its legacy markers. */ +export function claudeDispatchRejection( + failure: SubmissionRejectionFact +): AgentJournalDispatchRejection { + return agentSessionFailureWords(failure, { + surface: 'rejection', + agentName: TUI_AGENT_DISPLAY_NAMES.claude, + provider: 'claude' + }) +} + +/** 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 { + 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) + return claudeDispatchRejection(agentSessionFailureFact('attachmentUnreadable')) +} + +const BYTES_PER_MB = 1024 * 1024 +const MAX_IMAGE_BYTES = 5 * BYTES_PER_MB const MAX_IMAGE_COUNT = 20 -const MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024 +const MAX_TOTAL_IMAGE_BYTES = 20 * BYTES_PER_MB const MAX_REPLAY_CONTENT_KEY_BYTES = 256 type ImageBudget = { @@ -18,14 +70,17 @@ type ImageBudget = { export async function readClaudeImage(path: string, openImpl: typeof open = open): Promise { const file = await openImpl(path, 'r') try { - const invalidImage = (): Error => - new Error(`Claude image must be a non-empty file no larger than ${MAX_IMAGE_BYTES} bytes`) + const tooLarge = (): Error => + attachmentError(`Claude image must be no larger than ${MAX_IMAGE_BYTES} bytes`, { + reason: 'tooLarge', + limit: MAX_IMAGE_BYTES + }) const info = await file.stat() if (!info.isFile()) { - throw new Error('Claude image must be a file') + throw attachmentError('Claude image must be a file', { reason: 'notAFile' }) } if (info.size > MAX_IMAGE_BYTES) { - throw invalidImage() + throw tooLarge() } const buffer = Buffer.allocUnsafe(info.size + 1) let bytesRead = 0 @@ -39,8 +94,15 @@ export async function readClaudeImage(path: string, openImpl: typeof open = open // A file can grow after the initial stat and after the final read returns // zero. Prove the descriptor's size matches what was copied before sending. const finalInfo = await file.stat() - if (bytesRead === 0 || bytesRead > MAX_IMAGE_BYTES || finalInfo.size !== bytesRead) { - throw invalidImage() + if (bytesRead > MAX_IMAGE_BYTES) { + throw tooLarge() + } + if (finalInfo.size !== bytesRead) { + // Not the image's fault: it changed while Orca read it, so it reads as unreadable. + throw new Error('Claude image changed while it was read') + } + if (bytesRead === 0) { + throw attachmentError('Claude image must be a non-empty file', { reason: 'empty' }) } return buffer.subarray(0, bytesRead) } finally { @@ -62,22 +124,30 @@ async function imageContent( ): Promise { budget.count += 1 if (budget.count > MAX_IMAGE_COUNT) { - throw new Error(`Claude messages support at most ${MAX_IMAGE_COUNT} images`) + throw attachmentError(`Claude messages support at most ${MAX_IMAGE_COUNT} images`, { + reason: 'tooMany', + limit: MAX_IMAGE_COUNT + }) } if (block.url) { return { type: 'image', source: { type: 'url', url: block.url } } } if (!block.path) { - throw new Error('image reference has neither a path nor a URL') + throw attachmentError('image reference has neither a path nor a URL', { reason: 'noSource' }) } const data = await readClaudeImage(block.path) budget.localBytes += data.byteLength if (budget.localBytes > MAX_TOTAL_IMAGE_BYTES) { - throw new Error(`Claude images must total no more than ${MAX_TOTAL_IMAGE_BYTES} bytes`) + throw attachmentError(`Claude images must total no more than ${MAX_TOTAL_IMAGE_BYTES} bytes`, { + reason: 'totalTooLarge', + limit: MAX_TOTAL_IMAGE_BYTES + }) } const mediaType = IMAGE_MIME_BY_EXTENSION[extname(block.path).toLowerCase()] if (!mediaType) { - throw new Error(`Claude does not support the image type ${extname(block.path)}`) + throw attachmentError(`Claude does not support the image type ${extname(block.path)}`, { + reason: 'unsupportedType' + }) } return { type: 'image', @@ -99,7 +169,11 @@ export async function claudeDispatchMessageContent( body: AgentJournalMessageItem ): Promise { if (body.role !== 'user') { - throw new Error('Claude dispatch accepts only user messages') + // No Orca client sends one, so the fault is Orca's. + throw new ClaudeDispatchContentError( + 'Claude dispatch accepts only user messages', + agentSessionFailureFact('hostFault') + ) } const images: unknown[] = [] const texts: string[] = [] @@ -115,7 +189,10 @@ export async function claudeDispatchMessageContent( // text blocks would silently discard every one but the last. const content = texts.length > 0 ? [...images, { type: 'text', text: texts.join('\n') }] : images if (content.length === 0) { - throw new Error('Claude dispatch requires text or an image') + throw new ClaudeDispatchContentError( + 'Claude dispatch requires text or an image', + agentSessionFailureFact('emptyMessage') + ) } return content } diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index 1945ac3531d..c540167de4c 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { dispatchClaudeTurn, resolveClaudeReplayTurn } from './claude-structured-dispatch' -import { readClaudeImage } from './claude-structured-dispatch-content' +import { ClaudeDispatchContentError, readClaudeImage } from './claude-structured-dispatch-content' import { claudeUnwrittenUserMessageError } from './claude-agent-sdk-user-message-queue' import type { ClaudeSession } from './claude-structured-session-state' import { @@ -379,7 +379,11 @@ describe('Claude structured dispatch image limits', () => { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) }) - ).resolves.toEqual({ state: 'rejected', reason: 'provider_write_failed: broken pipe' }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'provider_write_failed', + rejection: { kind: 'writeFailed' } + }) expect(session.dispatchWaiters).toEqual([firstWaiter]) const firstUuid = (firstWaiter as { sentUuid?: string }).sentUuid @@ -392,6 +396,7 @@ describe('Claude structured dispatch image limits', () => { }) it('does not let a provably unwritten attempt block retry correlation', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const send = vi .fn() .mockRejectedValueOnce(claudeUnwrittenUserMessageError(new Error('broken pipe'))) @@ -401,7 +406,17 @@ describe('Claude structured dispatch image limits', () => { await expect( dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) - ).resolves.toEqual({ state: 'rejected', reason: 'provider_write_failed: broken pipe' }) + ).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. + expect(warn).toHaveBeenCalledWith( + '[claude-dispatch] message could not be handed to Claude:', + expect.objectContaining({ message: expect.stringContaining('broken pipe') }) + ) + warn.mockRestore() expect(session.dispatchWaiters).toHaveLength(0) expect(session.retiredDispatchWaiters).toHaveLength(0) @@ -684,66 +699,6 @@ describe('Claude structured dispatch image limits', () => { }) }) - it('rejects more than twenty URL images before sending', async () => { - const session = sessionFor() - const body = userMessage( - Array.from({ length: 21 }, (_, index) => ({ - type: 'image-ref' as const, - url: `https://example.test/${index}.png` - })) - ) - - await expect( - dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) - ).resolves.toEqual({ state: 'rejected', reason: 'Claude messages support at most 20 images' }) - expect(session.connection.send).not.toHaveBeenCalled() - }) - - it('rejects local images whose aggregate size exceeds twenty MiB', async () => { - const directory = await mkdtemp(join(tmpdir(), 'orca-claude-images-')) - try { - const paths = await Promise.all( - Array.from({ length: 5 }, async (_, index) => { - const path = join(directory, `${index}.png`) - await writeFile(path, Buffer.alloc(5 * 1024 * 1024)) - return path - }) - ) - const session = sessionFor() - const body = userMessage(paths.map((path) => ({ type: 'image-ref' as const, path }))) - - await expect( - dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) - ).resolves.toEqual({ - state: 'rejected', - reason: `Claude images must total no more than ${20 * 1024 * 1024} bytes` - }) - expect(session.connection.send).not.toHaveBeenCalled() - } finally { - await rm(directory, { recursive: true, force: true }) - } - }) - - it('rejects a local image by actual bytes read beyond the per-image cap', async () => { - const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) - try { - const path = join(directory, 'oversized.png') - await writeFile(path, Buffer.alloc(5 * 1024 * 1024 + 1)) - const session = sessionFor() - const body = userMessage([{ type: 'image-ref', path }]) - - await expect( - 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` - }) - expect(session.connection.send).not.toHaveBeenCalled() - } finally { - await rm(directory, { recursive: true, force: true }) - } - }) - it('allocates local image reads from the file size, not the maximum cap', async () => { const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) const allocUnsafe = vi.spyOn(Buffer, 'allocUnsafe') @@ -821,8 +776,11 @@ describe('Claude structured dispatch image limits', () => { read, close: vi.fn().mockResolvedValue(undefined) } as never) - await expect(readClaudeImage('/controlled/growing.png', open)).rejects.toThrow( - `Claude image must be a non-empty file no larger than ${5 * 1024 * 1024} bytes` + // It changed while Orca read it: unreadable, never a limit the image did not break. + const rejected = await readClaudeImage('/controlled/growing.png', open).catch( + (error: unknown) => error ) + expect(rejected).not.toBeInstanceOf(ClaudeDispatchContentError) + expect(rejected).toMatchObject({ message: 'Claude image changed while it was read' }) }) }) diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 5a5b2796595..220542fab91 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -19,22 +19,22 @@ import type { import { readClaudeFrameString } from './claude-structured-init-proof' import { claudeDispatchContentKey, + claudeDispatchContentRejection, claudeDispatchInvokesSlashCommand, - claudeDispatchMessageContent + claudeDispatchMessageContent, + claudeDispatchRejection } from './claude-structured-dispatch-content' import { dispatchWriteOutcomeUnknownReason } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' -import { - DISPATCH_REJECTED_CANCELLED, - DISPATCH_REJECTED_QUEUE_FULL, - dispatchWriteFailureReason -} from '../../shared/structured-agent-session-dispatch-rejection' +import { DISPATCH_REJECTED_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection' +import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' import { claudeUnwrittenUserMessageError, claudeUserMessageWasProvablyUnwritten } from './claude-agent-sdk-user-message-queue' import { AgentSessionPreDispatchError } from '../native-chat/agent-session-wire/structured-agent-session-operation-settlement' import { - claudeStartupFailureReason, + claudeStartupFailureFact, failClaudeStartup } from './claude-structured-session-startup-state' @@ -214,7 +214,7 @@ export function settleCancelledClaudeDispatchWaiters( onSettledLate?.({ clientMessageId: waiter.clientMessageId, state: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED + ...claudeDispatchRejection(agentSessionFailureFact('cancelled')) }) } } @@ -231,6 +231,12 @@ 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) + return claudeDispatchRejection(agentSessionFailureFact('writeFailed')) +} + export async function dispatchClaudeTurn( session: ClaudeSession, input: { clientMessageId?: string; body: AgentJournalMessageItem; requestedAt?: number }, @@ -240,14 +246,14 @@ export async function dispatchClaudeTurn( try { content = await claudeDispatchMessageContent(input.body) } catch (error) { - return { state: 'rejected', reason: (error as Error).message } + return { state: 'rejected', ...claudeDispatchContentRejection(error) } } if (session.dispatchWaiters.length >= MAX_ACTIVE_DISPATCH_WAITERS) { - return { state: 'rejected', reason: DISPATCH_REJECTED_QUEUE_FULL } + return { state: 'rejected', ...claudeDispatchRejection(agentSessionFailureFact('queueFull')) } } - const startupFailure = claudeStartupFailureReason(session) + const startupFailure = claudeStartupFailureFact(session) if (startupFailure) { - return { state: 'rejected', reason: startupFailure } + return { state: 'rejected', ...claudeDispatchRejection(startupFailure) } } // 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. @@ -293,7 +299,7 @@ export async function dispatchClaudeTurn( if (error instanceof AgentSessionPreDispatchError) { throw error } - return { state: 'rejected', reason: dispatchWriteFailureReason(error) } + return { state: 'rejected', ...claudeWriteFailureRejection(error) } } const waiter = replay.waiter if (waiter.settledUuid) { @@ -311,7 +317,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', reason: dispatchWriteFailureReason(error) } + return { state: 'rejected', ...claudeWriteFailureRejection(error) } } if (!waiter.retired) { retireWaiter(session, waiter) diff --git a/src/main/claude/claude-structured-init-proof.ts b/src/main/claude/claude-structured-init-proof.ts index c29cb2d4715..0d4af831ea9 100644 --- a/src/main/claude/claude-structured-init-proof.ts +++ b/src/main/claude/claude-structured-init-proof.ts @@ -64,7 +64,8 @@ export function claudeInitializationAuthError( isRecord(initialization) && isRecord(initialization.account) ? initialization.account : null return readClaudeFrameString(account ?? {}, 'tokenSource') === 'none' ? new AgentSessionAcquisitionRefusal( - 'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.' + 'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.', + 'notSignedIn' ) : null } diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index c54d1a5179f..3d7592c8406 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -492,16 +492,17 @@ describe('claude structured launch resolution', () => { gate = WSL_ONLY_NORMALIZED - // Reacquire after the account state changed: refused before anything spawns. - await expect(resolve({ identity: identityAt('leaf-current') })).rejects.toBeInstanceOf( - AgentSessionPreSpawnError - ) + // Reacquire after the account state changed: refused before anything spawns, naming the + // account shape a person can change. + const refused = resolve({ identity: identityAt('leaf-current') }) + await expect(refused).rejects.toBeInstanceOf(AgentSessionPreSpawnError) + await expect(refused).rejects.toMatchObject({ reason: 'managedAccountUnsupported' }) }) - it('fails closed when the account state cannot be read', async () => { - await expect( - resolverWithGate(() => null)({ identity: identityAt('leaf-current') }) - ).rejects.toBeInstanceOf(AgentSessionPreSpawnError) + it('fails closed when the account state cannot be read, naming no situation', async () => { + const refused = resolverWithGate(() => null)({ identity: identityAt('leaf-current') }) + await expect(refused).rejects.toBeInstanceOf(AgentSessionPreSpawnError) + await expect(refused).rejects.toMatchObject({ reason: undefined }) }) it('keeps resolving when no gate is wired, so other embedders are unaffected', async () => { diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index a2b3d41bbfd..d5d0542cb9d 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -22,6 +22,7 @@ import { } from '../claude-accounts/live-pty-gate' import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { + hasWslBoundClaudeAccount, structuredClaudeMatchesActiveManagedAccount, type ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' @@ -172,7 +173,9 @@ export async function resolveClaudeStructuredInvocation( // Under a managed account the pinned credential is the only auth this launch may // use, so an explicit override is refused rather than silently beating the pin. if (auth.stripAuthEnv && hasClaudeAuthEnvConflict(overlay)) { - throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE), { + reason: 'managedAccountEnvOverride' + }) } // Why the overlay merges onto the inherited env rather than replacing it: the child // still needs PATH and the rest of the shell environment, and withCliRuntimeOnPath @@ -209,7 +212,9 @@ export async function assertClaudeAuthSwitchSettled( timeoutMs = CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS ): Promise { if (!(await whenClaudeAuthSwitchSettles(timeoutMs))) { - throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE), { + reason: 'accountSwitchInProgress' + }) } } @@ -247,12 +252,12 @@ export function createClaudeStructuredLaunchResolver( // Every acquisition, not just the first: the account state can change under a live session, and // a reacquire after an unexpected exit would otherwise spawn under whatever it has become. // Codex has no gate here — it resolves its account on a different path. - if ( - deps.readManagedAccountGate && - !structuredClaudeMatchesActiveManagedAccount(deps.readManagedAccountGate()) - ) { + const gate = deps.readManagedAccountGate?.() + if (gate !== undefined && !structuredClaudeMatchesActiveManagedAccount(gate)) { + // Unreadable account state names no situation a person can act on, so only the log reads it. throw new AgentSessionPreSpawnError( - 'structured Claude is not offered under the active managed Claude account' + 'structured Claude is not offered under the active managed Claude account', + gate && hasWslBoundClaudeAccount(gate) ? { reason: 'managedAccountUnsupported' } : {} ) } const head = agentSessionProviderHandleChainHead(record.providerHandleChain) diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index 5a8d8466822..2f0b7ac4b70 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -58,7 +58,8 @@ export function setClaudeStructuredSessionOption( if (session.startup.state !== 'proven') { return Promise.reject( new AgentSessionOptionRejectedError( - 'Claude is still starting; options can be changed once it is ready.' + 'Claude is still starting; options can be changed once it is ready.', + 'providerStarting' ) ) } diff --git a/src/main/claude/claude-structured-prompt-ownership.test.ts b/src/main/claude/claude-structured-prompt-ownership.test.ts index 678c6a4f1e2..3c290fa5184 100644 --- a/src/main/claude/claude-structured-prompt-ownership.test.ts +++ b/src/main/claude/claude-structured-prompt-ownership.test.ts @@ -269,7 +269,8 @@ describe('Claude live prompt ownership', () => { sessionId: 'session-1', clientMessageId: 'queued-message', state: 'rejected', - reason: 'provider_cancelled_before_start' + reason: 'provider_cancelled_before_start', + rejection: { kind: 'cancelled' } }) }) diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index baa6cb83a52..e107633173c 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -16,6 +16,11 @@ import { } from './claude-structured-item-translation' import { claudeResultOutcome } from './claude-result-outcome' import { rootClaudeRowStamp, type ClaudeRowStamp } from './claude-provisional-row-corrections' +import { + CLAUDE_API_RETRY_FRAME_KIND, + claudeApiRetryRowBody, + createClaudeApiRetryRuns +} from './claude-api-retry-row' export function claudeProviderFrameKind(message: Record): string { const type = claudeText(message.type) ?? 'unknown' @@ -121,9 +126,23 @@ export function createClaudeProviderFrameFallback( ) => boolean } { let sequence = 0 + const retryRun = createClaudeApiRetryRuns() return { append: (kind, payload, displayText, beforeAppend, options, stamp) => { sequence += 1 + const retrying = kind === CLAUDE_API_RETRY_FRAME_KIND ? claudeRecord(payload) : null + if (retrying) { + beforeAppend?.() + // One row per retry run, revised by each attempt, never the frame as a row. + const identity = { + provider: 'orca', + clientMessageId: `provider-retry:claude:${acquisitionId}:${retryRun(retrying)}` + } as const + const body = claudeApiRetryRowBody(retrying) + sink.appendItem(identity, body, (stamp ?? rootClaudeRowStamp)(identity, body)) + sink.publish() + return true + } const translated = unhandledProviderFrameJournalItem( 'claude', kind, diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 6728f960ef0..372e1f8eb94 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -30,6 +30,7 @@ import { type ClaudeAcquireCallbacks } from './claude-structured-session-state' import { resolveClaudeAcquisitionError } from './claude-structured-session-close' +import { withObservedProviderExit } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { readClaudeTranscriptEntryUuid } from './claude-transcript-entry-uuid' import { persistClaudeTurnResumePoint } from './claude-structured-resume-point' import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation' @@ -58,7 +59,9 @@ export async function acquireClaudeSession({ // A managed-account switch is mid-swap of the pinned credential home; refuse here, // before this acquisition cancels the previous attempt and closes the live session. if (isClaudeAuthSwitchInProgress()) { - throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)) + throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE), { + reason: 'accountSwitchInProgress' + }) } const sessionId = input.identity.sessionId const prompts = new ClaudePromptRegistry() @@ -178,6 +181,8 @@ export async function acquireClaudeSession({ initProof.reject(error) }, onExit: (error) => { + // The child exited on its own; marked in place, as the fault report may hold this error. + withObservedProviderExit(error) childEnded ??= error initProof.reject(error) callbacks.handleExit(sessionId, attempt, error) diff --git a/src/main/claude/claude-structured-session-adapter-turns.test.ts b/src/main/claude/claude-structured-session-adapter-turns.test.ts index caafee6a94b..c77d5ee0a91 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -68,7 +68,11 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { body: USER_MESSAGE, fence: 7 }) - ).resolves.toEqual({ state: 'rejected', reason: 'provider_write_failed: broken pipe' }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'provider_write_failed', + rejection: { kind: 'writeFailed' } + }) }) it('requires an acknowledged interrupt and supports controlled options', async () => { diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 4c67da4fa26..29b51e730ae 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -1,3 +1,4 @@ +import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import { compactClaudeSession, observeClaudeCompaction } from './claude-structured-compaction' import type { AgentSessionAcquisition, @@ -13,7 +14,7 @@ import { supportsClaudeStructuredLocation } from './claude-structured-location-s import { setClaudeStructuredSessionOption } from './claude-structured-options' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' import { - claudeStartupFailureReason, + claudeStartupFailureFact, claudeStartupSettledWithin } from './claude-structured-session-startup-state' import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' @@ -120,13 +121,13 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda /** Resolves once a published session's startup has landed, faulted, or been ended by a close; * with the reason when it did not land. */ - awaitStarted = async (sessionId: string): Promise => { + awaitStarted = async (sessionId: string): Promise => { const session = this.sessions.get(sessionId) if (!session) { return } await session.startup.settled - return claudeStartupFailureReason(session) ?? undefined + return claudeStartupFailureFact(session) ?? undefined } /** Restart reconciliation reads the transcript a resume replays; these maps track liveness. */ diff --git a/src/main/claude/claude-structured-session-exit-blame.test.ts b/src/main/claude/claude-structured-session-exit-blame.test.ts new file mode 100644 index 00000000000..79430131e15 --- /dev/null +++ b/src/main/claude/claude-structured-session-exit-blame.test.ts @@ -0,0 +1,66 @@ +// After a Claude start lands, only the child's own exit says Claude stopped; a fault on Orca's +// side that makes Orca close the child is Orca's. + +import { describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' +import { + adapterFor, + fakeClaude, + identityFor, + PROVIDER_SESSION_ID +} from './claude-structured-session-test-support' + +async function startedClaude(sink: StructuredAgentSessionEventSink) { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) + const ended = async () => { + await vi.waitFor(() => expect(events.some((event) => event.type === 'ended')).toBe(true)) + return events.find((event) => event.type === 'ended') + } + return { connection: claude.connections[0], ended } +} + +describe('what a started Claude session says ended it', () => { + it('says Claude stopped when the child exits on its own', async () => { + const { connection, ended } = await startedClaude({ + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {} + }) + + connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): killed')) + + expect(await ended()).toMatchObject({ + cause: 'unexpected-exit', + failure: { kind: 'providerExited' } + }) + }) + + it('blames Orca when a journal fault makes Orca close the child', async () => { + const { connection, ended } = await startedClaude({ + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + tryAppendResolvedItemAndPublish: () => ({ accepted: false, reason: 'failed' }) + }) + + connection.handlers.onMessage?.({ + type: 'system', + subtype: 'task_notification', + session_id: PROVIDER_SESSION_ID, + task_id: 'task-1', + status: 'failed', + summary: 'failed' + }) + + expect(await ended()).toMatchObject({ + reason: 'claude background task journal sink failed', + cause: 'unexpected-exit', + failure: { kind: 'hostFault' } + }) + expect(connection.closeCount).toBeGreaterThanOrEqual(1) + }) +}) diff --git a/src/main/claude/claude-structured-session-exit-lifecycle.ts b/src/main/claude/claude-structured-session-exit-lifecycle.ts index d0bddaf0a11..854646490a8 100644 --- a/src/main/claude/claude-structured-session-exit-lifecycle.ts +++ b/src/main/claude/claude-structured-session-exit-lifecycle.ts @@ -1,3 +1,8 @@ +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' +import { + providerExitObserved, + providerStartupFailureFact +} from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { settledClaudeTurnEndLeaf } from './claude-structured-resume-point' import { claudeRootExitObserved, @@ -32,6 +37,8 @@ export function observeClaudeSessionExit( return } lifecycle.sessions.delete(sessionId) + // Not marked here: startup and journal faults end the session this way too. The child's own + // exit arrives already marked by the connection's exit callback. failClaudeStartup(session, error) // Re-enter the provider's close ladder before publishing lifecycle recovery. // An exit callback is root evidence only; the retained tree proof must run @@ -85,6 +92,16 @@ export function settleClaudeUnexpectedExit( type: 'ended', sessionId, reason: exit.error.message, + // A start that never landed says why it failed. After it landed, only the child's own exit + // blames the provider; an Orca fault that closed it is Orca's. + failure: + exit.session.startup.state !== 'proven' + ? providerStartupFailureFact(exit.session.startup.failure ?? exit.error) + : providerExitObserved(exit.error) + ? agentSessionFailureFact('providerExited', { + detail: providerDiagnosticOf(exit.error) + }) + : agentSessionFailureFact('hostFault'), cause: 'unexpected-exit', fence: exit.session.fence, acquisitionGeneration: exit.session.acquisitionGeneration, diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts index 8ddc29a2e67..a5647caee84 100644 --- a/src/main/claude/claude-structured-session-recovery.test.ts +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -417,6 +417,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { type: 'ended', sessionId: 'session-1', reason: 'crashed before replacement', + failure: { kind: 'providerExited' }, cause: 'unexpected-exit', fence: 7, acquisitionGeneration: firstAcquisition.acquisitionGeneration, diff --git a/src/main/claude/claude-structured-session-startup-state.ts b/src/main/claude/claude-structured-session-startup-state.ts index 1bda560377a..cf44bd984ab 100644 --- a/src/main/claude/claude-structured-session-startup-state.ts +++ b/src/main/claude/claude-structured-session-startup-state.ts @@ -3,7 +3,8 @@ // options restored): the host's delivery loop waits on `settled` before it hands a message over, // so a first turn never runs under defaults the restore was about to replace. -import { providerStartupFailureRejection } from '../native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement' +import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' +import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import type { ClaudeSession } from './claude-structured-session-state' export type ClaudeSessionStartup = { @@ -24,9 +25,9 @@ export function createClaudeSessionStartup(): ClaudeSessionStartup { return { state: 'pending', failure: null, settled: ended, end } } -export function claudeStartupFailureReason(session: ClaudeSession): string | null { +export function claudeStartupFailureFact(session: ClaudeSession): SubmissionRejectionFact | null { return session.startup.state === 'failed' - ? providerStartupFailureRejection(session.startup.failure ?? undefined) + ? providerStartupFailureFact(session.startup.failure ?? undefined) : null } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index b671dd8b632..a0b0f321679 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -1,3 +1,5 @@ +import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' +import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import type { AgentJournalItemIdentity, AgentSessionJournalIdentity @@ -66,6 +68,7 @@ export type ClaudeStructuredSessionEvent = type: 'ended' sessionId: string reason: string + failure?: SubmissionRejectionFact /** Present for first-hand child exits so the host can fence recovery. */ cause?: 'unexpected-exit' | 'requested-close' fence?: number @@ -81,7 +84,7 @@ export type ClaudeLateDispatchOutcome = clientMessageId: string providerIdentity: AgentJournalItemIdentity } - | { clientMessageId: string; state: 'rejected'; reason: string } + | ({ clientMessageId: string; state: 'rejected' } & AgentJournalDispatchRejection) export type ClaudeStructuredSessionAdapterDeps = { resolveLaunch: (input: { diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index aa5a4474235..4e7cd016bd6 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events' import { realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { PassThrough } from 'node:stream' +import { providerDiagnosticOf } from '../../shared/agent-session-failure' import { afterEach, describe, expect, it, vi } from 'vitest' import type { spawnProcess } from '../../shared/child-process/run-process' import { @@ -293,6 +294,9 @@ describe('openCodexAppServerConnection', () => { expect(isCodexAppServerRequestError(refusal)).toBe(true) expect((refusal as Error).message).toContain('bad params') + // Codex's own words, apart from Orca's prefix, for a person to read. + expect(providerDiagnosticOf(refusal)).toEqual({ text: 'bad params', audience: 'person' }) + expect(providerDiagnosticOf(missing)).toBeUndefined() expect(isCodexAppServerUnsupportedError(missing)).toBe(true) expect(isCodexAppServerRequestError(missing)).toBe(false) await connection.close() diff --git a/src/main/codex/codex-app-server-exit-error.test.ts b/src/main/codex/codex-app-server-exit-error.test.ts new file mode 100644 index 00000000000..63ec9a63743 --- /dev/null +++ b/src/main/codex/codex-app-server-exit-error.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { providerDiagnosticOf } from '../../shared/agent-session-failure' +import { buildCodexAppServerExitError } from './codex-app-server-exit-error' + +describe('buildCodexAppServerExitError', () => { + it("keeps the stderr tail apart from Orca's wording, as log text", () => { + const error = buildCodexAppServerExitError(' thread panicked at main.rs:4 ') + expect(error.message).toBe('codex app-server connection ended: thread panicked at main.rs:4') + expect(providerDiagnosticOf(error)).toEqual({ + text: 'thread panicked at main.rs:4', + audience: 'log' + }) + }) + + it('carries no diagnostic when a cause, not the stderr, explains the end', () => { + const error = buildCodexAppServerExitError('ignored', new Error('spawn codex ENOENT')) + expect(providerDiagnosticOf(error)).toBeUndefined() + expect(providerDiagnosticOf(buildCodexAppServerExitError(''))).toBeUndefined() + }) +}) diff --git a/src/main/codex/codex-app-server-exit-error.ts b/src/main/codex/codex-app-server-exit-error.ts index 85d8bc7dd6e..8812e6698a0 100644 --- a/src/main/codex/codex-app-server-exit-error.ts +++ b/src/main/codex/codex-app-server-exit-error.ts @@ -2,6 +2,7 @@ // stderr tail is the only evidence: a CLI without the subcommand is a durable // capability fact, and anything else is this run's crash. +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-signal' import { CodexAppServerUnsupportedError } from './codex-app-server-session' @@ -9,11 +10,17 @@ const EXIT_DETAIL_MAX_CHARS = 400 export function buildCodexAppServerExitError(stderrTail: string, cause?: Error): Error { const tail = stderrTail.trim().slice(0, EXIT_DETAIL_MAX_CHARS) + // The tail is Codex's own stderr: a log, kept behind Details. + const diagnostic = providerDiagnostic(tail, 'log') if (stderrIndicatesMissingAppServer(stderrTail)) { - return new CodexAppServerUnsupportedError( - `codex CLI does not support the app-server subcommand: ${tail}` + return withProviderDiagnostic( + new CodexAppServerUnsupportedError( + `codex CLI does not support the app-server subcommand: ${tail}` + ), + diagnostic ) } const detail = cause ? `: ${cause.message}` : tail ? `: ${tail}` : '' - return new Error(`codex app-server connection ended${detail}`) + const error = new Error(`codex app-server connection ended${detail}`) + return cause ? error : withProviderDiagnostic(error, diagnostic) } diff --git a/src/main/codex/codex-app-server-record-dispatch.ts b/src/main/codex/codex-app-server-record-dispatch.ts index e76f2509399..b1d3605b38f 100644 --- a/src/main/codex/codex-app-server-record-dispatch.ts +++ b/src/main/codex/codex-app-server-record-dispatch.ts @@ -88,7 +88,8 @@ export function createCodexAppServerRecordDispatcher(input: { : new CodexAppServerRequestError( waiter.method, typeof error.code === 'number' ? error.code : null, - `codex app-server ${waiter.method} failed: ${detail}` + `codex app-server ${waiter.method} failed: ${detail}`, + typeof error.message === 'string' ? error.message : undefined ) ) return diff --git a/src/main/codex/codex-app-server-request-error.ts b/src/main/codex/codex-app-server-request-error.ts index 0dbefaca965..0e7cc719017 100644 --- a/src/main/codex/codex-app-server-request-error.ts +++ b/src/main/codex/codex-app-server-request-error.ts @@ -1,12 +1,23 @@ +import { providerDiagnostic, type ProviderDiagnostic } from '../../shared/agent-session-failure' + /** Codex answered the call and refused it, rather than timing out or exiting. */ export class CodexAppServerRequestError extends Error { + /** Codex's own `error.message`, kept apart from the Orca text around it. */ + readonly providerDiagnostic?: ProviderDiagnostic + constructor( readonly method: string, readonly code: number | null, - message: string + message: string, + providerMessage?: string ) { super(message) this.name = 'CodexAppServerRequestError' + const diagnostic = + providerMessage === undefined ? undefined : providerDiagnostic(providerMessage, 'person') + if (diagnostic) { + this.providerDiagnostic = diagnostic + } } } diff --git a/src/main/codex/codex-structured-compaction-refusal.test.ts b/src/main/codex/codex-structured-compaction-refusal.test.ts new file mode 100644 index 00000000000..437f32f0a8f --- /dev/null +++ b/src/main/codex/codex-structured-compaction-refusal.test.ts @@ -0,0 +1,28 @@ +// A compaction Codex refuses up front keeps Codex's own words for the chat's failure row. + +import { describe, expect, it } from 'vitest' +import { CodexAppServerRequestError } from './codex-app-server-connection' +import { acquired, fakeCodex } from './codex-structured-session-adapter-fixture' + +describe('Codex compaction refused at the request', () => { + it("carries Codex's message as the detail, apart from Orca's wrapper", async () => { + const codex = fakeCodex({ + 'thread/compact/start': () => { + throw new CodexAppServerRequestError( + 'thread/compact/start', + -32600, + 'codex app-server thread/compact/start failed: thread has nothing to compact', + 'thread has nothing to compact' + ) + } + }) + const adapter = await acquired(codex) + + await expect( + adapter.compact({ turnId: 'compact-1', sessionId: 'session-1', fence: 7 }) + ).resolves.toEqual({ + outcome: 'failed', + detail: { text: 'thread has nothing to compact', audience: 'person' } + }) + }) +}) diff --git a/src/main/codex/codex-structured-dispatch-admission.test.ts b/src/main/codex/codex-structured-dispatch-admission.test.ts index 5fc811b9382..b8ea81d592c 100644 --- a/src/main/codex/codex-structured-dispatch-admission.test.ts +++ b/src/main/codex/codex-structured-dispatch-admission.test.ts @@ -140,7 +140,12 @@ describe('codex dispatch admission', () => { const { CodexAppServerRequestError } = await import('./codex-app-server-connection') const codex = fakeCodexAppServer({ 'turn/start': () => { - throw new CodexAppServerRequestError('turn/start', -32602, 'thread not found') + throw new CodexAppServerRequestError( + 'turn/start', + -32602, + 'codex app-server turn/start failed: thread not found', + 'thread not found' + ) } }) const settlements: LateSettlement[] = [] @@ -148,9 +153,14 @@ describe('codex dispatch admission', () => { const connection = codex.connections[0]! startTurn(connection, 'turn-1') + // Codex's own words reach the sentence and the fact; Orca's prefix reaches neither. expect(await send(adapter, 'client-1')).toEqual({ state: 'rejected', - reason: 'thread not found' + reason: 'The provider did not accept this message: thread not found.', + rejection: { + kind: 'providerRejected', + detail: { text: 'thread not found', audience: 'person' } + } }) // A refused write is disarmed, so a later echo of that id settles nothing. @@ -347,7 +357,8 @@ describe('codex dispatch admission', () => { } expect(await send(adapter, 'client-overflow')).toEqual({ state: 'rejected', - reason: 'codex structured dispatch queue is full' + reason: 'codex structured dispatch queue is full', + rejection: { kind: 'queueFull' } }) echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u0', clientId: 'client-0' }) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index ae92bd48f54..b7262ea6fee 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -203,7 +203,7 @@ export async function acquireCodexStructuredSession(input: { primaryThreadId = opened.threadId const restoreAdmission = translator?.restoreThread(opened.threadId, opened.thread ?? {}) if (restoreAdmission && !restoreAdmission.accepted) { - throw new AgentSessionAcquisitionRefusal( + throw AgentSessionAcquisitionRefusal.historyTooLarge( 'Codex thread history exceeds the bounded restore queue; history was not partially imported.' ) } diff --git a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts index 6389cbe70d5..bcfd2f733f0 100644 --- a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts +++ b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts @@ -181,6 +181,7 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { type: 'ended', sessionId: 'session-1', reason: 'codex app-server connection ended', + failure: { kind: 'providerExited' }, cause: 'unexpected-exit', fence: 7, acquisitionGeneration: 'generation-1', diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 169772d4992..43b76536c64 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -466,7 +466,12 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { body: USER_MESSAGE, fence: 7 }) - ).toEqual({ state: 'rejected', reason: 'turn already running' }) + ).toEqual({ + // Built without Codex's own words, so nothing is quoted and no detail is invented. + state: 'rejected', + reason: 'The provider did not accept this message.', + rejection: { kind: 'providerRejected' } + }) }) it('rethrows a dead child so the wire settles the submission unknown', async () => { diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 8cb066511e9..68cdbd59329 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -257,12 +257,16 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap { threadId: session.threadId }, { timeoutMs: this.deps.requestTimeoutMs } ) - .catch((error) => { - if (isCodexAppServerRequestError(error)) { - return { error: error.message } + .then( + () => undefined, + (error) => { + if (isCodexAppServerRequestError(error)) { + const detail = error.providerDiagnostic + return { outcome: 'failed' as const, ...(detail ? { detail } : {}) } + } + throw error } - throw error - }) + ) }, input.onLateResult, input.turnId diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 0a1de244dca..56e1855c60f 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -1,3 +1,4 @@ +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import type { CodexAppServerConnection } from './codex-app-server-connection-types' import { closeProcessRegistry } from '../../shared/child-process/close-process-registry' import { @@ -14,6 +15,9 @@ export function handleCodexSessionExit(input: { sessionId: string connection: CodexAppServerConnection | null error: Error + /** Set by Orca's own close. Absent only from the connection's onExit, which the connection + * withholds while Orca is closing the child. */ + closedByOrca?: true prompts?: CodexSession['prompts'] allowFailedSettlement?: boolean onEvent?: (event: CodexStructuredSessionEvent) => void @@ -29,6 +33,10 @@ export function handleCodexSessionExit(input: { type: 'ended', sessionId: input.sessionId, reason: input.error.message, + // Only the child's own exit blames Codex; a close Orca made, for any reason, is Orca's. + failure: input.closedByOrca + ? agentSessionFailureFact('hostFault') + : agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(input.error) }), cause: session.requestedClose ? 'requested-close' : 'unexpected-exit', fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, @@ -93,6 +101,7 @@ export async function closeCodexPublishedSession( sessionId, connection: session.connection, error: options?.unexpectedReason ?? new Error('codex session closed'), + closedByOrca: true, prompts: session.prompts, ...(options?.allowFailedSettlement ? { allowFailedSettlement: true } : {}), ...(onEvent ? { onEvent } : {}) diff --git a/src/main/codex/codex-structured-session-exit-blame.test.ts b/src/main/codex/codex-structured-session-exit-blame.test.ts new file mode 100644 index 00000000000..591f0e61ae8 --- /dev/null +++ b/src/main/codex/codex-structured-session-exit-blame.test.ts @@ -0,0 +1,68 @@ +// Only the Codex app-server child's own exit says Codex stopped; a close Orca made — a journal +// sink that could not take a frame, a forced close — is Orca's. + +import { describe, expect, it, vi } from 'vitest' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { adapterFor, fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' +import type { CodexStructuredSessionEvent } from './codex-structured-session-state' + +function sink(overrides: Partial = {}) { + return { appendItem: vi.fn(), appendTombstone: vi.fn(), publish: vi.fn(), ...overrides } +} + +async function startedCodex(events: StructuredAgentSessionEventSink) { + const codex = fakeCodex() + const emitted: CodexStructuredSessionEvent[] = [] + const adapter = adapterFor(codex, {}, emitted) + await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 's', events }) + const ended = async () => { + await vi.waitFor(() => expect(emitted.some((event) => event.type === 'ended')).toBe(true)) + return emitted.filter((event) => event.type === 'ended') + } + return { adapter, connection: codex.connections[0], ended } +} + +describe('what a started Codex session says ended it', () => { + it('says Codex stopped when the child exits on its own', async () => { + const { connection, ended } = await startedCodex(sink()) + + connection.handlers.onExit?.(new Error('codex app-server connection ended: killed')) + + const [event] = await ended() + expect(event).toMatchObject({ cause: 'unexpected-exit', failure: { kind: 'providerExited' } }) + const failure = event && 'failure' in event ? event.failure : undefined + expect( + failure && agentSessionFailureWords(failure, { surface: 'row', agentName: 'Codex' }).text + ).toBe( + 'Codex stopped while this response was in progress. You can continue in this conversation.' + ) + }) + + it('blames Orca when a journal sink failure makes Orca close the child', async () => { + const { connection, ended } = await startedCodex( + sink({ tryAppendItem: () => ({ accepted: false, reason: 'failed' }) }) + ) + + connection.handlers.onUnhandledFrame?.('frame:invalid-json', '{') + + expect(await ended()).toMatchObject([ + { + reason: 'Codex provider frame frame:invalid-json could not be durably recorded (failed)', + cause: 'unexpected-exit', + failure: { kind: 'hostFault' } + } + ]) + expect(connection.closeCount).toBe(1) + }) + + it('blames Orca for a forced close', async () => { + const { adapter, ended } = await startedCodex(sink()) + + await expect(adapter.forceCloseSession('session-1')).resolves.toBe(true) + + expect(await ended()).toMatchObject([ + { cause: 'unexpected-exit', failure: { kind: 'hostFault' } } + ]) + }) +}) diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 2d22ce42f3e..0c46969d62f 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -1,3 +1,10 @@ +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection +} from '../../shared/agent-session-failure-words' +import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -7,7 +14,6 @@ import { } from './codex-app-server-connection' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' -import { DISPATCH_REJECTED_CODEX_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' // Writing a Codex turn and learning which message landed where, which are not @@ -115,6 +121,15 @@ export async function startCodexTurn( return true } +/** A message Codex rejected, in the words that name Codex and its legacy markers. */ +function codexDispatchRejection(failure: SubmissionRejectionFact): AgentJournalDispatchRejection { + return agentSessionFailureWords(failure, { + surface: 'rejection', + agentName: TUI_AGENT_DISPLAY_NAMES.codex, + provider: 'codex' + }) +} + /** * One submission's outcome as the wire must read it: admitted means Codex owns * the message and its identity settles on the echo, rejected is Codex answering @@ -128,13 +143,19 @@ export async function dispatchCodexTurn( ): Promise { try { if (!(await startCodexTurn(session, { ...input, timeoutMs }))) { - return { state: 'rejected', reason: DISPATCH_REJECTED_CODEX_QUEUE_FULL } + return { state: 'rejected', ...codexDispatchRejection(agentSessionFailureFact('queueFull')) } } } catch (error) { if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) { // Codex answered and declined, so no echo for this write can arrive. session.dispatchEchoes.disarm(input.clientMessageId) - return { state: 'rejected', reason: (error as Error).message } + // Codex's own words, when it gave any, are the one part of the error a person can use. + return { + state: 'rejected', + ...codexDispatchRejection( + agentSessionFailureFact('providerRejected', { detail: providerDiagnosticOf(error) }) + ) + } } // A timeout or transport failure can happen after the frame was written. // Keep the correlation armed so a later echo can prove delivery. diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts index d142672f8e5..6cc267a9054 100644 --- a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts @@ -16,7 +16,8 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' -import { dispatchWriteFailureReason } from '../../../shared/structured-agent-session-dispatch-rejection' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { digestPayload } from './journal-payload-bounds' import { reconcileSubmissions, @@ -180,7 +181,7 @@ describe('crash between provider accept and journal commit', () => { await journal.resolveDispatch({ clientMessageId: 'cm_write_failed', state: 'rejected', - reason: dispatchWriteFailureReason(new Error('broken pipe')), + ...agentSessionFailureWords(agentSessionFailureFact('writeFailed'), { surface: 'rejection' }), fence: 1 }) @@ -191,7 +192,7 @@ describe('crash between provider accept and journal commit', () => { // so recovery must not reopen it as doubt. expect(restarted.submissions()[0]).toMatchObject({ dispatchState: 'rejected', - reason: 'provider_write_failed: broken pipe' + reason: 'provider_write_failed' }) expect(restarted.submissions()[0]?.recovered).toBeUndefined() expect(hasUnansweredStructuredAgentSessionDispatch(restarted.submissions())).toBe(false) @@ -250,7 +251,9 @@ describe('crash between provider accept and journal commit', () => { await restarted.resolveDispatch({ clientMessageId: 'cm_1', state: 'rejected', - reason: 'not_delivered', + ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { + surface: 'rejection' + }), fence: 2, recovered: true }) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts index 70052865242..668232e607d 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts @@ -4,9 +4,9 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { + classifyDispatchRejection, DISPATCH_REJECTED_WRITE_FAILED, - dispatchRejectionReasonIsInternal, - dispatchRejectionWasTransportWriteFailure + isWriteFailureSubmission } from '../../../shared/structured-agent-session-dispatch-rejection' import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import type { openAgentSessionJournal } from './journal-store-factory' @@ -45,7 +45,14 @@ async function settle(reason: string): Promise { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, fence: 1 }) - await journal.resolveDispatch({ clientMessageId: 'msg-1', state: 'rejected', reason, fence: 1 }) + await journal.resolveDispatch({ + clientMessageId: 'msg-1', + // A rejection's reason comes only from `agentSessionFailureWords`; an unknown's is still free + // text (an adapter's error), so it is what reaches the bound. + state: 'unknown', + reason, + fence: 1 + }) return journal.snapshot().submissions[0]?.reason ?? null } @@ -81,7 +88,8 @@ describe('dispatch reason bounding', () => { it('keeps a clipped transport failure classifiable', async () => { const stored = await settle(`${DISPATCH_REJECTED_WRITE_FAILED}: ${HUGE}`) expect(stored).not.toBe(`${DISPATCH_REJECTED_WRITE_FAILED}: ${HUGE}`) - expect(dispatchRejectionWasTransportWriteFailure(stored)).toBe(true) - expect(dispatchRejectionReasonIsInternal(stored)).toBe(true) + // Read as an older host wrote it: by the reason alone. + expect(isWriteFailureSubmission({ reason: stored })).toBe(true) + expect(classifyDispatchRejection({ reason: stored })).toMatchObject({ kind: 'writeFailed' }) }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts new file mode 100644 index 00000000000..470ed3dfcb2 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -0,0 +1,68 @@ +// How a `dispatch` row settles its submission. Field by field, so a key the row gains must be +// copied here to reach any reader. + +import { + readAgentSessionFailureFact, + type UnreadAgentSessionFailureFact +} from '../../../shared/agent-session-failure' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { JournalReducerState } from './journal-reducer' +import type { JournalRow } from './journal-row-schema' + +export function applyJournalDispatchRow( + state: JournalReducerState, + row: Extract +): void { + const submission = state.submissions.get(row.clientMessageId) + if (!submission) { + return + } + // `rejected` is terminal; a late `unknown` must not reopen a settled answer. + if (submission.dispatchState === 'rejected' || submission.dispatchState === 'accepted') { + return + } + submission.fence = row.fence + submission.dispatchState = row.state + submission.providerItemId = row.providerItemId + submission.reason = row.reason + // Read where it can be placed; a kind it cannot place is kept as written, so the classifier + // still knows a fact was there without this build claiming what it says. + const rejection = + row.state === 'rejected' + ? (readAgentSessionFailureFact(row.rejection) ?? unreadFailureFact(row.rejection)) + : undefined + if (rejection) { + submission.rejection = rejection + } else { + delete submission.rejection + } + submission.resolvedAt = row.state === 'pending' ? null : row.ts + if (row.state === 'pending') { + submission.handedOverAt = row.ts + } + if (row.recovered) { + submission.recovered = row.recovered + } else { + delete submission.recovered + } + if (row.state !== 'accepted' || !row.providerItemId) { + return + } + state.aliases.set(row.providerItemId, agentJournalSubmissionKey(row.clientMessageId)) + state.receipts.set(row.clientMessageId, { + clientMessageId: row.clientMessageId, + providerItemId: row.providerItemId, + cursor: { epoch: row.epoch, sequence: row.seq }, + acceptedAt: row.ts + }) +} + +function unreadFailureFact(value: unknown): UnreadAgentSessionFailureFact | undefined { + return typeof value === 'object' && + value !== null && + 'kind' in value && + typeof value.kind === 'string' && + value.kind + ? { kind: value.kind } + : undefined +} diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts new file mode 100644 index 00000000000..d2e1bf3b350 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts @@ -0,0 +1,106 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { openJournalDatabase } from './journal-database' +import { classifyJournalOpenFailure } from './journal-open-failure' +import { loadJournal } from './journal-open' +import { journalDatabaseFile } from './journal-paths' + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-open-failure-')) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +/** What the journal's own open throws for the file as it stands. */ +function openFailure(): unknown { + try { + loadJournal(root, 'session-1') + } catch (error) { + return error + } + throw new Error('the journal opened') +} + +/** A thrown value shaped as node:sqlite shapes one, with the result code it reports. */ +function nodeSqliteError(errcode: number): Error { + return Object.assign(new Error('sqlite'), { code: 'ERR_SQLITE_ERROR', errcode }) +} + +function systemError(code: string, errno: number): Error { + return Object.assign(new Error(`${code}: open`), { code, errno }) +} + +describe('classifyJournalOpenFailure', () => { + it('calls a journal that is not a database corrupt', async () => { + await writeFile(journalDatabaseFile(root), 'not a database '.repeat(64)) + const error = openFailure() + expect(error).toMatchObject({ errcode: 26 }) + expect(classifyJournalOpenFailure(error)).toBe('journalCorrupt') + }) + + it('calls a journal whose pages are damaged corrupt', async () => { + const path = journalDatabaseFile(root) + const opened = openJournalDatabase(path) + opened.db.exec('PRAGMA journal_mode = DELETE') + opened.db.close() + const bytes = await readFile(path) + // Page 1 holds the header and schema; every table's root page follows it. + bytes.fill(0xab, 4096) + await writeFile(path, bytes) + const error = openFailure() + expect(error).toMatchObject({ errcode: 11 }) + expect(classifyJournalOpenFailure(error)).toBe('journalCorrupt') + }) + + it.each([ + ['SQLITE_CORRUPT_VTAB', 267], + ['SQLITE_CORRUPT_SEQUENCE', 523], + ['SQLITE_CORRUPT_INDEX', 779] + ])('reads an extended corrupt code as corrupt: %s', (_name, errcode) => { + expect(classifyJournalOpenFailure(nodeSqliteError(errcode))).toBe('journalCorrupt') + }) + + it("reads the Bun driver's corrupt code as corrupt", () => { + const error = Object.assign(new Error('file is not a database'), { + name: 'SQLiteError', + code: 'SQLITE_NOTADB', + errno: 26 + }) + expect(classifyJournalOpenFailure(error)).toBe('journalCorrupt') + }) + + it('finds corruption a wrapper names as its cause', () => { + const wrapped = new Error('opening the conversation failed', { + cause: new Error('the journal would not open', { cause: nodeSqliteError(11) }) + }) + expect(classifyJournalOpenFailure(wrapped)).toBe('journalCorrupt') + }) + + it.each([ + ['a busy database', nodeSqliteError(5)], + ['a locked database', nodeSqliteError(6)], + ['a database SQLite cannot open', nodeSqliteError(14)], + ['a disk I/O error', nodeSqliteError(10)], + ['permission denied', systemError('EACCES', -13)], + ['too many open files', systemError('EMFILE', -24)], + ['a Windows error whose low byte reads as corrupt', systemError('EUNKNOWN', -4085)], + ['an error that only claims a corrupt code', Object.assign(new Error('x'), { errcode: 11 })], + ['a thrown string', 'database disk image is malformed'], + ['nothing at all', undefined] + ])('calls any other failure one that can clear: %s', (_label, error) => { + expect(classifyJournalOpenFailure(error)).toBe('journalUnavailable') + }) + + it('stops on a cause chain that loops back on itself', () => { + const first = new Error('first') + const second = new Error('second', { cause: first }) + Object.assign(first, { cause: second }) + expect(classifyJournalOpenFailure(first)).toBe('journalUnavailable') + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.ts new file mode 100644 index 00000000000..1835f691cb7 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.ts @@ -0,0 +1,22 @@ +// What a failed journal open means for the chat: its history is damaged, which no retry reads +// past, or the open failed in a way that can clear (a lock, permissions, too many open files). + +import type { AgentSessionRefusalReason } from '../../../shared/agent-session-refusal-details' +import { isSqliteCorruption } from '../../sqlite/sqlite-read-failure' + +export type JournalOpenFailure = AgentSessionRefusalReason<'agent_session_journal_unreadable'> + +// Bounds a cause chain that loops back on itself. +const MAX_CAUSE_DEPTH = 8 + +/** Damage only where the storage says so; anything unproven can clear. */ +export function classifyJournalOpenFailure(error: unknown): JournalOpenFailure { + let current = error + for (let depth = 0; depth < MAX_CAUSE_DEPTH && current !== undefined; depth += 1) { + if (isSqliteCorruption(current)) { + return 'journalCorrupt' + } + current = current instanceof Error ? current.cause : undefined + } + return 'journalUnavailable' +} diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts index c356d7904b2..c252a57d8a5 100644 --- a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -1,3 +1,4 @@ +import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { DISPATCH_DOUBT_HOST_RESTARTED } from './journal-dispatch-doubt-reasons' @@ -40,7 +41,7 @@ export async function markJournalPendingSubmissionsUnknown( export async function rejectJournalPendingSubmissions( journal: AgentSessionJournal, fence: number, - reason: string + rejection: AgentJournalDispatchRejection ): Promise { const unwritten = journal .submissions() @@ -54,7 +55,7 @@ export async function rejectJournalPendingSubmissions( await journal.resolveDispatch({ clientMessageId: entry.clientMessageId, state: 'rejected', - reason, + ...rejection, fence, recovered: true }) @@ -66,7 +67,7 @@ export async function rejectJournalPendingSubmissions( export async function rejectJournalQueuedSubmissions( journal: AgentSessionJournal, fence: number, - reason: string, + rejection: AgentJournalDispatchRejection, which: (submission: AgentJournalSubmission) => boolean = () => true ): Promise { const queued = journal @@ -76,7 +77,7 @@ export async function rejectJournalQueuedSubmissions( await journal.resolveDispatch({ clientMessageId: entry.clientMessageId, state: 'rejected', - reason, + ...rejection, fence, recovered: true }) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts new file mode 100644 index 00000000000..3332e53b0b3 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts @@ -0,0 +1,100 @@ +// A dispatch row's typed rejection fact, as the reducer folds it onto the submission. + +import { describe, expect, it } from 'vitest' +import { + classifyDispatchRejection, + DISPATCH_REJECTED_QUEUE_FULL +} from '../../../shared/structured-agent-session-dispatch-rejection' +import { + applyJournalRow, + createJournalReducerState, + type JournalReducerState +} from './journal-reducer' +import type { JournalRow } from './journal-row-schema' + +const EPOCH = 'epoch-1' + +function base(seq: number): { v: number; epoch: string; seq: number; fence: number; ts: number } { + return { v: 1, epoch: EPOCH, seq, fence: 1, ts: 1_000 + seq } +} + +function fold(rows: JournalRow[]): JournalReducerState { + const state = createJournalReducerState('session-1', EPOCH) + for (const row of rows) { + applyJournalRow(state, row) + } + return state +} + +describe('a rejected dispatch', () => { + const submission: JournalRow = { + kind: 'submission', + clientMessageId: 'cm_1', + payloadFingerprint: 'fp_1', + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + ...base(1) + } + + it("copies a rejection's typed fact onto the submission, and only on `rejected`", () => { + const rejection = { kind: 'providerRejected', detail: { text: 'Too long', audience: 'person' } } + const state = fold([ + submission, + { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'rejected', + providerItemId: null, + reason: 'The provider did not accept this message.', + rejection: { kind: 'providerRejected', detail: { text: 'Too long', audience: 'person' } }, + ...base(2) + } + ]) + expect(state.submissions.get('cm_1')).toMatchObject({ + dispatchState: 'rejected', + reason: 'The provider did not accept this message.', + rejection + }) + + const doubt = fold([ + { ...submission, clientMessageId: 'cm_2' }, + { + kind: 'dispatch', + clientMessageId: 'cm_2', + state: 'unknown', + providerItemId: null, + reason: 'provider_exited_before_acknowledgement', + rejection: { kind: 'writeFailed' }, + ...base(2) + } + ]) + expect(doubt.submissions.get('cm_2')).not.toHaveProperty('rejection') + }) + + it('keeps only the kind of a rejection fact it cannot place, so it reads as no verdict', () => { + const rejected = (rejection: unknown): JournalRow => { + const row: JournalRow = { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_QUEUE_FULL, + ...base(2) + } + // A row read from disk carries whatever the host that wrote it did. + return Object.assign(row, { rejection }) + } + // A newer host's kind: the marker beside it must not decide. + const newer = fold([submission, rejected({ kind: 'futureKind', detail: 'x' })]) + const settled = newer.submissions.get('cm_1') + expect(settled).toMatchObject({ dispatchState: 'rejected', rejection: { kind: 'futureKind' } }) + expect(settled && classifyDispatchRejection(settled)).toEqual({ + category: 'undelivered', + verdict: null + }) + // Not a fact at all: dropped, leaving the reason. + const malformed = fold([submission, rejected('queueFull')]).submissions.get('cm_1') + expect(malformed).not.toHaveProperty('rejection') + expect(malformed && classifyDispatchRejection(malformed)).toMatchObject({ kind: 'queueFull' }) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index cf0f3739acb..7ce1f4ef1c1 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -28,7 +28,8 @@ import { import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' import { journalItemRevisionIsStale } from './journal-item-revision' import type { JournalRow } from './journal-row-schema' -import { dispatchRejectionWasTransportWriteFailure } from '../../../shared/structured-agent-session-dispatch-rejection' +import { applyJournalDispatchRow } from './journal-dispatch-reducer' +import { isWriteFailureSubmission } from '../../../shared/structured-agent-session-dispatch-rejection' export const MAX_JOURNAL_APPLIED_SETTLEMENT_IDS = 4_096 @@ -114,7 +115,7 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo applySubmission(state, row) return } - applyDispatch(state, row) + applyJournalDispatchRow(state, row) } export function rememberAppliedSettlementId( @@ -166,7 +167,7 @@ export function resolveJournalItemId( .find( (candidate) => candidate.dispatchState !== 'rejected' && - !dispatchRejectionWasTransportWriteFailure(candidate.reason) && + !isWriteFailureSubmission(candidate) && candidate.payloadFingerprint === fingerprint && state.items.get(agentJournalSubmissionKey(candidate.clientMessageId))?.revision === 0 ) @@ -257,43 +258,6 @@ function applySubmission( upsertItem(state, itemId, 0, journalRenderItem(itemId, 0, row.body, row)) } -function applyDispatch( - state: JournalReducerState, - row: Extract -): void { - const submission = state.submissions.get(row.clientMessageId) - if (!submission) { - return - } - // `rejected` is terminal; a late `unknown` must not reopen a settled answer. - if (submission.dispatchState === 'rejected' || submission.dispatchState === 'accepted') { - return - } - submission.fence = row.fence - submission.dispatchState = row.state - submission.providerItemId = row.providerItemId - submission.reason = row.reason - submission.resolvedAt = row.state === 'pending' ? null : row.ts - if (row.state === 'pending') { - submission.handedOverAt = row.ts - } - if (row.recovered) { - submission.recovered = row.recovered - } else { - delete submission.recovered - } - if (row.state !== 'accepted' || !row.providerItemId) { - return - } - state.aliases.set(row.providerItemId, agentJournalSubmissionKey(row.clientMessageId)) - state.receipts.set(row.clientMessageId, { - clientMessageId: row.clientMessageId, - providerItemId: row.providerItemId, - cursor: { epoch: row.epoch, sequence: row.seq }, - acceptedAt: row.ts - }) -} - function acceptSubmissionFromProviderItem( state: JournalReducerState, providerItemId: string, diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts index 46d161b1172..4428e13953e 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts @@ -15,6 +15,9 @@ import { digestPayload } from './journal-payload-bounds' import { reconcileJournalSubmissionsAgainstHistory } from './journal-restart-reconciliation' import type { ProviderHistoryItem, ProviderHistoryWindow } from './journal-submission-reconciler' import { createTrackedJournalOpener } from './journal-store-test-open' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { classifyDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -122,7 +125,18 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { expect(settled).toEqual(['cm_1']) const submission = journal.submissions()[0] expect(submission?.dispatchState).toBe('rejected') - expect(submission?.reason).toBe('not_delivered') + // A sentence, since released clients print the reason as it is, and the fact beside it. + expect(submission?.reason).toBe( + agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { surface: 'rejection' }) + .reason + ) + expect(submission?.rejection).toEqual({ kind: 'notDelivered' }) + // Nobody failed: the crash stranded it before the provider took it. + expect(submission && classifyDispatchRejection(submission)).toEqual({ + category: 'undelivered', + verdict: null, + kind: 'notDelivered' + }) }) it('leaves a submission unknown while the provider reports a turn in flight', async () => { @@ -254,7 +268,7 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { 'accepted', 'rejected' ]) - expect(restarted.submissions()[1]?.reason).toBe('not_delivered') + expect(restarted.submissions()[1]?.rejection).toEqual({ kind: 'notDelivered' }) }) it('leaves two identical unsettled sends unknown rather than guessing between them', async () => { diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts index f37571814bb..7b36fc88dc6 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts @@ -10,6 +10,8 @@ // through the user's Retry, which rotates the client message id; Orca still // never puts a message back on the wire on the user's behalf. +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentJournalMessageItem, AgentJournalSubmission @@ -116,7 +118,9 @@ export async function reconcileJournalSubmissionsAgainstHistory(input: { : { clientMessageId: outcome.clientMessageId, state: 'rejected', - reason: outcome.reason, + ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { + surface: 'rejection' + }), fence: input.fence, recovered: true } diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index e56ca86a7fb..ee56f28f8a2 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -1,5 +1,4 @@ import type { - AgentJournalDispatchState, AgentJournalItemBody, AgentJournalItemIdentity, AgentJournalMessageItem, @@ -78,23 +77,22 @@ export function journalDispatchRowBuilder( ): RowBuilder { const providerItemId = input.state === 'accepted' ? agentJournalItemKey(input.providerIdentity) : null - return (seq, ts) => - buildJournalDispatchRow({ - state: state(), - clientMessageId: input.clientMessageId, - dispatchState: input.state, - providerItemId, - reason: boundedDispatchReason(input), - seq, - fence: input.fence, - ts, - recovered: input.recovered - }) + // The only dispatch-row builder: its input type is what makes a rejected row carry its fact. + return (seq, ts) => ({ + kind: 'dispatch', + clientMessageId: input.clientMessageId, + state: input.state, + providerItemId, + reason: boundedDispatchReason(input), + ...(input.state === 'rejected' ? { rejection: input.rejection } : {}), + ...journalRowBase(state().epoch, seq, input.fence, ts), + ...(input.recovered ? { recovered: input.recovered } : {}) + }) } /** `reason` is the only unbounded field written by Orca's own code: a provider error is * arbitrary text, and a multi-megabyte one reached the row verbatim. Bounded head-first, - * because `dispatchRejectionWasTransportWriteFailure` prefix-matches the value. Rows + * because `isWriteFailureSubmission` prefix-matches the value. Rows * written before this keep their full text, so readers still meet unbounded ones. */ function boundedDispatchReason(input: ResolveDispatchInput): string | null { if (input.state === 'accepted' || input.state === 'pending' || !input.reason) { @@ -280,25 +278,3 @@ export function buildJournalSubmissionRow(input: { ...(input.handoverRecorded ? { handoverRecorded: true } : {}) } } - -export function buildJournalDispatchRow(input: { - state: JournalReducerState - clientMessageId: string - dispatchState: AgentJournalDispatchState - providerItemId: string | null - reason: string | null - seq: number - fence: number - ts: number - recovered?: true -}): JournalDispatchRow { - return { - kind: 'dispatch', - clientMessageId: input.clientMessageId, - state: input.dispatchState, - providerItemId: input.providerItemId, - reason: input.reason, - ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), - ...(input.recovered ? { recovered: input.recovered } : {}) - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 0f29f8b3879..786d422876c 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -5,6 +5,7 @@ // UNREADABLE, not skippable: the caller must degrade to read-only rather than // render a partial timeline or compact past a row it cannot interpret. +import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION, type AgentJournalDispatchState, @@ -89,6 +90,9 @@ export type JournalDispatchRow = JournalRowBase & { /** Provider item identity adopted on accept. */ providerItemId: string | null reason: string | null + /** On `rejected`: why, typed. Older readers keep the key and ignore it; a malformed one is + * dropped when read, never the row. */ + rejection?: AgentSessionFailureFact } /** An item mutation may name its own producer, because one batch can CREATE diff --git a/src/main/native-chat/agent-session-journal/journal-store-close.test.ts b/src/main/native-chat/agent-session-journal/journal-store-close.test.ts index 960c65a638a..af53cd16d3d 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-close.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-close.test.ts @@ -13,6 +13,8 @@ import type { AgentJournalItemIdentity, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { journalDatabaseFile } from './journal-paths' import type { AgentSessionJournal } from './journal-store' import { createTrackedJournalOpener } from './journal-store-test-open' @@ -117,7 +119,16 @@ describe('closed-state admission happens at enqueue', () => { fence: 1 }) ), - settle(journal.resolveDispatch({ clientMessageId: 'cm_1', state: 'rejected', fence: 1 })), + settle( + journal.resolveDispatch({ + clientMessageId: 'cm_1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('hostFault'), { + surface: 'rejection' + }), + fence: 1 + }) + ), settle( journal.appendLifecycleBatch({ settlementId: 'settle', diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 79d86f80dbb..c2210efb253 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -1,3 +1,4 @@ +import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentJournalCursor, AgentJournalItemBody, @@ -31,7 +32,10 @@ export type ResolveDispatchInput = { } & ( | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } | { state: 'pending' } - | { state: 'rejected' | 'unknown'; reason?: string | null } + /** `reason` is what released clients print, `rejection` what newer ones read: both from + * `agentSessionFailureWords`, never written by hand. */ + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason?: string | null } ) export type JournalAppendResult = { diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 5119f7ef597..45c42b45f8a 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -1,5 +1,6 @@ // Append-only journal store for one agent session. +import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import { randomUUID } from 'node:crypto' import type { AgentJournalAcceptanceReceipt, @@ -319,17 +320,20 @@ export class AgentSessionJournal { } /** Reject unanswered sends after an owner that never proved its start ended: none was written. */ - async rejectPendingSubmissions(fence: number, reason: string): Promise { - return rejectJournalPendingSubmissions(this, fence, reason) + async rejectPendingSubmissions( + fence: number, + rejection: AgentJournalDispatchRejection + ): Promise { + return rejectJournalPendingSubmissions(this, fence, rejection) } /** Reject sends accepted but never handed over, optionally only those `which` names. */ async rejectQueuedSubmissions( fence: number, - reason: string, + rejection: AgentJournalDispatchRejection, which?: (submission: AgentJournalSubmission) => boolean ): Promise { - return rejectJournalQueuedSubmissions(this, fence, reason, which) + return rejectJournalQueuedSubmissions(this, fence, rejection, which) } /** The escape hatch for corruption, an unreconcilable prefix, a forked handle, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index c8cd54ab4f8..fb4ee607e38 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -2,6 +2,7 @@ // session's delivery loop starts a provider child for it and hands it over. Against the real host, // store and journal; each assertion reads what an open chat or the journal's next reader sees. +import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -14,9 +15,8 @@ import type { AgentSessionTurnCompletionEvent } from '../../../shared/agent-session-wire' import { - DISPATCH_REJECTED_CANCELLED, - DISPATCH_REJECTED_HOST_RESTARTED, - DISPATCH_REJECTED_PROVIDER_CLOSED + classifyDispatchRejection, + DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' @@ -29,6 +29,7 @@ import { import { journalIdentityFor } from './structured-agent-session-attach' import { attachParamsForRecord } from './structured-agent-session-conversation-open' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionChildEndCause } from './structured-agent-session-host-types' import { persistRewindRecord } from './structured-rewind-recovery' import { HOST_TEST_NOW as NOW, @@ -40,6 +41,8 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' const CALLER = { callerKey: 'client-1' } @@ -177,6 +180,12 @@ async function errorRows(): Promise { ) } +async function errorFailures(): Promise<(AgentSessionFailureFact | undefined)[]> { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.failure] : [] + ) +} + let subscriptions = 0 async function subscribe(): Promise { @@ -320,9 +329,21 @@ describe('a start the chat needed and did not get', () => { await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) const rows = await errorRows() expect(rows).toHaveLength(1) - expect(rows[0]).toContain('spawn codex ENOENT') - expect(await submission(first)).toMatchObject({ dispatchState: 'rejected', reason: rows[0] }) - expect(await submission(second)).toMatchObject({ dispatchState: 'rejected', reason: rows[0] }) + // Orca's spawn error goes to the log; the row and every message say what failed, typed. The + // child is gone, but no exit was observed, so nothing blames the provider. + expect(rows[0]).toBe("Codex couldn't restart. Send your message to try again.") + const failure = { + kind: 'restartFailed', + refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } + } + expect(await errorFailures()).toEqual([failure]) + for (const id of [first, second]) { + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: rows[0], + rejection: failure + }) + } const next = await accept('after the fix') await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) @@ -376,12 +397,27 @@ describe('a start the chat needed and did not get', () => { () => { adapterExtras = { supportsLocation: () => false } }, - 'cannot resume' + { + text: "Codex couldn't restart. Start a new chat to continue.", + failure: { + kind: 'restartFailed', + refusal: { + code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' } + } + } + } ], [ 'spawn', () => acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')), - 'spawn codex ENOENT' + { + text: "Codex couldn't restart. Send your message to try again.", + failure: { + kind: 'restartFailed', + refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } + } + } ], [ 'auth', @@ -389,9 +425,16 @@ describe('a start the chat needed and did not get', () => { acquire.mockRejectedValueOnce( new AgentSessionPreSpawnError(new Error('Not logged in. Please run /login.')) ), - 'Not logged in' + { + // No process ever started, so nothing says the provider stopped. + text: "Codex couldn't restart. Send your message to try again.", + failure: { + kind: 'restartFailed', + refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } + } + } ] - ])('writes one row a live chat sees for a %s refusal (W14)', async (_source, arrange, cause) => { + ])('writes one row a live chat sees for a %s refusal (W14)', async (_source, arrange, row) => { await host.close(SESSION) arrange() await host.flushAllStreamedEvents() @@ -400,8 +443,8 @@ describe('a start the chat needed and did not get', () => { const events = await subscribe() await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) - expect(await errorRows()).toHaveLength(1) - expect((await errorRows())[0]).toContain(cause) + expect(await errorRows()).toEqual([row.text]) + expect(await errorFailures()).toEqual([row.failure]) const framedRows = events.flatMap((event) => event.type === 'batch' || event.type === 'snapshot' ? (event.type === 'batch' ? event.batch.items : event.page.items).filter( @@ -432,11 +475,13 @@ describe('a start the chat needed and did not get', () => { settled = await reopened(id) expect(settled?.dispatchState).not.toBe('pending') }) - // The message names the start that failed, not a close or a restart it never met. + // The message names the start that failed, not a close or a restart it never met — and never + // the store's own error, which is Orca's and goes to the log. expect(settled).toMatchObject({ dispatchState: 'rejected', - reason: expect.stringContaining('record store write failed') + rejection: (await errorFailures())[0] }) + expect(settled?.reason).not.toContain('record store write failed') expect(await errorRows()).toEqual([settled?.reason]) }) }) @@ -487,7 +532,9 @@ describe('what an earlier host process left behind', () => { expect(await submission('queued')).toMatchObject({ dispatchState: 'rejected', - reason: DISPATCH_REJECTED_HOST_RESTARTED + ...agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' + }) }) expect(dispatch).not.toHaveBeenCalled() }) @@ -533,12 +580,64 @@ describe('a child that exits before its message is handed over', () => { const id = await accept('hello') await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) - expect((await submission(id))?.reason).toContain('codex app-server crashed') + expect(await submission(id)).toMatchObject({ + reason: 'Codex stopped before this message was sent.', + rejection: { kind: 'providerExited' } + }) expect(acquire).toHaveBeenCalledTimes(2) expect(dispatch).not.toHaveBeenCalled() }) }) +describe('a start whose failure the delivery loop settles before the exit is published', () => { + it('keeps one row in the words its rejected messages carry', async () => { + // The adapter's startup answer and its later exit event word the same start differently. + const awaitStarted = vi.fn(async () => agentSessionFailureFact('startFailed')) + adapterExtras = { awaitStarted } + acquire.mockImplementation(async (input) => ({ + ...(await spawnChild(input)), + providerChildPhase: 'starting' as const + })) + await host.close(SESSION) + await startHost() + + const first = await accept('first') + const second = await accept('second') + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + await host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + reason: 'codex app-server exited with code 1', + failure: agentSessionFailureFact('providerStartFailed', { + detail: { text: 'codex: config.toml is invalid', audience: 'person' } + }), + cause: 'unexpected-exit', + startupUnproven: true + }) + await host.flushStreamedEvents(SESSION) + + const rows = (await host.journalSnapshot(SESSION)).items.filter((item) => + item.itemId.includes('start-failure') + ) + expect(rows).toHaveLength(1) + const words = agentSessionFailureWords(agentSessionFailureFact('startFailed'), { + surface: 'rejection', + agentName: 'Codex', + provider: 'codex' + }) + expect(rows[0].body).toMatchObject({ text: words.reason, failure: words.rejection }) + for (const id of [first, second]) { + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: words.reason, + rejection: words.rejection + }) + } + }) +}) + describe('Stop withdraws what is queued', () => { it('withdraws a crash leftover ahead of any delivery step (W17a)', async () => { await writeAsEarlierProcess(async (journal, fence) => { @@ -620,7 +719,7 @@ describe('an eviction between acceptance and handover', () => { expect(await reopened(id)).toMatchObject({ dispatchState: 'rejected', - reason: DISPATCH_REJECTED_PROVIDER_CLOSED + ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) }) expect(dispatch).not.toHaveBeenCalled() }) @@ -643,7 +742,7 @@ describe('an eviction between acceptance and handover', () => { expect(await reopened(queued)).toMatchObject({ dispatchState: 'rejected', - reason: DISPATCH_REJECTED_PROVIDER_CLOSED + ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) }) expect(await submission(handed)).toMatchObject({ dispatchState: 'unknown' }) expect(dispatch).toHaveBeenCalledTimes(1) @@ -668,6 +767,49 @@ describe('an eviction between acceptance and handover', () => { }) }) +// A close abandons what is queued before it stops the child, so a release that then fails still +// leaves every queued message rejected as closed, never blamed on the provider. +describe('a close that stops the child and then fails', () => { + const END_CHILD = { + evict: () => host.close(SESSION) + } satisfies Partial Promise>> + + it.each([ + { end: 'evict', starting: true, kind: 'chatClosed', verdict: null }, + { end: 'evict', starting: false, kind: 'chatClosed', verdict: null } + ] as const)( + 'rejects what is queued as $kind after a $end (during startup: $starting)', + async ({ end, starting, kind, verdict }) => { + const started = deferred() + adapterExtras = { + awaitStarted: () => started.promise, + // Once: the host's own teardown acknowledges again. + acknowledgeSessionRelease: vi.fn().mockImplementationOnce(() => { + throw new Error('release acknowledgement failed') + }) + } + await host.close(SESSION) + await startHost() + acquire.mockImplementationOnce(async (input) => ({ + ...(await spawnChild(input)), + ...(starting ? { providerChildPhase: 'starting' as const } : {}) + })) + const id = await accept('hello') + await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + + await expect(END_CHILD[end]()).rejects.toThrow() + expect(host.hasSession(SESSION)).toBe(true) + started.resolve() + + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + const rejected = (await submission(id))! + expect(rejected.rejection).toMatchObject({ kind }) + expect(classifyDispatchRejection(rejected).verdict).toBe(verdict) + expect(dispatch).not.toHaveBeenCalled() + } + ) +}) + describe('a compaction or rewind an earlier child left prepared', () => { async function leftPrepared(prepare: (fence: number) => Promise): Promise { await host.close(SESSION) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index 8302b403033..7c12909e036 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -430,7 +430,10 @@ describe('structured session acquisition options', () => { }) ).resolves.toEqual({ ok: false, - refusal: { code: 'agent_session_operation_invalid', message: 'model list unavailable' } + refusal: { + code: 'agent_session_operation_invalid', + message: "Codex couldn't restart. Send your message to try again." + } }) expect(releaseAcquisition).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.ownerProcess).toBeNull() @@ -521,12 +524,16 @@ describe('structured session acquisition options', () => { onAttached: () => {} }) - // A proven exit before the journal opens is answered once, as the refusal its replay gives. + // A proven exit before the journal opens is answered once, as the refusal its replay gives; + // no exit was observed, so it names no situation. const failed = perform(store, CREATE_OPERATION, null) await (exitProven && failurePoint !== 'journal' ? expect(failed).resolves.toEqual({ ok: false, - refusal: { code: 'agent_session_operation_invalid', message: injected.message } + refusal: { + code: 'agent_session_operation_invalid', + message: "Codex couldn't restart. Send your message to try again." + } }) : expect(failed).rejects.toThrow( exitProven ? injected.message : 'agent_session_acquisition_exit_unproven' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index b209bbccb9e..bb003f93427 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -1,3 +1,4 @@ +import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionAccountHome, @@ -122,7 +123,7 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi awaitOptionWritable = (sessionId: string): Promise => this.liveOwnerOrNull(sessionId)?.awaitOptionWritable?.(sessionId) ?? Promise.resolve() - awaitStarted = (sessionId: string): Promise => + awaitStarted = (sessionId: string): Promise => this.liveOwnerOrNull(sessionId)?.awaitStarted?.(sessionId) ?? Promise.resolve() readOptions = (input: { sessionId: string; fence: number }) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index b579b7487c5..bc3c27ca5b1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -27,23 +27,37 @@ import type { AgentSessionBackgroundTaskState, AgentSessionOptionsResult, AgentSessionSlashCommand, - AgentSessionThreadGoalChange, - AgentSessionWireRefusalCode + AgentSessionThreadGoalChange } from '../../../shared/agent-session-wire' -import { isAgentSessionWireRefusalCode } from '../../../shared/agent-session-wire-refusals' +import { + isAgentSessionWireRefusalCode, + type AgentSessionRefusalReason +} from '../../../shared/agent-session-wire-refusals' +import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' +import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { StructuredSessionCompactionResult } from './structured-session-compaction' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' export class AgentSessionAcquisitionRefusal extends Error { + readonly code = 'agent_session_operation_invalid' + constructor( message: string, - readonly code: AgentSessionWireRefusalCode = 'agent_session_operation_invalid' + /** The situation, so the chat can say what to do; the message is Orca's log wording. Absent, + * the provider refused its own start. */ + readonly reason: AgentSessionRefusalReason<'agent_session_operation_invalid'> = 'providerStartFailed' ) { super(message) this.name = 'AgentSessionAcquisitionRefusal' } + + /** The conversation's history is more than this host can restore. */ + static historyTooLarge(message: string): AgentSessionAcquisitionRefusal { + return new AgentSessionAcquisitionRefusal(message, 'historyTooLarge') + } } export class AgentSessionPromptUnavailableError extends Error { @@ -103,11 +117,24 @@ export type AgentSessionAcquisition = { providerChildPhase?: StructuredAgentSessionProviderChildPhase } +/** A refusal before spawn that a person can act on; the site that refused names it. */ +export type AgentSessionPreSpawnReason = Extract< + AgentSessionRefusalReason<'agent_session_operation_invalid'>, + 'managedAccountEnvOverride' | 'accountSwitchInProgress' | 'managedAccountUnsupported' +> + /** Acquisition failed with first-hand proof that no provider process existed. */ export class AgentSessionPreSpawnError extends Error { - constructor(cause: unknown) { - super(cause instanceof Error ? cause.message : String(cause), { cause }) + /** Absent: Orca's own reason, which only the log reads. A wrapped pre-spawn error keeps its. */ + readonly reason: AgentSessionPreSpawnReason | undefined + + constructor( + cause: unknown, + options: { reason?: AgentSessionPreSpawnReason; message?: string } = {} + ) { + super(options.message ?? (cause instanceof Error ? cause.message : String(cause)), { cause }) this.name = 'AgentSessionPreSpawnError' + this.reason = options.reason ?? (isAgentSessionPreSpawnError(cause) ? cause.reason : undefined) } } @@ -125,14 +152,19 @@ export type AgentSessionDispatchOutcome = * anything and never promotes this to `unknown`. */ | { state: 'admitted' } - | { state: 'rejected'; reason: string } + /** Words from `agentSessionFailureWords`, never written by hand. */ + | ({ state: 'rejected' } & AgentJournalDispatchRejection) /** The call did not settle. Never re-send on the user's behalf. */ | { state: 'unknown'; reason: string } export type StructuredAgentSessionEndedEvent = { type: 'ended' sessionId: string + /** Log text only; the chat's words come from `failure`. */ reason: string + /** Why it ended, as the adapter knows it: the provider's exit with its own diagnostic, or an + * Orca fault. Absent reads as a provider exit with nothing to add. */ + failure?: SubmissionRejectionFact cause: 'unexpected-exit' | 'requested-close' fence: number acquisitionGeneration: string @@ -235,8 +267,8 @@ export type StructuredAgentSessionAdapter = { turnId: string sessionId: string fence: number - onLateResult?: (result: { error?: string }) => Promise - }): Promise<{ error?: string }> + onLateResult?: (result: StructuredSessionCompactionResult) => Promise + }): Promise /** Cancels one turn, not the session: a session-wide interrupt would also kill * a turn the client never asked to stop. */ cancelTurn(input: { @@ -293,7 +325,7 @@ export type StructuredAgentSessionAdapter = { /** Resolves once a session published before it proved its start has proven it, failed, or been * closed; at once for any other. A start that did not land resolves with the chat's words for * why. Never rejects. */ - awaitStarted?(sessionId: string): Promise + awaitStarted?(sessionId: string): Promise readOptions?(input: { sessionId: string; fence: number }): Promise /** Option keys skipped after a provider rejected their persisted restore value. */ readOptionRestoreFailures?(sessionId: string): readonly string[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts index 7a85de6a473..3eccc4f9457 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts @@ -1,4 +1,9 @@ -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { + agentSessionRefusalError, + isAgentSessionRefusalError, + refuse, + type AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionAttachParams, AttachedJournal } from './structured-agent-session-attach' import type { JournalReplacementItem } from '../agent-session-journal/journal-epoch-replacement' @@ -18,10 +23,13 @@ export async function prepareAdoptedTranscript( } catch (error) { return { ok: false, - refusal: { - code: 'agent_session_identity_required', - message: error instanceof Error ? error.message : String(error) - } + refusal: isAgentSessionRefusalError(error) + ? error.refusal + : refuse( + 'agent_session_identity_required', + { reason: 'transcriptUnreadable' }, + error instanceof Error ? error.message : String(error) + ) } } } @@ -35,7 +43,9 @@ async function readAdoptedTranscript( return null } if (!adopt.transcriptPath) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptNotFound' + }) } const prepared = await prepareLegacyTranscriptImport({ agent: params.agent, @@ -49,7 +59,9 @@ async function readAdoptedTranscript( throw new Error(prepared.error) } if (prepared.items.length === 0) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptUnreadable' + }) } return prepared.items } @@ -81,7 +93,9 @@ async function applyAdoptedTranscript( return } if (!adopt.transcriptPath) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptNotFound' + }) } const imported = await importLegacyTranscriptIntoJournal({ journal: attached.journal, @@ -99,6 +113,8 @@ async function applyAdoptedTranscript( // `replaced: false` means the transcript decoded to nothing. The row promised a conversation and // the provider resumed one, so an empty journal here is a disagreement, not an empty chat. if (!imported.replaced) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptUnreadable' + }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index 0df752983dd..a4c93fa00f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -5,12 +5,24 @@ // serialize, with the attach it is given, so the eligibility it reads is the one the attach acts // on. +import { + providerDiagnosticOf, + type ProviderDiagnostic +} from '../../../shared/agent-session-failure' +import { + agentSessionRefusalFromReference, + readAgentSessionRefusalReference, + refuse, + refuseUnclassified, + type AgentSessionRefusalDetailsByCode, + type AgentSessionWireRefusalCode +} from '../../../shared/agent-session-wire-refusals' +import { agentSessionWriteNoticeEnglish } from '../../../shared/agent-session-refusal-notice' import type { AgentSessionAttachResult, AgentSessionMutationResult, AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import { isAgentSessionWireRefusalCode } from '../../../shared/agent-session-wire-refusals' import { terminalOwnerRefusalMessage } from '../../../shared/agent-session-legacy-handoff-lease' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { attachStructuredAgentSessionUnderSerialize } from './structured-agent-session-attach-orchestration' @@ -25,7 +37,13 @@ import { * someone else is settling from an owner that will not come back. */ export type StructuredAgentSessionResumeOutcome = | { ok: true } - | { ok: false; refusal: AgentSessionWireRefusal } + | { + ok: false + refusal: AgentSessionWireRefusal + /** What the provider said about the failed start, for the chat's own record; host-side + * only, never on the refusal. */ + diagnostic?: ProviderDiagnostic + } /** The attach's caller key: the ledger row a start settles is Orca's own. */ const AGENT_START_CALLER_KEY = 'trusted-local:agent-start' @@ -47,13 +65,11 @@ export async function ensureStructuredAgentSessionAgent( if (!started.ok || context.sessions.get(sessionId)?.child) { return started } - return { - ok: false, - refusal: { - code: 'agent_session_ownership_unknown', - message: 'The session attached without a provider child to write to.' - } - } + return refuseResume( + 'agent_session_ownership_unknown', + { reason: 'noProviderChild' }, + 'The session attached without a provider child to write to.' + ) } /** The same, for an operation's admission: a start that throws is that operation's refusal. */ @@ -61,13 +77,17 @@ export function ensureStructuredAgentSessionAgentForOperation( context: StructuredAgentSessionAttachContext, sessionId: string ): Promise { - return ensureStructuredAgentSessionAgent(context, sessionId).catch((error: unknown) => ({ - ok: false, - refusal: { - code: 'agent_session_owner_restart_failed', - message: error instanceof Error ? error.message : String(error) + return ensureStructuredAgentSessionAgent(context, sessionId).catch((error: unknown) => { + // The error is Orca's own and goes to the log; the refusal says only that the start failed. + console.warn('[agent-session] starting the agent for an operation failed:', error) + return { + ok: false, + refusal: refuseUnclassified( + 'agent_session_owner_restart_failed', + agentSessionWriteNoticeEnglish(['restartFailed']) + ) } - })) + }) } async function startStructuredAgentSessionAgent( @@ -86,11 +106,16 @@ async function startStructuredAgentSessionAgent( await context.runtimeState.resolveRecovery(sessionId) const record = context.deps.store.getRecord(sessionId) if (!record) { - return refuse('agent_session_identity_required', 'No structured session exists by that id.') + return refuseResume( + 'agent_session_identity_required', + { reason: 'recordMissing' }, + 'No structured session exists by that id.' + ) } if (!adapterSupportsRecord(context.deps.adapter, record)) { - return refuse( + return refuseResume( 'structured_agent_session_unsupported', + { reason: 'hostUnsupported' }, 'This execution host cannot resume the requested structured agent session.' ) } @@ -100,25 +125,32 @@ async function startStructuredAgentSessionAgent( ) if (!params) { return record.lease.unreconciled - ? refuse( + ? refuseResume( 'execution_owner_reconciling', + { reason: 'hostReconciling' }, 'This host has not yet adjudicated the session lease.' ) : record.lease.claimStatus === 'conflicted' - ? refuse('agent_session_conflict', terminalOwnerRefusalMessage(record.lease)) - : refuse( + ? refuseResume( + 'agent_session_conflict', + { reason: 'claimConflicted' }, + terminalOwnerRefusalMessage(record.lease) + ) + : refuseResume( 'agent_session_ownership_unknown', + { reason: 'notResumable' }, 'The session lease is not one this host may resume.' ) } let attached: AgentSessionMutationResult + let acquisitionError: unknown try { - attached = await attachStructuredAgentSessionUnderSerialize( - context, - callerKey, - params, - startedFor === undefined ? {} : { startedFor } - ) + attached = await attachStructuredAgentSessionUnderSerialize(context, callerKey, params, { + ...(startedFor === undefined ? {} : { startedFor }), + onAcquisitionFailed: (error) => { + acquisitionError = error + } + }) } catch (error) { // The attach settles an acquisition that failed — the ledger row, the released lease — before // it rethrows the cause. That row is the answer: a failure it recorded is this resume's @@ -131,11 +163,19 @@ async function startStructuredAgentSessionAgent( error ) if (settled) { - return settled + return withDiagnostic(settled.refusal, error) } throw error } - return attached.ok ? { ok: true } : { ok: false, refusal: attached.refusal } + return attached.ok ? { ok: true } : withDiagnostic(attached.refusal, acquisitionError) +} + +function withDiagnostic( + refusal: AgentSessionWireRefusal, + error: unknown +): StructuredAgentSessionResumeOutcome { + const diagnostic = providerDiagnosticOf(error) + return { ok: false, refusal, ...(diagnostic ? { diagnostic } : {}) } } function settledResumeRefusal( @@ -144,24 +184,29 @@ function settledResumeRefusal( operationId: string, sessionId: string, error: unknown -): StructuredAgentSessionResumeOutcome | null { +): { ok: false; refusal: AgentSessionWireRefusal } | null { const outcome = context.deps.store.getOperationRow(callerKey, operationId)?.outcome - if (outcome?.status !== 'failed' || !isAgentSessionWireRefusalCode(outcome.code)) { + const reference = + outcome?.status === 'failed' + ? readAgentSessionRefusalReference({ code: outcome.code, details: outcome.details }) + : undefined + if (outcome?.status !== 'failed' || !reference) { return null } return failedCreateRefusal( - { - code: outcome.code, - message: outcome.message ?? (error instanceof Error ? error.message : String(error)) - }, + agentSessionRefusalFromReference( + reference, + outcome.message ?? (error instanceof Error ? error.message : String(error)) + ), outcome.status, context.deps.store.getRecord(sessionId) ) } -function refuse( - code: AgentSessionWireRefusal['code'], +function refuseResume( + code: C, + details: NoInfer, message: string ): StructuredAgentSessionResumeOutcome { - return { ok: false, refusal: { code, message } } + return { ok: false, refusal: refuse(code, details, message) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index eef9297e82a..55128a23c9c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -93,6 +93,7 @@ function exitBeforeProof(): Promise { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, acquisitionGeneration: `generation-${generation}`, reason: EXIT_REASON, + failure: { kind: 'providerExited', detail: { text: EXIT_REASON, audience: 'log' } }, cause: 'unexpected-exit', startupUnproven: true }) @@ -166,7 +167,7 @@ describe('an open chat receives every row its journal commits', () => { ) // One row, however many of its writers reported the start. expect(pane.received().statusRows).toEqual([ - expect.stringMatching(/stopped before it finished starting: .*not signed in/) + 'Codex stopped before it finished starting. Send your message to try again.' ]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts index 6af8eb369d5..16af00e32ea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts @@ -35,6 +35,7 @@ export async function settlePostAcquisitionAttachFailure( outcome: { status: 'failed', code: 'agent_session_operation_invalid', + details: { reason: 'attachFailed' }, message: cause instanceof Error ? cause.message : String(cause) }, exitProof, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 2969cb62f09..8fdee5c62bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -1,7 +1,9 @@ +import { refuse } from '../../../shared/agent-session-wire-refusals' import { settlePostAcquisitionAttachFailure } from './structured-agent-session-attach-failure' import { failedAcquisitionRefusal, - failedAcquisitionSettlement + failedAcquisitionSettlement, + preSpawnFailureInWords } from './structured-agent-session-failed-create-refusal' import type { StructuredAgentSessionAdapter, @@ -72,6 +74,9 @@ export type AttachFlowInput = { /** A failure after acquisition released the session's acquisition; `cause` is that failure and * `rootGone` whether the release saw the provider root go. */ onAcquisitionReleased?: (cause: unknown, verdict: { rootGone: boolean }) => void + /** The error an acquisition failed with, for a host-side reader of the provider's words; the + * refusal never carries them. */ + onAcquisitionFailed?: (error: unknown) => void } export async function performAttach( @@ -80,10 +85,11 @@ export async function performAttach( const { params, store } = input const unsupported = (): AgentSessionMutationResult => ({ ok: false, - refusal: { - code: 'structured_agent_session_unsupported', - message: 'This execution host cannot create the requested structured agent session.' - } + refusal: refuse( + 'structured_agent_session_unsupported', + { reason: 'hostUnsupported' }, + 'This execution host cannot create the requested structured agent session.' + ) }) const sessionId = params.envelope.sessionId const admitted = admitAttachOrRefuse(params) @@ -172,6 +178,10 @@ export async function performAttach( acquiredOwner = true } } catch (error) { + const wording = { + record: reservedRecord ?? store.getRecord(sessionId), + newSession: !params.providerHandle + } const spawnToken = reservedRecord?.lease.reservedSpawnToken if (reservedRecord && spawnToken && !unsupportedReservationSettlementAttempted) { // Settle processless proof and failed operation atomically. @@ -182,7 +192,7 @@ export async function performAttach( spawnToken, callerKey: input.callerKey, operationId: params.envelope.clientOperationId, - ...failedAcquisitionSettlement(error), + ...failedAcquisitionSettlement(error, wording), now: input.now() }) } catch (settlementError) { @@ -192,11 +202,18 @@ export async function performAttach( ) } } + input.onAcquisitionFailed?.(error) + const failed = failedAcquisitionRefusal(error, wording) + const thrown = failed ? error : preSpawnFailureInWords(error, wording) + if (failed || thrown !== error) { + // The answer carries only its sentence, so what failed is kept here. + console.warn('[agent-session] provider start failed:', error) + } return ( - failedAcquisitionRefusal(error) ?? { + failed ?? { ok: false, refusal: classifyStoreFailure( - error, + thrown, store.getRecord(sessionId)?.lease.runtimeFence ?? null, store.getRecord(sessionId) ) @@ -282,6 +299,7 @@ async function settleUnsupportedReservation( outcome: { status: 'failed', code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' }, message: 'Structured session support changed before the provider could start.' }, exitProof: 'processless', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index f56f119d8e0..40804bf55e2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -1,3 +1,4 @@ +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { recoverStructuredRewind } from './structured-rewind-recovery' import { recoverInterruptedCompaction } from './structured-compaction-recovery' // The host's attach, lifted out of the host class. @@ -14,7 +15,7 @@ import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { performAttach } from './structured-agent-session-attach-flow' +import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow' import { stampFailedCreateOwnerVerdict } from './structured-agent-session-failed-create-refusal' import { pinnedAgentSessionLaunchArgs, @@ -22,6 +23,7 @@ import { } from './structured-agent-session-launch-env' import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionProviderChild, @@ -43,6 +45,7 @@ import { export type StructuredAgentSessionAttachOptions = { recordPhase?: AgentSessionCreatePhaseRecorder + onAcquisitionFailed?: AttachFlowInput['onAcquisitionFailed'] /** The queued message a start is for; see `StructuredAgentSessionProviderChild.startedFor`. */ startedFor?: string } @@ -123,7 +126,8 @@ async function runAttach( const attemptSink = context.runtimeState.mintEventSink(sessionId) // Read before the reserve clears it: how the previous generation ended decides how whatever it // left running is settled. - const priorDeathEvidence = context.deps.store.getRecord(sessionId)?.lease.deathEvidence ?? null + const priorRecord = context.deps.store.getRecord(sessionId) + const priorDeathEvidence = priorRecord?.lease.deathEvidence ?? null const attempt: { candidate: AttachCandidate | null; committed: boolean } = { candidate: null, committed: false @@ -153,6 +157,7 @@ async function runAttach( params, now: () => context.now(), recordPhase, + ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), openConversation: async (record) => { const conversation = await context.openConversation(record.sessionId, { acquisition: true @@ -180,7 +185,8 @@ async function runAttach( sessionId, fence, acquisitionGeneration, - deathEvidence: priorDeathEvidence + deathEvidence: priorDeathEvidence, + failureTextContext: structuredAgentSessionFailureWordsContext(priorRecord) }) } await bindAndDrain(eventSink, attached.journal, fence, (activity) => @@ -249,6 +255,8 @@ function endReleasedChild( fence: child.fence, cause: 'attach-failed', reason: cause instanceof Error ? cause.message : String(cause), + // Orca failed to attach; the provider said nothing. + failure: agentSessionFailureFact('hostFault'), duringStartup: child.phase === 'starting', ...verdict }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts index 4e6390c46d9..36f6ebc79e8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts @@ -113,7 +113,7 @@ describe('attachJournal restart reconciliation', () => { expect(attached.unconfirmedClientMessageIds).toEqual([]) const submission = attached.journal.submissions()[0] expect(submission?.dispatchState).toBe('rejected') - expect(submission?.reason).toBe('not_delivered') + expect(submission?.rejection).toEqual({ kind: 'notDelivered' }) // Deciding is not sending: nothing here puts the message back on the wire. expect(dispatch).not.toHaveBeenCalled() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 4cf6505d276..10c4866cea3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -24,10 +24,14 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { - AGENT_SESSION_WIRE_REFUSAL_CODES, + AgentSessionRefusalError, + agentSessionRefusalFromReference, + agentSessionRefusalReference, + isAgentSessionWireRefusalCode, + refuse, type AgentSessionMutationEnvelope, - type AgentSessionWireRefusal, - type AgentSessionWireRefusalCode + type AgentSessionRefusalReference, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { agentSessionFingerprintConflict, @@ -118,10 +122,11 @@ export function admitAttachOrRefuse( if (params.providerHandle && params.providerHandle.kind !== params.provider) { return { ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: `A ${params.provider} session requires a ${params.provider} provider handle.` - } + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'requestMalformed' }, + `A ${params.provider} session requires a ${params.provider} provider handle.` + ) } } const fingerprint = computeAgentSessionPayloadFingerprint({ @@ -331,16 +336,21 @@ export function classifyStoreFailure( record: AgentSessionRecord | null = null ): AgentSessionWireRefusal { const rawCode = error instanceof Error ? error.message : String(error) - if (!(AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(rawCode)) { + if (!isAgentSessionWireRefusalCode(rawCode)) { throw error } - const code = rawCode as AgentSessionWireRefusalCode - return { - code, - // Why: a latched session is exactly where a bare store code strands the user. - message: - structuredAgentSessionRefusalMessage(code, record) ?? - `The session store refused this call: ${code}.`, - ...(code === 'agent_session_checkpoint_stale' && currentFence !== null ? { currentFence } : {}) - } + // A refusal error's message is its code, so its details are this code's. + const emitted: AgentSessionRefusalReference = + error instanceof AgentSessionRefusalError + ? agentSessionRefusalReference(error.refusal) + : { code: rawCode } + // Why: a latched session is exactly where a bare store code strands the user. + const told = structuredAgentSessionRefusalMessage(emitted, record) + const reference = told?.reference ?? emitted + return agentSessionRefusalFromReference( + reference.code === 'agent_session_checkpoint_stale' && currentFence !== null + ? { code: reference.code, details: { ...reference.details, currentFence } } + : reference, + told?.message ?? `The session store refused this call: ${rawCode}.` + ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index 6e54da64462..f5c7fb825f3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -1,3 +1,4 @@ +import { AgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' @@ -106,7 +107,7 @@ export class StructuredAgentSessionClientDelivery { private requireJournal(sessionId: string): AgentSessionJournal { const journal = this.sessions.get(sessionId)?.journal if (!journal) { - throw new Error(AGENT_SESSION_NOT_ATTACHED.code) + throw new AgentSessionRefusalError(AGENT_SESSION_NOT_ATTACHED) } return journal } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index 74f916d57c1..bf3cd1f96a6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -2,7 +2,6 @@ // list and a send each see only the one that happened. Ticks are driven by hand. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../shared/structured-agent-session-dispatch-rejection' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import { readStructuredSessionGateFacts } from '../../runtime/orchestration/structured-mailbox-pointer-host' @@ -248,8 +247,8 @@ describe('a start that never finishes (P2-15)', () => { ...args ) { // Not the open's sweep of an earlier process's leftovers. - if (args[1] !== DISPATCH_REJECTED_HOST_RESTARTED) { - order.push(`rejected: ${args[1]}`) + if (args[1].rejection.kind !== 'hostRestarted') { + order.push(`rejected: ${args[1].reason}`) } return reject.apply(this, args) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index a59b72b9195..e81eb9ae976 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -6,6 +6,10 @@ // because the queue is not reentrant. import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' +import { + AgentSessionRefusalError, + agentSessionRefusalError +} from '../../../shared/agent-session-wire-refusals' import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' import { abandonQueuedStructuredAgentSessionMessages, @@ -98,19 +102,25 @@ export function createStructuredAgentSessionConversationLifetime(host: { } const record = deps().store.getRecord(sessionId) if (!record) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'recordMissing' + }) } if (!adapterSupportsRecord(deps().adapter, record)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) } return serialize(sessionId, async () => { // Read at the open itself: a read queued before quit began runs after it. if (disposed) { - throw new Error(AGENT_SESSION_NOT_ATTACHED.code) + throw new AgentSessionRefusalError(AGENT_SESSION_NOT_ATTACHED) } const session = await host.open(sessionId) if (!session) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'recordMissing' + }) } return session }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index 07d1fb6b5ff..bce79ca71f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -21,6 +21,7 @@ import { import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession @@ -112,7 +113,8 @@ export async function openStructuredAgentSessionConversationJournal( sessionId, fence, acquisitionGeneration: null, - deathEvidence: record.lease.deathEvidence ?? null + deathEvidence: record.lease.deathEvidence ?? null, + failureTextContext: structuredAgentSessionFailureWordsContext(record) }) } } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 8ad51871c24..4a0febb6ceb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -4,16 +4,21 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import { dispatchRejectionReasonIsInternal } from '../../../shared/structured-agent-session-dispatch-rejection' +import { + agentSessionFailureFact, + MAX_PROVIDER_DIAGNOSTIC_CHARS, + providerDiagnostic +} from '../../../shared/agent-session-failure' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { captureUnfinishedStructuredAgentSessionWork, - MAX_UNEXPECTED_EXIT_REASON_CHARS, settleStructuredAgentSessionDeadGeneration, - UNEXPECTED_PROVIDER_EXIT_OUTCOME, unfinishedStructuredAgentSessionWorkWasInterrupted } from './structured-agent-session-dead-generation-settlement' +const UNEXPECTED_PROVIDER_EXIT_OUTCOME = + 'The agent stopped while this response was in progress. You can continue in this conversation.' + const SESSION = 'session-dead-generation' const THREAD = 'thread-1' let root: string @@ -143,7 +148,7 @@ describe('dead structured-session generation settlement', () => { ).toHaveLength(1) }) - it('keeps the actionable tail when the provider dumps a stderr wall into its exit reason', async () => { + it('keeps a stderr wall out of the sentence, as a bounded detail for a log', async () => { await seedUnfinishedWork() await expect( @@ -155,19 +160,47 @@ describe('dead structured-session generation settlement', () => { pendingSubmissionReason: 'provider_exited_before_acknowledgement', verdict: { state: 'interrupted', completedAt: 1_000 }, showUnexpectedExitOutcome: true, - unexpectedExitReason: 'stack frame '.repeat(4_000) + exitFailure: agentSessionFailureFact('providerExited', { + detail: providerDiagnostic('stack frame '.repeat(4_000), 'log') + }) }) ).resolves.toBe(true) const statuses = journal .snapshot() - .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) + .items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) expect(statuses).toHaveLength(1) - // The cause is bounded before composing, so the row never reaches the byte cap that would - // truncate the sentence telling the user the conversation is still usable. - expect(statuses[0]).toContain('stack frame') - expect(statuses[0]).toMatch(/You can continue in this conversation\.$/) - expect(statuses[0]?.length).toBeLessThan(MAX_UNEXPECTED_EXIT_REASON_CHARS * 2) + expect(statuses[0]?.text).toBe(UNEXPECTED_PROVIDER_EXIT_OUTCOME) + expect(statuses[0]?.failure?.kind).toBe('providerExited') + expect(statuses[0]?.failure?.detail?.audience).toBe('log') + expect(statuses[0]?.failure?.detail?.text.length).toBe(MAX_PROVIDER_DIAGNOSTIC_CHARS) + }) + + it("words Orca's own fault as Orca's, never as the provider stopping", async () => { + await seedUnfinishedWork() + + await settleStructuredAgentSessionDeadGeneration({ + journal, + sessionId: SESSION, + fence: 7, + settlementId: `provider-exit:${SESSION}:7:generation-1`, + pendingSubmissionReason: 'provider_exited_before_acknowledgement', + verdict: { state: 'interrupted', completedAt: 1_000 }, + showUnexpectedExitOutcome: true, + // Orca stopped the provider because its own journal failed. + exitFailure: agentSessionFailureFact('hostFault') + }) + + const statuses = journal + .snapshot() + .items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) + expect(statuses).toEqual([ + { + kind: 'status', + text: "Orca ran into a problem, so this didn't go through. Try again.", + failure: { kind: 'hostFault' } + } + ]) }) it('retries an already settled expected close without writing through a closed journal gate', async () => { @@ -276,16 +309,24 @@ describe('dead structured-session generation settlement', () => { settlementId: `provider-exit:${SESSION}:7:generation-1`, pendingSubmissionReason: 'provider_closed_before_acknowledgement', verdict: { state: 'interrupted', completedAt: 1_000 }, - unexpectedExitReason: 'claude stream-json exited (code 1): not signed in', + exitFailure: agentSessionFailureFact('providerExited', { + detail: providerDiagnostic('code 1\nnot signed in', 'log') + }), exitedDuringStartup: { generation: 'generation-1' } }) - const reason = - 'The provider stopped before it finished starting: claude stream-json exited (code 1): not signed in.' + // The sentence is Orca's; the stderr the exit carried rides as a log detail only. expect(journal.submissions()).toEqual([ - expect.objectContaining({ clientMessageId: 'client-held', dispatchState: 'rejected', reason }) + expect.objectContaining({ + clientMessageId: 'client-held', + dispatchState: 'rejected', + reason: 'The agent stopped before it finished starting. Send your message to try again.', + rejection: { + kind: 'providerStartFailed', + detail: { text: 'code 1\nnot signed in', audience: 'log' } + } + }) ]) - expect(dispatchRejectionReasonIsInternal(reason)).toBe(false) }) it("keeps a subagent's settled rows the subagent's, in one batch and after a reopen", async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 759ff18b1e7..f6a166c7c02 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -1,7 +1,9 @@ import { - agentJournalItemKey, - parseAgentJournalItemKey -} from '../../../shared/agent-session-journal-item-key' + agentSessionFailureFact, + MAX_PROVIDER_DIAGNOSTIC_CHARS, + type SubmissionRejectionFact +} from '../../../shared/agent-session-failure' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentJournalItemBody, @@ -10,12 +12,17 @@ import type { import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' -import { - boundJournalStatusText, - cancelledJournalPromptBody -} from '../agent-session-journal/journal-prompt-body-bounds' +import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { structuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' +import { + hasStructuredAgentSessionStartFailureRow, + structuredAgentSessionStartFailureRow +} from './structured-agent-session-start-failure-row' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { runningTurnLifecycleRevisions, @@ -23,67 +30,9 @@ import { type StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' -export const UNEXPECTED_PROVIDER_EXIT_OUTCOME = - 'The provider stopped while this response was in progress. You can continue in this conversation.' - -/** A provider may put a whole stderr dump in its exit reason; unbounded it would push the - * actionable tail past the row's byte cap and lose it to truncation. */ -export const MAX_UNEXPECTED_EXIT_REASON_CHARS = 512 - -/** The cause is the only thing separating an auth failure from an OOM kill, so it is carried - * into the copy rather than left in the durable record nothing renders. */ -export function unexpectedProviderExitOutcome(reason?: string): string { - const detail = exitReasonDetail(reason) - return detail - ? `The provider stopped while this response was in progress: ${detail}. You can continue in this conversation.` - : UNEXPECTED_PROVIDER_EXIT_OUTCOME -} - -/** A restart that produced no child, answered to the send that asked for it; its cause is the - * whole story, and nothing is remembered, so the next try is a fresh one. A new chat is offered - * only when the host holds nothing this chat could restart from. */ -export function ownerRestartFailedOutcome(input: { - agentName: string - reason?: string - resumable: boolean -}): string { - const detail = exitReasonDetail(input.reason) - const failed = detail - ? `${input.agentName} couldn't restart: ${detail}.` - : `${input.agentName} couldn't restart.` - return input.resumable ? failed : `${failed} Start a new chat to continue.` -} - -/** A start that never finished has no response to interrupt; its cause is the whole story. */ -export function providerStartupFailureOutcome(reason?: string): string { - const detail = exitReasonDetail(reason) - return detail - ? `The provider stopped before it finished starting: ${detail}.` - : 'The provider stopped before it finished starting.' -} - -/** Why a message accepted but not yet handed over was rejected when its child exited. */ -export function providerExitBeforeDeliveryRejection(reason?: string): string { - const detail = exitReasonDetail(reason) - return detail - ? `The provider stopped before this message was sent: ${detail}.` - : 'The provider stopped before this message was sent.' -} - -/** Why a send a child that never started left unwritten was rejected. The child's own diagnostic is - * the cause the user can act on, so it is the reason, in the words the chat row uses. */ -export function providerStartupFailureRejection(cause?: unknown): string { - return providerStartupFailureOutcome( - cause === undefined ? undefined : cause instanceof Error ? cause.message : String(cause) - ) -} - -function exitReasonDetail(reason: string | undefined): string | undefined { - return reason - ?.slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS) - .trim() - .replace(/[.\s]+$/, '') -} +/** Bounds the exit reason the lease keeps as log evidence; a provider diagnostic is held to the + * same cap. */ +export const MAX_UNEXPECTED_EXIT_REASON_CHARS = MAX_PROVIDER_DIAGNOSTIC_CHARS type DeadGenerationSubmission = Pick< ReturnType[number], @@ -155,8 +104,10 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { verdict: StructuredAgentSessionTurnVerdict pendingSubmissionReason: string showUnexpectedExitOutcome?: boolean - /** Why the provider stopped, when the host has it. Rendered with the outcome copy. */ - unexpectedExitReason?: string + /** Why the provider stopped, as the adapter told it; the row's sentence is this fact's. */ + exitFailure?: SubmissionRejectionFact + /** Who a failed start's sentence names. */ + failureTextContext?: AgentSessionFailureWordsContext /** The provider never finished starting: the start that failed, keyed by the child's * generation. Its row is the one the delivery loop writes for the same start. */ exitedDuringStartup?: { generation: string | null } @@ -172,26 +123,24 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { // child that never proved its start accepted nothing either — input is written only after it // initializes — so every send it was handed is rejected with the child's own diagnostic. A // proven child's handed-over sends stay in doubt. - await (input.exitedDuringStartup - ? input.journal.rejectPendingSubmissions( - input.fence, - providerStartupFailureRejection(input.unexpectedExitReason) - ) + const startupFailure = input.exitedDuringStartup + ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) + : null + await (startupFailure + ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] - if (showUnexpectedExitOutcome && input.exitedDuringStartup) { - const row = structuredAgentSessionStartFailureRow( - input.exitedDuringStartup.generation ?? input.settlementId, - providerStartupFailureOutcome(input.unexpectedExitReason) - ) + if (showUnexpectedExitOutcome && input.exitedDuringStartup && startupFailure) { + const startKey = input.exitedDuringStartup.generation ?? input.settlementId // A start a message waited on is the delivery loop's to record, before or after this exit, // in the words it rejected the message with; this row is for a command, goal or rewind start. + // A row already written stays: rejected is terminal, so its words are not reworded. const recordedByDeliveryLoop = input.journal.submissions?.().some(isQueuedAgentJournalSubmission) || - items.some((item) => item.itemId === agentJournalItemKey(row.identity)) + hasStructuredAgentSessionStartFailureRow(items, startKey) if (!recordedByDeliveryLoop) { - mutations.push(row) + mutations.push(structuredAgentSessionStartFailureRow(startKey, startupFailure)) } } else if (showUnexpectedExitOutcome) { mutations.push({ @@ -199,7 +148,13 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { identity: { provider: 'orca', clientMessageId: input.settlementId }, body: { kind: 'status', - text: boundJournalStatusText(unexpectedProviderExitOutcome(input.unexpectedExitReason)) + ...agentSessionFailureWords( + input.exitFailure ?? agentSessionFailureFact('providerExited'), + { + ...input.failureTextContext, + surface: 'row' + } + ) } }) } @@ -240,6 +195,8 @@ export async function settleStaleStructuredAgentSessionState(input: { fence: number acquisitionGeneration: string | null deathEvidence: AgentSessionDeathEvidence | null + /** Who the exit row names. */ + failureTextContext?: AgentSessionFailureWordsContext }): Promise { const { journal } = input const items = journal.snapshot().items @@ -259,9 +216,14 @@ export async function settleStaleStructuredAgentSessionState(input: { mutations.unshift({ kind: 'item', identity: { provider: 'orca', clientMessageId: settlementId }, + // The death evidence is Orca's log text, never a sentence for a person: the row says only + // that the provider stopped. body: { kind: 'status', - text: boundJournalStatusText(unexpectedProviderExitOutcome(input.deathEvidence?.detail)) + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + ...input.failureTextContext, + surface: 'row' + }) } }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts new file mode 100644 index 00000000000..242daf74faf --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-delivery-loop' +import type { + StructuredAgentSessionChildEndCause, + StructuredAgentSessionEndedChild +} from './structured-agent-session-host-types' + +const EXIT_FAILURE = agentSessionFailureFact('providerExited', { + detail: { text: 'crashed', audience: 'log' } +}) + +function ended( + cause: StructuredAgentSessionChildEndCause, + duringStartup: boolean +): StructuredAgentSessionEndedChild { + return { + generation: 'generation-1', + fence: 2, + rootGone: true, + cause, + reason: null, + failure: EXIT_FAILURE, + duringStartup, + endedAt: { epoch: 'epoch-1', sequence: 3 } + } +} + +describe('what a child end means for the messages queued behind it', () => { + it.each([false, true])("fails nothing after a user's Stop (during startup: %s)", (starting) => { + expect(structuredAgentSessionEndedChildFailure(ended('user-stop', starting))).toBeNull() + }) + + it.each([false, true])( + 'is a start Orca stopped after a host stop (during startup: %s)', + (starting) => { + expect(structuredAgentSessionEndedChildFailure(ended('host-stop', starting))).toEqual({ + failure: { kind: 'hostStopped' } + }) + } + ) + + it.each(['exit', 'attach-failed', 'evict'] as const)( + 'carries the recorded failure of a %s, as a failed start while starting', + (cause) => { + expect(structuredAgentSessionEndedChildFailure(ended(cause, true))).toEqual({ + exit: EXIT_FAILURE + }) + expect(structuredAgentSessionEndedChildFailure(ended(cause, false))).toEqual({ + failure: EXIT_FAILURE + }) + expect( + structuredAgentSessionEndedChildFailure({ ...ended(cause, false), failure: undefined }) + ).toEqual({ failure: agentSessionFailureFact('providerExited') }) + } + ) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 4181a682d0e..7dd17306105 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -9,15 +9,22 @@ // would have written them. Stop and the conversation's close are the only other writers of a // queued message: a child's exit only ends the child, and this loop reads why. -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { + agentSessionFailureFact, + type SubmissionRejectionFact +} from '../../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { - providerExitBeforeDeliveryRejection, - providerStartupFailureOutcome -} from './structured-agent-session-dead-generation-settlement' + structuredAgentSessionStartFailure, + type StructuredAgentSessionStartFailureCause +} from './structured-agent-session-failure-text' import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' import type { + StructuredAgentSessionChildEndCause, StructuredAgentSessionEndedChild, StructuredAgentSessionHostSession, StructuredAgentSessionProviderChildIdentity @@ -43,8 +50,8 @@ export type StructuredAgentSessionDeliveryLoopDeps = { ) => Promise /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ conversationFence: (sessionId: string) => number - /** What the chat says when the session could not be made ready. */ - startFailureText: (sessionId: string, cause: AgentSessionWireRefusal) => string + /** Who the chat's failure sentences name. */ + failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext onError: (sessionId: string, error: unknown) => void } @@ -52,10 +59,11 @@ type Step = 'continue' | 'stop' type Prepared = | 'stop' - | { ok: false; refusal: AgentSessionWireRefusal } + | Extract | { ok: true; awaited: StructuredAgentSessionProviderChildIdentity | null } -type StartFailure = { startKey: string | null; text: string } +/** A failed start before it is worded; `fail` words it once, through the one wording point. */ +type StartFailure = { startKey: string | null; cause: StructuredAgentSessionStartFailureCause } export class StructuredAgentSessionDeliveryLoop { private readonly running = new Set() @@ -92,8 +100,11 @@ export class StructuredAgentSessionDeliveryLoop { return } if (!prepared.ok) { - const text = this.deps.startFailureText(sessionId, prepared.refusal) - await this.deps.serialize(sessionId, () => this.fail(sessionId, { startKey: null, text })) + const { refusal, diagnostic } = prepared + const cause = { refusal, ...(diagnostic ? { diagnostic } : {}) } + await this.deps.serialize(sessionId, () => + this.fail(sessionId, { startKey: null, cause }) + ) return } // A child published before it proved its start takes no input yet; waited for outside @@ -107,13 +118,11 @@ export class StructuredAgentSessionDeliveryLoop { } } } catch (error) { + // The error is Orca's own and goes to the log; the chat says only that Orca failed. this.deps.onError(sessionId, error) - const text = this.deps.startFailureText(sessionId, { - code: 'agent_session_owner_restart_failed', - message: error instanceof Error ? error.message : String(error) - }) + const cause = { hostFault: true } as const await this.deps - .serialize(sessionId, () => this.fail(sessionId, { startKey: null, text })) + .serialize(sessionId, () => this.fail(sessionId, { startKey: null, cause })) .catch((failure: unknown) => { // Rows left queued are rejected by the next open, or by the next loop an accept wakes. this.running.delete(sessionId) @@ -130,7 +139,7 @@ export class StructuredAgentSessionDeliveryLoop { } await session.journal.rejectQueuedSubmissions( this.deps.conversationFence(sessionId), - DISPATCH_REJECTED_HOST_RESTARTED, + agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { surface: 'rejection' }), // A handle closes only with nothing queued, so one an earlier handle wrote is a leftover. (submission) => session.journal.wroteBeforeOpen(submission.acceptedSequence) ) @@ -157,7 +166,7 @@ export class StructuredAgentSessionDeliveryLoop { private async handOver( sessionId: string, awaited: StructuredAgentSessionProviderChildIdentity | null, - startFailure: string | null + startFailure: SubmissionRejectionFact | null ): Promise { const session = this.deps.sessions.get(sessionId) if (!session || this.disposed) { @@ -175,14 +184,17 @@ export class StructuredAgentSessionDeliveryLoop { if (!awaitedChild || (awaitedChild.phase === 'starting' && startFailure !== null)) { // The child waited on is gone, replaced by another, or settled its start without proving it. const ended = awaitedChild ? undefined : session.lastEndedChild + const endedFailure = ended ? structuredAgentSessionEndedChildFailure(ended) : undefined // A user's Stop is not a failure: the next step starts, or waits on, a child for what is - // queued. A host stop is: its cause is why the start did not land. - if (ended?.cause === 'user-stop') { + // queued. + if (endedFailure === null) { return 'continue' } return this.fail(sessionId, { startKey: awaited?.generation ?? null, - text: ended ? endedChildRejection(ended) : (startFailure ?? providerStartupFailureOutcome()) + cause: endedFailure ?? + // Gone with no end observed: nothing says the provider stopped. + { failure: startFailure ?? agentSessionFailureFact('startFailed') } }) } const next = oldestQueuedSubmission(session) @@ -195,7 +207,8 @@ export class StructuredAgentSessionDeliveryLoop { journal: session.journal, fence: awaitedChild.fence, adapter: this.deps.adapter, - providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase + providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase, + failureTextContext: this.deps.failureTextContext(sessionId) }, next ) @@ -207,7 +220,13 @@ export class StructuredAgentSessionDeliveryLoop { if (session) { await recordStructuredAgentSessionStartFailure( { journal: session.journal, fence: this.deps.conversationFence(sessionId) }, - failure + { + startKey: failure.startKey, + ...structuredAgentSessionStartFailure( + failure.cause, + this.deps.failureTextContext(sessionId) + ) + } ) } return this.stop(sessionId) @@ -235,18 +254,37 @@ function startThatFailedWhileQueued( ) { return null } - return { startKey: ended.generation, text: endedChildRejection(ended) } + const cause = structuredAgentSessionEndedChildFailure(ended) + return cause ? { startKey: ended.generation, cause } : null } -const HOST_STOPPED_BEFORE_DELIVERY = 'Orca stopped the agent before this message was sent.' - -/** Why a queued message the child never took is rejected, in the words the chat row uses. */ -function endedChildRejection(ended: StructuredAgentSessionEndedChild): string { - if (ended.cause === 'host-stop') { - return ended.reason ?? HOST_STOPPED_BEFORE_DELIVERY +function providerEndFailure( + ended: StructuredAgentSessionEndedChild +): StructuredAgentSessionStartFailureCause { + if (ended.duringStartup) { + return { exit: ended.failure } } - const reason = ended.reason ?? undefined - return ended.duringStartup - ? providerStartupFailureOutcome(reason) - : providerExitBeforeDeliveryRejection(reason) + return { failure: ended.failure ?? agentSessionFailureFact('providerExited') } +} + +// Every end cause, so a new one does not compile until it says whether it fails what is queued. +const ENDED_CHILD_FAILURE = { + 'user-stop': () => null, + // The host stopping the child is Orca's cause, never the provider's: a start that never finished. + 'host-stop': () => ({ failure: agentSessionFailureFact('hostStopped') }), + exit: providerEndFailure, + // The attach records its own fault as the end's failure. + 'attach-failed': providerEndFailure, + // Reached only when an eviction's stop landed and a later step failed, leaving the conversation. + evict: providerEndFailure +} satisfies Record< + StructuredAgentSessionChildEndCause, + (ended: StructuredAgentSessionEndedChild) => StructuredAgentSessionStartFailureCause | null +> + +/** Why a queued message the child never took is rejected; null when its end fails nothing. */ +export function structuredAgentSessionEndedChildFailure( + ended: StructuredAgentSessionEndedChild +): StructuredAgentSessionStartFailureCause | null { + return ENDED_CHILD_FAILURE[ended.cause](ended) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 0c2a76e05a5..f7a50627370 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -1,3 +1,4 @@ +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { stopAgentSessionProviderRoot, type StructuredAgentSessionLifecycleEvent @@ -49,6 +50,8 @@ export class StructuredAgentSessionEventRecovery { type: 'ended', sessionId, reason: `journal sink failure: ${error instanceof Error ? error.message : String(error)}`, + // Orca stopped the provider because its own journal failed. + failure: agentSessionFailureFact('hostFault'), cause: 'unexpected-exit', fence, acquisitionGeneration diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index ee664ebc0d4..317e7ca81a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -13,6 +13,7 @@ import { type StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { withObservedProviderExit } from './structured-agent-session-failure-text' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -23,6 +24,10 @@ import { const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' +// The refusal says what the chat's start failure says; the error text stays in the log. +const COULD_NOT_RESTART = "Codex couldn't restart. Send your message to try again." +const PROVIDER_STOPPED = + 'Codex stopped before it finished starting. Send your message to try again.' let root: string let store: AgentSessionRecordStore @@ -66,47 +71,75 @@ afterEach(async () => { }) describe('failed create owner verdict', () => { - it('answers an exit-proven failure as exited on the first call and its replay, and a new operation starts fresh', async () => { - // The cleanup's release proves the whole tree gone: the common failed start. - acquire.mockRejectedValueOnce(new Error(EXIT_REASON)) - const first = hostTestAttachParams(null) - const refusal = { - code: 'agent_session_operation_invalid', - message: EXIT_REASON, - ownerVerdict: 'exited' - } - - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - expect(acquire).toHaveBeenCalledOnce() + it.each([ + // The cleanup's release proves the whole tree gone, which says nothing about why it failed. + ['a failure the cleanup proved gone', () => new Error(EXIT_REASON), {}, COULD_NOT_RESTART], + // Only an exit the adapter saw says the provider stopped. + [ + 'an exit the adapter observed', + () => withObservedProviderExit(new Error(EXIT_REASON)), + { reason: 'providerStartFailed' }, + PROVIDER_STOPPED + ] + ])( + 'answers %s as exited on the first call and its replay, and a new operation starts fresh', + async (_case, failure, situation, message) => { + acquire.mockRejectedValueOnce(failure()) + const first = hostTestAttachParams(null) + // The replay names the same details as the first answer: the ledger kept them beside the code, + // and the verdict reaches released clients at the top level exactly as before. + const refusal = { + code: 'agent_session_operation_invalid', + details: { ...situation, ownerVerdict: 'exited' }, + message, + ownerVerdict: 'exited' + } - const retry = hostTestAttachParams(null) - expect(retry.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId) - await expect(host.attach(CALLER, retry)).resolves.toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledTimes(2) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - }) + await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) + await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) + expect(acquire).toHaveBeenCalledOnce() - it('answers a first-hand root exit as exited on the first call, in the shape its replay takes', async () => { - acquire.mockRejectedValueOnce( - new AgentSessionAcquisitionRootExitObservedError(new Error(EXIT_REASON)) - ) - const first = hostTestAttachParams(null) - const refusal = { - code: 'agent_session_operation_invalid', - message: EXIT_REASON, - ownerVerdict: 'exited' + const retry = hostTestAttachParams(null) + expect(retry.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId) + await expect(host.attach(CALLER, retry)).resolves.toMatchObject({ ok: true }) + expect(acquire).toHaveBeenCalledTimes(2) + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') } + ) + + it.each([ + // A cleanup that saw the root go may have stopped it itself. + ['a root exit the cleanup saw', () => new Error(EXIT_REASON), {}, COULD_NOT_RESTART], + [ + 'a root exit the adapter observed', + () => withObservedProviderExit(new Error(EXIT_REASON)), + { reason: 'providerStartFailed' }, + PROVIDER_STOPPED + ] + ])( + 'answers %s as exited on the first call, in the shape its replay takes', + async (_case, cause, situation, message) => { + acquire.mockRejectedValueOnce(new AgentSessionAcquisitionRootExitObservedError(cause())) + const first = hostTestAttachParams(null) + // The replay names the same details as the first answer: the ledger kept them beside the code, + // and the verdict reaches released clients at the top level exactly as before. + const refusal = { + code: 'agent_session_operation_invalid', + details: { ...situation, ownerVerdict: 'exited' }, + message, + ownerVerdict: 'exited' + } - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - expect(acquire).toHaveBeenCalledOnce() + await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) + await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) + expect(acquire).toHaveBeenCalledOnce() - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ - ok: true - }) - expect(acquire).toHaveBeenCalledTimes(2) - }) + await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + ok: true + }) + expect(acquire).toHaveBeenCalledTimes(2) + } + ) it('answers an acquisition refusal with its verdict directly', async () => { acquire.mockRejectedValueOnce(new AgentSessionAcquisitionRefusal('not signed in')) @@ -115,7 +148,8 @@ describe('failed create owner verdict', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - message: 'not signed in', + details: { reason: 'providerStartFailed', ownerVerdict: 'exited' }, + message: PROVIDER_STOPPED, ownerVerdict: 'exited' } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts index a5e00fde53c..90f52db3d09 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts @@ -1,7 +1,12 @@ -import type { - AgentSessionAttachResult, - AgentSessionMutationResult, - AgentSessionWireRefusal +import { + isAgentSessionWireRefusalCode, + refuse, + refuseUnclassified, + withAgentSessionRefusalFacts, + type AgentSessionAttachResult, + type AgentSessionMutationResult, + type AgentSessionRefusalDetailsByCode, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionOperationOutcome, @@ -15,16 +20,50 @@ import { AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionRefusal, AgentSessionAcquisitionRootExitObservedError, + AgentSessionPreSpawnError, isAgentSessionPreSpawnError } from './structured-agent-session-adapter' +import { + providerExitObserved, + structuredAgentSessionStartFailure +} from './structured-agent-session-failure-text' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' + +/** Who a failed acquisition's sentence names, and whether it was the session's first start. */ +export type FailedAcquisitionWording = { + record: AgentSessionRecord | null + newSession: boolean +} + +/** The refusal a failed acquisition answers with, worded as the start failure it records: its + * error text is Orca's or the provider's log wording, so it goes to the log, never the wire. */ +function failedAcquisitionWireRefusal( + details: AgentSessionRefusalDetailsByCode['agent_session_operation_invalid'] | undefined, + wording: FailedAcquisitionWording +): AgentSessionWireRefusal { + const code = 'agent_session_operation_invalid' + const refusal = details ? refuse(code, details, code) : refuseUnclassified(code, code) + const { reason } = structuredAgentSessionStartFailure( + { refusal, ...(wording.newSession ? { newSession: true as const } : {}) }, + structuredAgentSessionFailureWordsContext(wording.record) + ) + return { ...refusal, message: reason } +} /** What a failed acquisition proved about its process, and the outcome its operation settles to. */ -export function failedAcquisitionSettlement(error: unknown): { +export function failedAcquisitionSettlement( + error: unknown, + wording: FailedAcquisitionWording +): { exitProof: AgentSessionAcquisitionExitProof outcome: Extract } { if (error instanceof AgentSessionAcquisitionExitUnprovenError) { - const outcome = { code: 'agent_session_ownership_unknown', message: error.message } + const outcome = { + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' as const }, + message: error.message + } return { exitProof: 'unproven', outcome: { status: 'failed', ...outcome } } } const exitProof = isAgentSessionPreSpawnError(error) @@ -32,33 +71,79 @@ export function failedAcquisitionSettlement(error: unknown): { : error instanceof AgentSessionAcquisitionRootExitObservedError ? 'root-exit-observed' : 'exit-proven' - const message = error instanceof Error ? error.message : String(error) - const code = - error instanceof AgentSessionAcquisitionRefusal ? error.code : 'agent_session_operation_invalid' - return { exitProof, outcome: { status: 'failed', code, message } } + const raw = error instanceof Error ? error.message : String(error) + const details = failedAcquisitionDetails(error) + const message = + failedAcquisitionRefusal(error, wording)?.refusal.message ?? + // A store refusal's message is its code, which its replay has always carried. + (isAgentSessionWireRefusalCode(raw) + ? raw + : failedAcquisitionWireRefusal(details, wording).message) + return { + exitProof, + outcome: { + status: 'failed', + code: 'agent_session_operation_invalid', + ...(details ? { details } : {}), + message + } + } +} + +/** Cleanup proved the child gone after the start failed, whatever failed it. */ +function isExitProvenAcquisitionFailure(error: unknown): error is Error { + return ( + error instanceof AgentSessionAcquisitionRootExitObservedError || + error instanceof AgentSessionAcquisitionExitProvenError + ) +} + +/** The situation a failed acquisition stands for: what the adapter typed, or a provider the + * adapter saw exit while starting. A provider refusing a request, a timeout, a store refusal or + * Orca's own fault names none: the child being gone now says nothing about why. */ +function failedAcquisitionDetails( + error: unknown +): AgentSessionRefusalDetailsByCode['agent_session_operation_invalid'] | undefined { + if (error instanceof AgentSessionAcquisitionRefusal) { + return { reason: error.reason } + } + if (isAgentSessionPreSpawnError(error) && error.reason) { + return { reason: error.reason } + } + if (isExitProvenAcquisitionFailure(error) && providerExitObserved(error)) { + return { reason: 'providerStartFailed' } + } + return undefined } /** A failed acquisition answered as a refusal on the first call, in the shape its replay takes; * null leaves the error to the store-failure classification. */ export function failedAcquisitionRefusal( - error: unknown + error: unknown, + wording: FailedAcquisitionWording ): { ok: false; refusal: AgentSessionWireRefusal } | null { - if (error instanceof AgentSessionAcquisitionRefusal) { - return { ok: false, refusal: { code: error.code, message: error.message } } - } - // A proven exit is a settled fact; its message is the provider's own diagnostic. - if ( - error instanceof AgentSessionAcquisitionRootExitObservedError || - error instanceof AgentSessionAcquisitionExitProvenError - ) { + // A proven exit is a settled failure, answered in the shape its ledger row replays. + if (error instanceof AgentSessionAcquisitionRefusal || isExitProvenAcquisitionFailure(error)) { return { ok: false, - refusal: { code: 'agent_session_operation_invalid', message: error.message } + refusal: failedAcquisitionWireRefusal(failedAcquisitionDetails(error), wording) } } return null } +/** A start that failed before any process still throws, in the sentence its replay reads: the + * error's own text is Orca's or the refusing site's, so it goes to the log. A message that is + * itself a code keeps it, since the wire routes on it. */ +export function preSpawnFailureInWords(error: unknown, wording: FailedAcquisitionWording): unknown { + if (!isAgentSessionPreSpawnError(error) || /^[a-z0-9_]+$/.test(error.message)) { + return error + } + return new AgentSessionPreSpawnError(error, { + message: failedAcquisitionSettlement(error, wording).outcome.message + }) +} + /** Only a durably failed operation says anything about retrying under a new one. */ export function failedCreateRefusal( refusal: AgentSessionWireRefusal, @@ -68,7 +153,9 @@ export function failedCreateRefusal( return status === 'failed' && record ? { ok: false, - refusal: { ...refusal, ownerVerdict: agentSessionLeaseOwnerVerdict(record.lease) } + refusal: withAgentSessionRefusalFacts(refusal, { + ownerVerdict: agentSessionLeaseOwnerVerdict(record.lease) + }) } : { ok: false, refusal } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts index c61d93cbb8f..69f9b95c03a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts @@ -82,8 +82,11 @@ describe('a create that fails after its child wrote through the unbound sink', ( const failed = host.attach(CALLER, hostTestAttachParams(null)) await (cause instanceof AgentSessionPreSpawnError - ? expect(failed).rejects.toThrow(EXIT_REASON) - : expect(failed).resolves.toMatchObject({ ok: false, refusal: { message: EXIT_REASON } })) + ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") + : expect(failed).resolves.toMatchObject({ + ok: false, + refusal: { message: "Codex couldn't restart. Send your message to try again." } + })) await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true @@ -118,7 +121,7 @@ describe('a create that fails after its child wrote through the unbound sink', ( // The session stays indexed across this failure: it is a resume, not a create. const failed = host.attach(CALLER, hostTestAttachParams(releasedFence)) await (cause instanceof AgentSessionPreSpawnError - ? expect(failed).rejects.toThrow(EXIT_REASON) + ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") : expect(failed).resolves.toMatchObject({ ok: false })) const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts new file mode 100644 index 00000000000..fcebdda7726 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_PROVIDER_DIAGNOSTIC_CHARS, + providerDiagnostic, + withProviderDiagnostic +} from '../../../shared/agent-session-failure' +import { + AgentSessionRefusalError, + refuse, + refuseUnclassified +} from '../../../shared/agent-session-wire-refusals' +import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' +import { MAX_UNEXPECTED_EXIT_REASON_CHARS } from './structured-agent-session-dead-generation-settlement' +import { + structuredAgentSessionStartFailure, + withObservedProviderExit +} from './structured-agent-session-failure-text' + +/** What Orca's own error text looks like: a code, a marker, a uuid, a path, an exception. */ +const ORCA_INTERNAL = + /agent_session_|execution_owner|provider_[a-z_]+|[0-9a-f]{8}-[0-9a-f]{4}-|[/\\][\w.-]+[/\\]|Error:|ENOENT/ + +describe('structuredAgentSessionStartFailure', () => { + it('keeps a provider diagnostic only when the error carried one', () => { + const carried = withProviderDiagnostic( + new Error('claude stream-json exited (code 1): boom'), + providerDiagnostic('code 1\nboom', 'log') + ) + expect(structuredAgentSessionStartFailure({ error: carried }, { agentName: 'Claude' })).toEqual( + { + reason: "Claude couldn't start. Send your message to try again.", + rejection: { kind: 'startFailed', detail: { text: 'code 1\nboom', audience: 'log' } } + } + ) + // Orca's own words, however provider-like, are never promoted to a detail. + expect( + structuredAgentSessionStartFailure({ error: new Error('Not logged in. Run /login.') }) + .rejection + ).toEqual({ kind: 'startFailed' }) + }) + + it('keeps a start refusal the adapter typed', () => { + const refusal = new AgentSessionAcquisitionRefusal( + 'Claude is not signed in for the selected account.', + 'notSignedIn' + ) + expect(structuredAgentSessionStartFailure({ error: refusal }, { agentName: 'Claude' })).toEqual( + { + reason: + 'Claude is not signed in for the selected account. Sign in, then send your message again.', + rejection: { kind: 'notSignedIn' } + } + ) + }) + + it.each([ + [ + 'managedAccountEnvOverride', + 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' + ], + [ + 'accountSwitchInProgress', + 'A Claude account switch is in progress. Try again after it finishes.' + ], + [ + 'managedAccountUnsupported', + 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings, then send your message again.' + ] + ] as const)('words a start refused for %s by that situation', (reason, sentence) => { + const code = 'agent_session_operation_invalid' + expect( + structuredAgentSessionStartFailure( + { refusal: refuse(code, { reason }, code) }, + { agentName: 'Claude' } + ) + ).toEqual({ reason: sentence, rejection: { kind: reason } }) + }) + + it('words a refused restart by its situation, never its message', () => { + const words = structuredAgentSessionStartFailure( + { + refusal: refuse( + 'agent_session_ownership_unknown', + { reason: 'ownerUnproven' }, + 'Orca cannot prove that process 4242 on host-1 has exited.' + ) + }, + { agentName: 'Claude' } + ) + expect(words).toEqual({ + reason: "Claude couldn't restart. Send your message to try again.", + rejection: { + kind: 'restartFailed', + refusal: { + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' } + } + } + }) + }) + + it("reads an exit before the start as a failed start, and an Orca fault as Orca's", () => { + expect( + structuredAgentSessionStartFailure({ + exit: { kind: 'providerExited', detail: { text: 'stderr', audience: 'log' } } + }).rejection + ).toEqual({ kind: 'providerStartFailed', detail: { text: 'stderr', audience: 'log' } }) + expect(structuredAgentSessionStartFailure({ exit: { kind: 'hostFault' } }).rejection).toEqual({ + kind: 'hostFault' + }) + expect(structuredAgentSessionStartFailure({ hostFault: true }).reason).not.toMatch( + ORCA_INTERNAL + ) + }) + + it("holds any provider detail to the lease record's cap", () => { + expect(MAX_PROVIDER_DIAGNOSTIC_CHARS).toBe(512) + expect(MAX_UNEXPECTED_EXIT_REASON_CHARS).toBe(MAX_PROVIDER_DIAGNOSTIC_CHARS) + const long = 'x'.repeat(4_000) + // However the caller built the detail, the fact stores at most the cap. + const words = structuredAgentSessionStartFailure({ + diagnostic: { text: long, audience: 'log' } + }) + expect(words.rejection.detail?.text).toHaveLength(MAX_UNEXPECTED_EXIT_REASON_CHARS) + expect( + structuredAgentSessionStartFailure({ + exit: { kind: 'providerExited', detail: { text: long, audience: 'log' } } + }).rejection.detail?.text + ).toHaveLength(MAX_UNEXPECTED_EXIT_REASON_CHARS) + }) + + it('says the provider stopped only when an exit was observed', () => { + // An `exited` verdict says only that nothing runs now, not that the provider stopped. + const gone = refuseUnclassified('agent_session_operation_invalid', 'thread gone', { + ownerVerdict: 'exited' + }) + const person = { text: 'no rollout found for thread id T', audience: 'person' } as const + expect( + structuredAgentSessionStartFailure( + { refusal: gone, diagnostic: person }, + { agentName: 'Codex' } + ) + ).toEqual({ + reason: "Codex couldn't restart. Send your message to try again.", + rejection: { + kind: 'restartFailed', + detail: person, + refusal: { + code: 'agent_session_operation_invalid', + details: { ownerVerdict: 'exited' } + } + } + }) + const observed = refuse( + 'agent_session_operation_invalid', + { reason: 'providerStartFailed', ownerVerdict: 'exited' }, + 'exited (code 1)' + ) + expect( + structuredAgentSessionStartFailure({ + refusal: observed, + diagnostic: { text: 'code 1', audience: 'log' } + }) + ).toEqual({ + reason: 'The agent stopped before it finished starting. Send your message to try again.', + rejection: { kind: 'providerStartFailed', detail: { text: 'code 1', audience: 'log' } } + }) + // A start that threw proves nothing about the provider: it may be Orca's, or a failed spawn. + expect( + structuredAgentSessionStartFailure({ error: new Error('spawn claude ENOENT') }, {}) + ).toEqual({ + reason: "The agent couldn't start. Send your message to try again.", + rejection: { kind: 'startFailed' } + }) + // The same error, once the adapter that observed the child's exit marked it, blames the provider. + const exit = withObservedProviderExit( + withProviderDiagnostic(new Error('exited (code 1)'), providerDiagnostic('code 1', 'log')) + ) + expect( + structuredAgentSessionStartFailure({ error: new Error('wrapped', { cause: exit }) }) + ).toEqual({ + reason: 'The agent stopped before it finished starting. Send your message to try again.', + rejection: { kind: 'providerStartFailed', detail: { text: 'code 1', audience: 'log' } } + }) + }) + + it('reads a thrown refusal the same as a returned one', () => { + const thrown = new AgentSessionRefusalError( + refuse( + 'agent_session_conflict', + { reason: 'claimConflicted' }, + 'Another process claims this session.' + ) + ) + expect(thrown.message).toBe('agent_session_conflict') + expect(structuredAgentSessionStartFailure({ refusal: thrown.refusal }).rejection).toEqual({ + kind: 'restartFailed', + refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts new file mode 100644 index 00000000000..c0c2172230d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -0,0 +1,163 @@ +// The facts a start or an exit reduces to, and the one place a failed start is worded. The +// sentence itself comes from `agentSessionFailureWords`, beside the fact it states. + +import { + agentSessionFailureFact, + providerDiagnosticOf, + type SubmissionRejectionFact, + type AgentSessionFailureKind, + type ProviderDiagnostic +} from '../../../shared/agent-session-failure' +import type { AgentSessionRefusalReason } from '../../../shared/agent-session-refusal-details' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import { + agentSessionRefusalReference, + type AgentSessionWireRefusal +} from '../../../shared/agent-session-wire-refusals' +import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' + +/** Start refusals whose situation is itself what the person reads, with its own next step. */ +const TYPED_START_REFUSALS = [ + 'notSignedIn', + 'historyTooLarge', + 'managedAccountEnvOverride', + 'accountSwitchInProgress', + 'managedAccountUnsupported' +] as const satisfies readonly (AgentSessionFailureKind & + AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] + +function typedStartRefusal( + reason: string | undefined +): (typeof TYPED_START_REFUSALS)[number] | undefined { + return TYPED_START_REFUSALS.find((typed) => typed === reason) +} + +/** Marks the error an adapter observed its child's exit with, where it observed it. */ +export function withObservedProviderExit(error: TError): TError { + return Object.assign(error, { providerExitObserved: true }) +} + +/** Whether the adapter saw the child exit on its own. Follows `cause` and the errors a cleanup + * aggregated, since the acquisition errors wrap what the adapter threw. */ +export function providerExitObserved(error: unknown, depth = 0): boolean { + if (depth >= 6 || !(error instanceof Error)) { + return false + } + if ('providerExitObserved' in error && error.providerExitObserved === true) { + return true + } + if ( + error instanceof AggregateError && + error.errors.some((inner) => providerExitObserved(inner, depth + 1)) + ) { + return true + } + return providerExitObserved(error.cause, depth + 1) +} + +/** A start that did not land. A refusal the adapter typed keeps its situation, and an exit the + * adapter observed says the provider stopped; anything else blames no one — it may be Orca's, or + * a spawn that failed. Either keeps the provider's diagnostic when the error carried one. */ +export function providerStartupFailureFact(cause?: unknown): SubmissionRejectionFact { + const typed = typedStartRefusal( + cause instanceof AgentSessionAcquisitionRefusal ? cause.reason : undefined + ) + if (typed) { + return agentSessionFailureFact(typed) + } + return agentSessionFailureFact( + providerExitObserved(cause) ? 'providerStartFailed' : 'startFailed', + { detail: providerDiagnosticOf(cause) } + ) +} + +/** A child that ended before it proved its start: an exit is a start that failed, keeping the + * provider's diagnostic; an Orca fault or a typed start refusal stays what it was. */ +function startupFailureFromExit( + failure: SubmissionRejectionFact | undefined +): SubmissionRejectionFact { + if (!failure || failure.kind === 'providerExited') { + return agentSessionFailureFact('providerStartFailed', { detail: failure?.detail }) + } + return failure +} + +/** What the chat records when a session could not be made ready. Only a refusal the host typed as + * an observed exit says the provider stopped; a verdict of `exited` means only that nothing runs + * now. */ +function refusedStartFailureFact( + cause: Extract +): SubmissionRejectionFact { + const { refusal, diagnostic } = cause + const reason = refusal.details?.reason + const typed = typedStartRefusal(reason) + if (typed) { + return agentSessionFailureFact(typed) + } + if (reason === 'providerStartFailed') { + return agentSessionFailureFact('providerStartFailed', { detail: diagnostic }) + } + return agentSessionFailureFact(cause.newSession ? 'startFailed' : 'restartFailed', { + detail: diagnostic, + refusal: agentSessionRefusalReference(refusal) + }) +} + +/** Why a start the chat needed did not land, as the place that saw it knows it. */ +export type StructuredAgentSessionStartFailureCause = + /** The session could not be made ready; the provider's words, if any, are kept host-side, off + * the refusal. `newSession`: one that never ran, so it failed to start rather than restart. */ + | { refusal: AgentSessionWireRefusal; diagnostic?: ProviderDiagnostic; newSession?: true } + /** A start that threw, or an adapter's own startup failure; any diagnostic it carries. */ + | { error: unknown } + /** The child ended before it proved its start, as its ended event told it. */ + | { exit: SubmissionRejectionFact | undefined } + /** The provider exited while starting; only its words are known, and the caller names their + * audience. */ + | { diagnostic: ProviderDiagnostic | undefined } + /** Orca's own fault; its error belongs in the log. */ + | { hostFault: true } + /** Already typed where it was observed. */ + | { failure: SubmissionRejectionFact } + +/** A start failure's row repeats the sentence its rejected messages carry: both are about the + * messages the start was for. */ +export type StructuredAgentSessionStartFailureWords = AgentJournalDispatchRejection + +/** The fact a failed start records, for a writer that words it on its own surface. */ +export function structuredAgentSessionStartFailureFact( + cause: StructuredAgentSessionStartFailureCause +): SubmissionRejectionFact { + if ('refusal' in cause) { + return refusedStartFailureFact(cause) + } + if ('error' in cause) { + return providerStartupFailureFact(cause.error) + } + if ('exit' in cause) { + return startupFailureFromExit(cause.exit) + } + if ('diagnostic' in cause) { + return agentSessionFailureFact('providerStartFailed', { detail: cause.diagnostic }) + } + if ('hostFault' in cause) { + return agentSessionFailureFact('hostFault') + } + return cause.failure +} + +/** The one place a failed start is worded: the error row and every message it rejects carry this + * sentence and this fact, whichever writer saw the start fail. */ +export function structuredAgentSessionStartFailure( + cause: StructuredAgentSessionStartFailureCause, + context: AgentSessionFailureWordsContext = {} +): StructuredAgentSessionStartFailureWords { + return agentSessionFailureWords(structuredAgentSessionStartFailureFact(cause), { + ...context, + surface: 'rejection' + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 63d0b0143a4..dace93edc62 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -17,7 +17,7 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { structuredAgentSessionStartFailureText } from './structured-agent-session-send-preparation' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import { settleInterruptedCompaction } from './structured-compaction-recovery' import { recoverStructuredRewind } from './structured-rewind-recovery' @@ -57,8 +57,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { ensureProviderChild: input.ensureProviderChild, conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), - startFailureText: (sessionId, cause) => - structuredAgentSessionStartFailureText(deps.store.getRecord(sessionId), cause), + failureTextContext: (sessionId) => + structuredAgentSessionFailureWordsContext(deps.store.getRecord(sessionId)), onError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) }) const adoptOpened = async ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index c2487161f60..c9ba8714b4e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -9,7 +9,8 @@ // reentrant, so every public entry point takes it once and calls these. import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import { DISPATCH_REJECTED_PROVIDER_CLOSED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { evictStructuredAgentSession, STRUCTURED_AGENT_SESSION_EVICTION_STEPS, @@ -59,7 +60,7 @@ export async function abandonQueuedStructuredAgentSessionMessages( await journal .rejectQueuedSubmissions( structuredAgentSessionConversationFence(deps.store, sessionId), - DISPATCH_REJECTED_PROVIDER_CLOSED + agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) ) .catch((error: unknown) => deps.onEventSinkError?.({ sessionId, error })) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 0d19c7aacbe..35782b676c2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -21,7 +21,11 @@ import type { AgentSessionThreadGoalChange, AgentSessionThreadGoalResult } from '../../../shared/agent-session-wire' -import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection +} from '../../../shared/agent-session-failure-words' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' import { threadGoalPlan } from './structured-agent-session-thread-goal' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' @@ -170,7 +174,7 @@ export function cancelStructuredAgentSessionTurn( // Stop withdraws every queued message first, whatever the start or the child is doing. const withdrawn = await ctx.journal.rejectQueuedSubmissions( ctx.fence, - DISPATCH_REJECTED_CANCELLED + agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) ) const child = context.sessions.get(ctx.sessionId)?.child if (child?.phase === 'starting') { @@ -250,7 +254,10 @@ export async function settleStructuredAgentSessionLateDispatch( input: { sessionId: string clientMessageId: string - } & ({ providerIdentity: AgentJournalItemIdentity } | { state: 'rejected'; reason: string }) + } & ( + | { providerIdentity: AgentJournalItemIdentity } + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + ) ): Promise { const session = context.sessions.get(input.sessionId) if (!session) { @@ -270,51 +277,12 @@ export async function settleStructuredAgentSessionLateDispatch( clientMessageId: input.clientMessageId, state: 'rejected', reason: input.reason, + rejection: input.rejection, fence } ) } -/** - * Releases sends the provider can no longer be holding. - * - * A dispatch whose RPC timed out is recorded `unknown` — doubt, never proof of - * non-delivery — and a live `unknown` reads as work still owed, so the session - * shows working until something re-derives it. The provider reporting its thread - * not running, with no turn open, IS that re-derivation. - * - * `pending` is deliberately untouched: that send's dispatch has not returned yet - * and may be in flight right now. And `recovered` only retires the obligation — - * it never makes a send re-deliverable, because the provider may well have run it. - */ -export async function releaseStructuredAgentSessionUnansweredDispatches( - context: Pick & { - deps: { store: Pick } - }, - input: { sessionId: string; reason: string } -): Promise { - const session = context.sessions.get(input.sessionId) - if (!session) { - return - } - const stranded = session.journal - .submissions() - .filter((entry) => entry.dispatchState === 'unknown' && entry.recovered !== true) - if (stranded.length === 0) { - return - } - for (const entry of stranded) { - await session.journal.resolveDispatch({ - clientMessageId: entry.clientMessageId, - state: 'unknown', - // The earlier reason names a sharper fact than this one does. - reason: entry.reason ?? input.reason, - fence: structuredAgentSessionConversationFence(context.deps.store, input.sessionId), - recovered: true - }) - } -} - /** The host's thin mutation surface. Each call re-reads the context, so a session * map or fence that moves between calls is never captured by a stale closure. */ export function structuredAgentSessionMutationDelegates( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index af017c0c70d..44699536a6f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -1,3 +1,4 @@ +import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -64,6 +65,8 @@ export type StructuredAgentSessionEndedChild = StructuredAgentSessionProviderChi cause: StructuredAgentSessionChildEndCause /** Descriptive text only — the provider's diagnostic, or the host's cause. Decides nothing. */ reason: string | null + /** What the chat records about this end; absent reads as a provider exit with no detail. */ + failure?: SubmissionRejectionFact duringStartup: boolean startedFor?: string /** Where the conversation's journal stood when the child ended, to order the end against a diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 7faa50dbe47..58a84cecd5f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -139,11 +139,13 @@ describe('attach', () => { }) const params = attachParams() + // Orca's own store fault: the child is gone, but nothing blames the provider. const refused = { ok: false, refusal: { code: 'agent_session_operation_invalid', - message: 'agent_session_provider_handle_stale_fence', + details: { ownerVerdict: 'exited' }, + message: "Codex couldn't restart. Send your message to try again.", ownerVerdict: 'exited' } } @@ -161,7 +163,10 @@ describe('attach', () => { await expect(host.attach(CALLER, attachParams())).resolves.toMatchObject({ ok: false, - refusal: { message: 'commit failed', ownerVerdict: 'exited' } + refusal: { + message: "Codex couldn't restart. Send your message to try again.", + ownerVerdict: 'exited' + } }) expect(releaseAcquisition).toHaveBeenCalledWith({ sessionId: SESSION }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index c1a3075cbee..eba44143074 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -35,9 +35,9 @@ import * as sessionTabs from './structured-agent-session-host-tabs' import { structuredAgentSessionMutationDelegates, settleStructuredAgentSessionLateDispatch, - type StructuredAgentSessionMutationContext, - releaseStructuredAgentSessionUnansweredDispatches + type StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { releaseStructuredAgentSessionUnansweredDispatches } from './structured-agent-session-unanswered-dispatch-release' import { flushStructuredAgentSessionHost } from './structured-agent-session-host-teardown' import type { StructuredAgentSessionCaller, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index d2bfa110148..2194b992fb0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -24,6 +24,8 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' const CALLER = { callerKey: 'client-1' } @@ -237,14 +239,15 @@ describe('settling a send the provider proves it received after the ack window', sessionId: SESSION, clientMessageId: params.envelope.clientOperationId, state: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) }) expect(await submissions()).toMatchObject([ { clientMessageId: params.envelope.clientOperationId, dispatchState: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED + reason: DISPATCH_REJECTED_CANCELLED, + rejection: { kind: 'cancelled' } } ]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index e1895758e6d..213126fb5eb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -244,7 +244,8 @@ describe('a record an older build left mid terminal handoff', () => { handoffStage: 'recovering' }) // Sending and opening the chat both say what frees it: quitting that terminal agent. A send is - // accepted, then rejected by the start that cannot take the lease, and the chat's row says why. + // accepted, then rejected by the start that cannot take the lease, and the chat's row says why, + // worded from the refusal's details; only the live refusal names the process. const quitTerminal = 'This chat is still open in a terminal agent (process 4242). Quit that agent to continue the chat here.' expect(await delivered('while the terminal still runs')).toMatchObject({ @@ -254,7 +255,9 @@ describe('a record an older build left mid terminal handoff', () => { (await host.journalSnapshot(SESSION)).items.flatMap((item) => item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] ) - ).toEqual([expect.stringContaining(quitTerminal)]) + ).toEqual([ + "Codex couldn't restart. This chat is still open in a terminal agent. Quit that agent to continue the chat here." + ]) const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: false, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 24f341d28da..6a623428e4f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -15,10 +15,11 @@ import { } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionOperationDecision } from '../../../shared/agent-session-operation-ledger' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { - AgentSessionMutationEnvelope, - AgentSessionMutationResult, - AgentSessionWireRefusal +import { + refuse, + type AgentSessionMutationEnvelope, + type AgentSessionMutationResult, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { AGENT_SESSION_UNATTACHED_REFUSAL_CODE } from '../../../shared/structured-agent-session-read-refusal' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -31,10 +32,11 @@ import type { AgentSessionTurnContext } from './structured-agent-session-turns' // The code is shared with the client so a read that refuses this way can be told apart from a // transcript that failed to load; the two must never drift apart. -export const AGENT_SESSION_NOT_ATTACHED: AgentSessionWireRefusal = { - code: AGENT_SESSION_UNATTACHED_REFUSAL_CODE, - message: 'This host holds no attached session by that id.' -} +export const AGENT_SESSION_NOT_ATTACHED: AgentSessionWireRefusal = refuse( + AGENT_SESSION_UNATTACHED_REFUSAL_CODE, + { reason: 'sessionNotAttached' }, + 'This host holds no attached session by that id.' +) export function refuseAgentSessionMutation(refusal: AgentSessionWireRefusal): { ok: false diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts index 5d5009aa918..e4675bfefb1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts @@ -41,6 +41,8 @@ export async function runSettledAgentSessionMutation(input: { : { status: 'failed', code: outcome.refusal.code, + ...(outcome.refusal.details ? { details: outcome.refusal.details } : {}), + // The row's own field, which builds before details read; copied from the legacy mirror. ...(outcome.refusal.rewindReason ? { rewindReason: outcome.refusal.rewindReason } : {}) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts new file mode 100644 index 00000000000..7700a616b38 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts @@ -0,0 +1,61 @@ +// What an operation that needs the agent is told when it cannot have one: a typed refusal in +// words a person can read, never Orca's own error text. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import { ensureStructuredAgentSessionAgentForOperation } from './structured-agent-session-agent-start' +import { recordStructuredAgentSessionOptionIntent } from './structured-agent-session-options-read' + +const SESSION = 'session-1' + +describe('an operation whose agent start throws', () => { + it('is refused as a failed restart, with the error only in the log', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const cause = new Error('EACCES: permission denied, open /Users/me/.orca/leases.json') + const context = { + sessions: new Map(), + reconcileLeases: () => Promise.reject(cause) + } + + const refused = await ensureStructuredAgentSessionAgentForOperation( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial context double; the start throws at `reconcileLeases`, before any other member is read. + context as unknown as StructuredAgentSessionAttachContext, + SESSION + ) + + expect(refused).toEqual({ + ok: false, + refusal: { + code: 'agent_session_owner_restart_failed', + message: "The agent couldn't restart." + } + }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('starting the agent'), cause) + warn.mockRestore() + }) +}) + +describe('an option picked while the chat is at rest', () => { + it('refuses a key the provider would not accept as a rejected option', async () => { + const persistOptions = vi.fn(async () => {}) + const refused = await recordStructuredAgentSessionOptionIntent( + { + getRecord: () => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the intent reads only the record's provider and options. + ({ provider: 'codex', options: {} }) as unknown as AgentSessionRecord + }, + { sessionId: SESSION, persistOptions, publish: () => {} }, + { key: 'notAnOption', value: 'x' } + ) + + expect(refused).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'optionRejected' } + } + }) + expect(persistOptions).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts index 607db1e6c4b..47f67910cf9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts @@ -1,6 +1,10 @@ /** Option validation failed before the provider session was mutated. */ export class AgentSessionOptionRejectedError extends Error { - constructor(cause: unknown) { + constructor( + cause: unknown, + /** `providerStarting`: nothing is wrong with the value; the session cannot take it yet. */ + readonly refusalReason: 'optionRejected' | 'providerStarting' = 'optionRejected' + ) { super(cause instanceof Error ? cause.message : String(cause), { cause }) this.name = 'AgentSessionOptionRejectedError' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index fd113dcdcfd..2391ffa0205 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -5,9 +5,10 @@ // exists. A pick made at rest is written to the record as intent, through the same transition a live // pick takes, so the next start applies it. -import type { - AgentSessionOptionResult, - AgentSessionOptionsResult +import { + refuse, + type AgentSessionOptionResult, + type AgentSessionOptionsResult } from '../../../shared/agent-session-wire' import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -64,10 +65,11 @@ export async function recordStructuredAgentSessionOptionIntent( if (!record || !accepted) { return { ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: `${record?.provider ?? 'This session'} has no session option named ${input.key}` - } + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'optionRejected' }, + `${record?.provider ?? 'This session'} has no session option named ${input.key}` + ) } } const options = { ...record.options, [input.key]: input.value } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts new file mode 100644 index 00000000000..89772d45469 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -0,0 +1,185 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { mapRuntimeError } from '../../runtime/rpc/errors' +import { + AgentSessionPreSpawnError, + type AgentSessionPreSpawnReason, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { + attachFingerprintFields, + type AgentSessionAttachParams +} from './structured-agent-session-attach' +import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' +import { performAttach } from './structured-agent-session-attach-flow' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-alpha' +const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` +let root: string | null = null + +afterEach(async () => { + vi.restoreAllMocks() + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = null +}) + +function createParams(): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: SESSION, + clientOperationId: OPERATION, + expectedRuntimeFence: null, + payloadFingerprint: '' + }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + runtimeKind: 'native' + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(params) + }) + } + } +} + +/** The thrown first answer as the wire sends it, and the ledger's replay of the same operation. */ +async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { + root = await mkdtemp(join(tmpdir(), 'orca-pre-spawn-first-answer-')) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const unused = async (): Promise => { + throw new Error('not reached before a spawn') + } + const adapter: StructuredAgentSessionAdapter = { + acquire: async () => { + throw thrown + }, + dispatch: unused, + cancelTurn: unused, + answerPrompt: unused, + setOption: unused + } + const input = { + store, + adapter, + journalRoot: root, + openConversation: openTestAttachConversation(root), + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: OPERATION, + probe: { outcome: 'reservation-unused' as const } + }, + callerKey: 'client-1', + params: createParams(), + now: () => NOW, + onAttached: () => {} + } + const first = await performAttach(input).then( + () => null, + (error: unknown) => error + ) + expect(first).toBeInstanceOf(AgentSessionPreSpawnError) + return { + first: mapRuntimeError('req-1', { runtimeId: 'runtime-1' }, first), + replay: await performAttach(input) + } +} + +describe('a create that fails before any process spawns', () => { + it.each<[string, string, AgentSessionPreSpawnReason | undefined, string]>([ + [ + 'the managed account env override', + 'This Claude launch defines explicit Anthropic auth environment variables.', + 'managedAccountEnvOverride', + 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' + ], + [ + 'an account switch in progress', + 'A Claude account switch is in progress. Try again after it finishes.', + 'accountSwitchInProgress', + 'A Claude account switch is in progress. Try again after it finishes.' + ], + [ + 'a Claude account added in WSL', + 'structured Claude is not offered under the active managed Claude account', + 'managedAccountUnsupported', + 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings, then send your message again.' + ], + [ + "Orca's own reason", + 'claude sessions pin CLAUDE_CONFIG_DIR, not CODEX_HOME', + undefined, + "Claude couldn't start. Send your message to try again." + ] + ])('answers %s first in the words its replay reads', async (_, raw, reason, sentence) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + const { first, replay } = await firstAnswerAndReplay( + new AgentSessionPreSpawnError(new Error(raw), reason ? { reason } : {}) + ) + + // Thrown as before: the wire code is unchanged, only the words are the replay's. + expect(first).toMatchObject({ ok: false, error: { code: 'runtime_error', message: sentence } }) + expect(replay).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: sentence, + ...(reason ? { details: { reason } } : {}) + } + }) + if (!reason) { + expect(replay).not.toHaveProperty('refusal.details') + } + if (raw !== sentence) { + expect(JSON.stringify([first, replay])).not.toContain(raw) + } + // What failed is kept for the log. + expect(warn).toHaveBeenCalledWith( + '[agent-session] provider start failed:', + expect.objectContaining({ message: raw }) + ) + }) + + it('keeps a message that is itself a code, and the wire code it maps to', async () => { + const { first } = await firstAnswerAndReplay( + new AgentSessionPreSpawnError(new Error('runtime_unavailable')) + ) + + expect(first).toMatchObject({ + ok: false, + error: { code: 'runtime_unavailable', message: 'runtime_unavailable' } + }) + }) + + it('keeps the situation of a pre-spawn error it wraps', () => { + const typed = new AgentSessionPreSpawnError(new Error('switching'), { + reason: 'accountSwitchInProgress' + }) + + expect(new AgentSessionPreSpawnError(typed).reason).toBe('accountSwitchInProgress') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index d1ab41ccaef..348d5a3d90c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -240,7 +240,7 @@ describe('processless structured session reservation', () => { now: () => NOW, onAttached: () => {} }) - ).rejects.toThrow('workspace no longer exists') + ).rejects.toThrow("Codex couldn't restart. Send your message to try again.") expect(settlement).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ exitProof: 'processless', spawnToken: 'spawn-a' }) ) @@ -308,7 +308,9 @@ describe('processless structured session reservation', () => { onAttached: () => {} } - await expect(performAttach(input)).rejects.toThrow('launch not ready') + await expect(performAttach(input)).rejects.toThrow( + "Codex couldn't restart. Send your message to try again." + ) await expect(performAttach(input)).resolves.toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_invalid' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts index 7f71a9c28ce..7324e3be9ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts @@ -2,7 +2,7 @@ import type { AgentJournalItemBody, AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { refuse, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionTurnContext } from './structured-agent-session-turns' type PendingPromptBody = Extract @@ -11,8 +11,12 @@ export type PendingPromptValidation = | { ok: true; item: AgentJournalRenderItem; prompt: PendingPromptBody } | { ok: false; refusal: AgentSessionWireRefusal } -function invalid(message: string): PendingPromptValidation { - return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +/** The prompt the client named is not one waiting on the user: nothing to answer. */ +function promptGone(message: string): PendingPromptValidation { + return { + ok: false, + refusal: refuse('agent_session_operation_invalid', { reason: 'promptGone' }, message) + } } export function validatePendingPrompt( @@ -25,34 +29,36 @@ export function validatePendingPrompt( ): PendingPromptValidation { const item = ctx.journal.snapshot().items.find((entry) => entry.itemId === input.itemId) if (!item) { - return invalid(`No item ${input.itemId} in session ${ctx.sessionId}.`) + return promptGone(`No item ${input.itemId} in session ${ctx.sessionId}.`) } const prompt = item.body.kind === 'approval' || item.body.kind === 'question' ? item.body : null if (!prompt || (input.kind !== undefined && prompt.kind !== input.kind)) { - return invalid( + return promptGone( `Item ${input.itemId} is not a pending${input.kind ? ` ${input.kind}` : ' prompt'}.` ) } if (item.revision !== input.expectedRevision) { return { ok: false, - refusal: { - code: 'agent_session_item_revision_stale', - message: `Item ${input.itemId} has moved on.`, - currentRevision: item.revision, - resolution: prompt.resolution - } + refusal: refuse( + 'agent_session_item_revision_stale', + { reason: 'promptMoved', currentRevision: item.revision, resolution: prompt.resolution }, + `Item ${input.itemId} has moved on.` + ) } } if (prompt.resolution.state !== 'pending') { return { ok: false, - refusal: { - code: 'agent_session_already_resolved', - message: `Item ${input.itemId} was already ${prompt.resolution.state}.`, - currentRevision: item.revision, - resolution: prompt.resolution - } + refusal: refuse( + 'agent_session_already_resolved', + { + reason: 'promptAlreadyResolved', + currentRevision: item.revision, + resolution: prompt.resolution + }, + `Item ${input.itemId} was already ${prompt.resolution.state}.` + ) } } return { ok: true, item, prompt } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 3beaa9eaa81..fb66a1e1a8f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -12,14 +12,13 @@ import type { AgentSessionStatusSummary, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import { - DISPATCH_REJECTED_CANCELLED, - DISPATCH_REJECTED_PROVIDER_CLOSED -} from '../../../shared/structured-agent-session-dispatch-rejection' -import { - providerStartupFailureOutcome, - unexpectedProviderExitOutcome -} from './structured-agent-session-dead-generation-settlement' + agentSessionFailureFact, + type AgentSessionFailureFact, + type SubmissionRejectionFact +} from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' @@ -173,14 +172,22 @@ async function submission(id: string): Promise { +async function statusRows(): Promise< + { + itemId: string + text: string + tone?: string + failure?: AgentSessionFailureFact + }[] +> { return (await host.journalSnapshot(SESSION)).items.flatMap((item) => item.body.kind === 'status' ? [ { itemId: item.itemId, text: item.body.text, - ...(item.body.tone ? { tone: item.body.tone } : {}) + ...(item.body.tone ? { tone: item.body.tone } : {}), + ...(item.body.failure ? { failure: item.body.failure } : {}) } ] : [] @@ -201,6 +208,8 @@ function exit(child: ReturnType, reason: string, startupUnp type: 'ended', ...child, reason, + // As an adapter reports it: the exit's stderr as a log detail. + failure: { kind: 'providerExited', detail: { text: reason, audience: 'log' } }, cause: 'unexpected-exit', ...(startupUnproven ? { startupUnproven } : {}) }) @@ -347,7 +356,9 @@ describe('settling an earlier child before the next one takes its message', () = ) expect( items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) - ).toContain(unexpectedProviderExitOutcome('provider exited')) + ).toContain( + 'Codex stopped while this response was in progress. You can continue in this conversation.' + ) // Handed over at the new child's fence, which the attach reserved after settling. const newFence = store.getRecord(SESSION)!.lease.runtimeFence expect(newFence).toBeGreaterThan(releasedFence) @@ -356,14 +367,21 @@ describe('settling an earlier child before the next one takes its message', () = }) }) +const START_EXIT = 'claude stream-json exited (code 1)' +const START_TEXT = 'Codex stopped before it finished starting. Send your message to try again.' +const START_FAILURE: SubmissionRejectionFact = { + kind: 'providerStartFailed', + detail: { text: START_EXIT, audience: 'log' } +} + describe('a published child that dies while it proves its start', () => { - const EXIT = 'claude stream-json exited (code 1)' - const TEXT = providerStartupFailureOutcome(EXIT) + const EXIT = START_EXIT + const TEXT = START_TEXT it.each([['the loop sees the start fail first'], ['the exit is processed first']])( 'leaves one error row keyed by the start, and every queued message rejected with it: %s (R2)', async (order) => { - const settled = deferred() + const settled = deferred() adapterExtras = { awaitStarted: vi.fn(() => settled.promise) } await restartHost() acquire.mockImplementation(spawnStartingChild) @@ -375,9 +393,9 @@ describe('a published child that dies while it proves its start', () => { if (order === 'the exit is processed first') { await exit(child, EXIT, true) - settled.resolve(TEXT) + settled.resolve(START_FAILURE) } else { - settled.resolve(TEXT) + settled.resolve(START_FAILURE) await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected') ) @@ -391,11 +409,17 @@ describe('a published child that dies while it proves its start', () => { { itemId: `orca:${encodeURIComponent(`start-failure:${child.acquisitionGeneration}`)}`, text: TEXT, - tone: 'error' + tone: 'error', + failure: START_FAILURE } ]) - expect(await submission(first)).toMatchObject({ dispatchState: 'rejected', reason: TEXT }) - expect(await submission(second)).toMatchObject({ dispatchState: 'rejected', reason: TEXT }) + for (const id of [first, second]) { + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: TEXT, + rejection: START_FAILURE + }) + } expect(rejectedIn(events, second)).toBe(true) expect(dispatch).not.toHaveBeenCalled() expect(acquire).toHaveBeenCalledTimes(2) @@ -411,11 +435,11 @@ function startForOperation() { } describe('a start another operation made that dies while a sent message waits on it', () => { - const EXIT = 'claude stream-json exited (code 1)' - const TEXT = providerStartupFailureOutcome(EXIT) + const EXIT = START_EXIT + const TEXT = START_TEXT it("is the message's own failed start: one error row, the message rejected, no second start (R2)", async () => { - adapterExtras = { awaitStarted: vi.fn(async () => TEXT) } + adapterExtras = { awaitStarted: vi.fn(async () => START_FAILURE) } await restartHost() acquire.mockImplementation(spawnStartingChild) // An operation that needs the agent starts a child that has not proven its start. @@ -441,7 +465,8 @@ describe('a start another operation made that dies while a sent message waits on { itemId: `orca:${encodeURIComponent(`start-failure:${operationChild.acquisitionGeneration}`)}`, text: TEXT, - tone: 'error' + tone: 'error', + failure: START_FAILURE } ]) expect(rejectedIn(events, id)).toBe(true) @@ -514,9 +539,17 @@ describe('a child that ends before its message is handed over', () => { const events = await subscribe() await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) - expect((await submission(id))?.reason).toContain('codex app-server crashed') + // The exit's stderr rides beside the sentence, never in it. + const failure = { + kind: 'providerExited', + detail: { text: 'codex app-server crashed', audience: 'log' } + } + expect(await submission(id)).toMatchObject({ + reason: 'Codex stopped before this message was sent.', + rejection: failure + }) expect(await statusRows()).toEqual([ - { itemId: expect.any(String), text: (await submission(id))?.reason, tone: 'error' } + { itemId: expect.any(String), text: (await submission(id))?.reason, tone: 'error', failure } ]) expect(rejectedIn(events, id)).toBe(true) await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) @@ -587,7 +620,7 @@ describe('a quit with a message still queued', () => { expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', - reason: DISPATCH_REJECTED_PROVIDER_CLOSED + ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) }) }) @@ -615,7 +648,7 @@ describe('a quit with a message still queued', () => { expect(dispatch).not.toHaveBeenCalled() expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', - reason: DISPATCH_REJECTED_PROVIDER_CLOSED + ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) }) }) }) @@ -683,8 +716,8 @@ describe('how a stopped child ends the start its loop was waiting on', () => { expect(await statusRows()).toEqual([]) }) - it('fails the start after a host stop, with the stop as the reason (R2)', async () => { - const reason = 'Claude never finished starting, so Orca stopped it.' + it("fails the start after a host stop, as a start Orca stopped rather than the provider's (R2)", async () => { + const reason = 'the start watchdog fired' const second = await stoppedWhileStarting(() => host['serialize'](SESSION, () => stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { @@ -695,9 +728,14 @@ describe('how a stopped child ends the start its loop was waiting on', () => { ) await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) - expect((await submission(second))?.reason).toBe(reason) + // The sentence is the constructor's, not the reason the stop was given. + const text = 'Codex never finished starting, so Orca stopped it.' + expect(await submission(second)).toMatchObject({ + reason: text, + rejection: { kind: 'hostStopped' } + }) expect(await statusRows()).toEqual([ - { itemId: expect.any(String), text: reason, tone: 'error' } + { itemId: expect.any(String), text, tone: 'error', failure: { kind: 'hostStopped' } } ]) expect(dispatch).not.toHaveBeenCalled() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index 9f4a1a4e1c1..5da0861aa19 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -12,6 +12,8 @@ import { hostTestAttachParams, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' const CLAUDE_SESSION = 'claude-session' const hosts: StructuredAgentSessionHost[] = [] @@ -35,7 +37,12 @@ function claudeAdapter(): StructuredAgentSessionAdapter { observedAt: HOST_TEST_NOW } }), - dispatch: async () => ({ state: 'rejected', reason: 'unused' }), + dispatch: async () => ({ + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { + surface: 'rejection' + }) + }), cancelTurn: async () => ({ cancelled: false }), answerPrompt: async () => undefined, setOption: async () => undefined diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts new file mode 100644 index 00000000000..505bec74c23 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts @@ -0,0 +1,274 @@ +// A refusal's details name the situation, so every place that answers for a refusal — the first +// reply, a ledger replay, the store fallback copy — must name the same one. + +import { describe, expect, it } from 'vitest' +import { + agentSessionRecordFixture, + agentSessionLeaseFixture +} from '../../../shared/agent-session-record.test-fixture' +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { classifyStoreFailure } from './structured-agent-session-attach' +import { + AgentSessionAcquisitionExitProvenError, + AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRefusal, + AgentSessionPreSpawnError +} from './structured-agent-session-adapter' +import { + failedAcquisitionRefusal, + failedAcquisitionSettlement +} from './structured-agent-session-failed-create-refusal' +import { withObservedProviderExit } from './structured-agent-session-failure-text' +import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' + +const CLAUDE_CREATE = { + record: agentSessionRecordFixture(), + newSession: true +} + +function replay(outcome: Parameters[0]['outcome']) { + return resolveAgentSessionReplayOutcome({ + operationId: 'op-1', + outcome, + reconstruct: () => null + }) +} + +describe('a ledger replay names the details its first answer did', () => { + it.each([ + [new AgentSessionAcquisitionRefusal('not signed in', 'notSignedIn'), 'notSignedIn'], + [ + new AgentSessionAcquisitionExitProvenError( + withObservedProviderExit(new Error('exited (code 1)')) + ), + 'providerStartFailed' + ], + // Gone now, with no exit observed: no situation, on the first answer or the replay. + [new AgentSessionAcquisitionExitProvenError(new Error('spawn codex ENOENT')), undefined] + ])('for a failed create: %s', (error, reason) => { + const first = failedAcquisitionRefusal(error, CLAUDE_CREATE) + const replayed = replay(failedAcquisitionSettlement(error, CLAUDE_CREATE).outcome) + expect(first?.refusal.details?.reason).toBe(reason) + expect(replayed).toMatchObject({ + decision: 'refuse', + refusal: { code: first?.refusal.code, ...(reason ? { details: first?.refusal.details } : {}) } + }) + if (!reason) { + expect(replayed).not.toHaveProperty('refusal.details') + } + }) + + it('for a create whose cleanup could not prove the child gone', () => { + const outcome = failedAcquisitionSettlement( + new AgentSessionAcquisitionExitUnprovenError(new Error('probe failed')), + CLAUDE_CREATE + ).outcome + expect(replay(outcome)).toMatchObject({ + refusal: { code: 'agent_session_ownership_unknown', details: { reason: 'ownerUnproven' } } + }) + }) + + it.each([ + [ + new AgentSessionAcquisitionExitProvenError( + withObservedProviderExit( + new Error('claude stream-json exited (code 1): claude: not signed in (rig)') + ) + ), + 'Claude stopped before it finished starting. Send your message to try again.' + ], + [ + new AgentSessionAcquisitionExitProvenError(new Error('spawn claude ENOENT')), + "Claude couldn't start. Send your message to try again." + ], + [ + new AgentSessionAcquisitionRefusal( + 'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.', + 'notSignedIn' + ), + 'Claude is not signed in for the selected account. Sign in, then send your message again.' + ], + [ + AgentSessionAcquisitionRefusal.historyTooLarge( + 'Codex thread history exceeds the bounded restore queue; history was not partially imported.' + ), + "This conversation's history is too large to restore here. Start a new chat to continue." + ] + ])('answers and replays %s in the sentence its start failure reads as', (error, sentence) => { + const first = failedAcquisitionRefusal(error, CLAUDE_CREATE) + const replayed = replay(failedAcquisitionSettlement(error, CLAUDE_CREATE).outcome) + expect(first?.refusal.message).toBe(sentence) + expect(replayed).toMatchObject({ decision: 'refuse', refusal: { message: sentence } }) + for (const message of [ + first?.refusal.message, + failedAcquisitionSettlement(error, CLAUDE_CREATE).outcome.message + ]) { + expect(message).not.toMatch( + /stream-json|exited \(code|ENOENT|CLAUDE_CONFIG_DIR|restore queue/ + ) + } + }) + + it('replays a spawn that failed before any process in a sentence, not its error', () => { + const outcome = failedAcquisitionSettlement( + new AgentSessionPreSpawnError(new Error('spawn claude ENOENT')), + CLAUDE_CREATE + ).outcome + expect(replay(outcome)).toMatchObject({ + refusal: { message: "Claude couldn't start. Send your message to try again." } + }) + }) + + it('keeps the code a store refusal replays with, and the unproven-exit marker', () => { + expect( + failedAcquisitionSettlement(new Error('agent_session_conflict'), CLAUDE_CREATE).outcome + .message + ).toBe('agent_session_conflict') + expect( + failedAcquisitionSettlement( + new AgentSessionAcquisitionExitUnprovenError(new Error('probe failed')), + CLAUDE_CREATE + ).outcome.message + ).toBe('agent_session_acquisition_exit_unproven') + }) + + it('replays the facts beside the reason, and mirrors them where released clients read', () => { + const resolution = { + state: 'resolved' as const, + selectedOptionId: 'allow', + resolvedBy: 'phone', + resolvedAt: 5 + } + const replayed = replay({ + status: 'failed', + code: 'agent_session_item_revision_stale', + details: { reason: 'promptMoved', currentRevision: 3, resolution } + }) + expect(replayed).toMatchObject({ + refusal: { + code: 'agent_session_item_revision_stale', + details: { reason: 'promptMoved', currentRevision: 3, resolution }, + currentRevision: 3, + resolution + } + }) + }) + + it('reads a row an older host wrote, with no details, as naming none', () => { + const replayed = replay({ status: 'failed', code: 'agent_session_conflict' }) + expect(replayed).toMatchObject({ refusal: { code: 'agent_session_conflict' } }) + expect(replayed.decision === 'refuse' && replayed.refusal).not.toHaveProperty('details') + }) + + it('drops the cause an unreleased build wrote, and a reason the code does not list', () => { + for (const row of [ + { status: 'failed' as const, code: 'agent_session_conflict', cause: 'claimConflicted' }, + { + status: 'failed' as const, + code: 'agent_session_conflict', + details: { reason: 'promptGone' as const } + } + ]) { + const replayed = replay(row) + expect(replayed).toMatchObject({ refusal: { code: 'agent_session_conflict' } }) + expect(replayed.decision === 'refuse' && replayed.refusal).not.toHaveProperty('details') + } + }) + + it('names a code this build cannot place as refused earlier', () => { + expect(replay({ status: 'failed', code: 'agent_session_future_code' })).toMatchObject({ + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'operationRefusedEarlier' } + } + }) + }) + + it('names a lost outcome and a lost result', () => { + expect(replay({ status: 'unknown' })).toMatchObject({ + refusal: { details: { reason: 'outcomeUnknown' } } + }) + expect(replay({ status: 'succeeded', sessionId: 's' })).toMatchObject({ + refusal: { details: { reason: 'resultLost' } } + }) + }) +}) + +describe('the store fallback copy', () => { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-1' } + }) + ) + + it('words a situation its code would misdescribe by the situation', () => { + const refusal = classifyStoreFailure( + agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'replaySuperseded' }), + null, + record + ) + expect(refusal).toMatchObject({ + code: 'agent_session_ownership_unknown', + details: { reason: 'replaySuperseded' } + }) + // Not the latched-owner story: this owner is not in doubt, the replay is just stale. + expect(refusal.message).not.toContain('4242') + }) + + it('keeps the latched-owner story, with its reason, for an owner it cannot prove gone', () => { + const refusal = classifyStoreFailure( + agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'ownerUnproven' }), + null, + record + ) + expect(refusal.details?.reason).toBe('ownerUnproven') + expect(refusal.message).toContain('4242') + }) + + it('does not promise an update fixes a record this build cannot read', () => { + // Unreadable covers a damaged record as well as one a newer build wrote. + expect( + classifyStoreFailure( + agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }), + null, + null + ) + ).toEqual({ + code: 'execution_owner_reconciling', + details: { reason: 'recordUnreadable' }, + message: + "Orca can't read this chat's saved state. If a newer version of Orca saved it, update Orca to open it; otherwise start a new chat." + }) + }) + + it('names the latch for a bare code an older path still throws', () => { + expect( + classifyStoreFailure(new Error('agent_session_conflict'), null, { + ...record, + lease: { ...record.lease, claimStatus: 'conflicted' } + }).details + ).toEqual({ reason: 'claimConflicted' }) + expect( + classifyStoreFailure(new Error('agent_session_conflict'), null, null) + ).not.toHaveProperty('details') + }) + + it('puts the current fence in the details of a stale checkpoint, and mirrors it', () => { + expect( + classifyStoreFailure( + agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'fenceStale' }), + 7, + null + ) + ).toEqual({ + code: 'agent_session_checkpoint_stale', + details: { reason: 'fenceStale', currentFence: 7 }, + currentFence: 7, + message: 'The session store refused this call: agent_session_checkpoint_stale.' + }) + // A bare code names no reason, but the fence is still a fact. + expect( + classifyStoreFailure(new Error('agent_session_checkpoint_stale'), 7, null) + ).toMatchObject({ details: { currentFence: 7 }, currentFence: 7 }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts index 9d5cab66e27..7a7a9e289ef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts @@ -9,7 +9,10 @@ import { terminalOwnerRefusalMessage } from '../../../shared/agent-session-legacy-handoff-lease' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionWireRefusalCode } from '../../../shared/agent-session-wire' +import type { + AgentSessionAnyRefusalReason, + AgentSessionRefusalReference +} from '../../../shared/agent-session-wire' function ownerDescription(record: AgentSessionRecord): string { const owner = record.lease.ownerProcess @@ -25,19 +28,63 @@ function latchedMessage(record: AgentSessionRecord): string { : 'Orca cannot tell whether an agent started for this session before the app stopped, so it will not start a second one on the same conversation. Quit any leftover agent process for this workspace and reopen this chat.' } -/** Null when the code has no session-specific story to tell; the caller keeps its own wording. */ +/** + * A situation whose own words the store's code would get wrong: each of these reaches the chat as + * `ownership_unknown` or `conflict`, which by code alone would read as the latched-owner story. + */ +const SITUATION_MESSAGES: Partial> = { + replaySuperseded: 'A newer start of this chat replaced this one. Try again.', + leaseMoved: 'This chat changed hands while Orca was starting it. Try again.', + spawnIdentityMismatch: + 'The agent that started was not the one Orca launched, so Orca did not use it. Try again.', + identityMismatch: + 'This chat belongs to a different workspace, agent or account. Start a new chat to continue.', + sessionExists: 'This chat already exists. Open it to continue.', + tabIdTaken: 'Another chat already uses this tab. Try again.', + conversationHeldElsewhere: + 'Another chat is already working in this conversation. Open that chat to continue.', + // Also a damaged record, which no update opens. + recordUnreadable: + "Orca can't read this chat's saved state. If a newer version of Orca saved it, update Orca to open it; otherwise start a new chat." +} + +/** + * The words and the situation for a store refusal. The reason is what the emitter named, when it + * named one; the latched branches name their own. Null when neither has a story to tell, and the + * caller keeps its own wording. + */ export function structuredAgentSessionRefusalMessage( - code: AgentSessionWireRefusalCode, + emitted: AgentSessionRefusalReference, record: AgentSessionRecord | null -): string | null { +): { message: string; reference: AgentSessionRefusalReference } | null { + const reason = emitted.details?.reason + const situational = reason ? SITUATION_MESSAGES[reason] : undefined + if (situational) { + return { message: situational, reference: emitted } + } if (!record) { return null } + const { code } = emitted if (code === 'agent_session_ownership_unknown' || code === 'agent_session_conflict') { - return latchedMessage(record) + const message = latchedMessage(record) + if (record.lease.claimStatus === 'conflicted') { + return { message, reference: { code, details: { reason: 'claimConflicted' } } } + } + return { + message, + reference: + code === 'agent_session_conflict' && reason === 'ownerAlive' + ? { code, details: { reason: 'ownerAlive' } } + : { code, details: { reason: 'ownerUnproven' } } + } } if (code === 'execution_owner_reconciling') { - return 'Orca is still working out who owns this session on this machine. Reopen the chat in a moment.' + return { + message: + 'Orca is still working out who owns this session on this machine. Reopen the chat in a moment.', + reference: { code, details: { reason: 'hostReconciling' } } + } } return null } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts index c81c45dfba5..00fa33a30fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts @@ -1,9 +1,11 @@ import { rewindRefusal } from './structured-rewind-refusal' import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' import { - AGENT_SESSION_WIRE_REFUSAL_CODES, - type AgentSessionWireRefusal, - type AgentSessionWireRefusalCode + agentSessionRefusalFromReference, + readAgentSessionRefusalReference, + refuse, + type AgentSessionRefusalReference, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' export type AgentSessionReplayOutcomeDecision = @@ -23,15 +25,12 @@ export function resolveAgentSessionReplayOutcome(input: { if (outcome.rewindReason) { return { decision: 'refuse', refusal: rewindRefusal(outcome.rewindReason).refusal } } - const code = (AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(outcome.code) - ? (outcome.code as AgentSessionWireRefusalCode) - : 'agent_session_operation_invalid' return { decision: 'refuse', - refusal: { - code, - message: outcome.message ?? `Operation ${operationId} was already refused: ${outcome.code}.` - } + refusal: agentSessionRefusalFromReference( + recordedRefusal(outcome.code, outcome.details), + outcome.message ?? `Operation ${operationId} was already refused: ${outcome.code}.` + ) } } if (outcome.status === 'unknown') { @@ -44,10 +43,11 @@ export function resolveAgentSessionReplayOutcome(input: { } return { decision: 'refuse', - refusal: { - code: 'agent_session_operation_unknown', - message: `The outcome of operation ${operationId} is unknown; it was not run again.` - } + refusal: refuse( + 'agent_session_operation_unknown', + { reason: 'outcomeUnknown' }, + `The outcome of operation ${operationId} is unknown; it was not run again.` + ) } } const recorded = input.reconstruct() @@ -60,10 +60,22 @@ export function resolveAgentSessionReplayOutcome(input: { return outcome.status === 'succeeded' ? { decision: 'refuse', - refusal: { - code: 'agent_session_operation_unknown', - message: `Operation ${operationId} succeeded, but its result is no longer reconstructable.` - } + refusal: refuse( + 'agent_session_operation_unknown', + { reason: 'resultLost' }, + `Operation ${operationId} succeeded, but its result is no longer reconstructable.` + ) } : { decision: 'rerun' } } + +/** The refusal a failed row recorded, so a replay says what the first answer said. A code this + * build does not know was refused for a reason it cannot name. */ +function recordedRefusal(code: string, details: unknown): AgentSessionRefusalReference { + return ( + readAgentSessionRefusalReference({ code, details }) ?? { + code: 'agent_session_operation_invalid', + details: { reason: 'operationRefusedEarlier' } + } + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index e96f2b77c7f..9dd04929385 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -7,6 +7,11 @@ // reports what happened. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + readAgentSessionFailureFact, + type UnreadAgentSessionFailureFact +} from '../../../shared/agent-session-failure' +import type { AgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionMutationEnvelope, AgentSessionMutationResult, @@ -39,6 +44,8 @@ export type StructuredAgentSessionContinuationOutcome = { sessionId: string outcome: 'continued' | 'pending' | 'unknown' | 'refused' reason?: string + /** The refusal that kept the agent from starting; `reason` is then its code. */ + refusal?: AgentSessionRefusalReference } /** The slice of the host one continuation needs. Structural so this module never imports the host. */ @@ -129,7 +136,11 @@ export class RestartContinuationSupersededError extends AgentSessionPreDispatchE } } -type ContinuationSubmission = { dispatchState?: string; reason?: string | null } +type ContinuationSubmission = { + dispatchState?: string + reason?: string | null + rejection?: UnreadAgentSessionFailureFact +} export type StructuredAgentSessionContinuationDeps = { /** Runtime fence as it stands now; null when this host has no record of the session. */ @@ -310,10 +321,13 @@ function refusedBy( sessionId: string, submission: ContinuationSubmission ): StructuredAgentSessionContinuationOutcome { + // A start the agent was refused files that refusal's code, which the failure guidance keys on. + const refusal = readAgentSessionFailureFact(submission.rejection)?.refusal return { sessionId, outcome: 'refused', - reason: submission.reason ?? 'agent_session_dispatch_rejected' + reason: refusal?.code ?? submission.reason ?? 'agent_session_dispatch_rejected', + ...(refusal ? { refusal } : {}) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index 9b5a89c313a..ee769b9e8c3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts @@ -116,6 +116,7 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { failedAt: failure.failedAt, outcome: failure.outcome, reason: failure.reason, + ...(failure.details ? { details: failure.details } : {}), retryable: deps.retryable(failure.marker) } ] @@ -167,6 +168,7 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { reason: resumed ? action.failureReason(outcome.sessionId) : (outcome.reason ?? 'agent_session_resume_refused'), + ...(!resumed && outcome.details ? { details: outcome.details } : {}), latestPrompt: promptBySession.get(outcome.sessionId) ?? '', latestUserItemId: action.markers.get(outcome.sessionId)?.latestUserItemId ?? null }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 4b10484a24b..d88a6330e78 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -11,6 +11,7 @@ import { import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' import { adapter, @@ -43,7 +44,11 @@ export async function startAgent(state: { export async function interruptedRestart( work: 'turn' | 'submission' | 'send-after-reply' | 'children' = 'turn', - historyBoundaryConsistent = true + historyBoundaryConsistent = true, + /** What the restarted host proves about the recorded owner; gone unless a test says otherwise. */ + probeOwner: NonNullable = async () => ({ + outcome: 'pid-absent' + }) ) { const previous = hostTestState() await attach() @@ -130,7 +135,7 @@ export async function interruptedRestart( journalRoot: previous.root, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-next', - probeOwner: async () => ({ outcome: 'pid-absent' }), + probeOwner, recoveryCapsule: new AgentSessionRecoveryCapsule(previous.root), now: () => clock.now }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts index b3fabba11d5..d36e5702d1d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts @@ -6,6 +6,8 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { AgentSessionRecoveryCapsule, AGENT_SESSION_RECOVERY_CAPSULE_FILE @@ -279,7 +281,12 @@ it('keeps a resume retryable when its agent started but refused the continuation })) state.dispatch.mockResolvedValueOnce({ state: 'rejected', - reason: 'the provider refused the turn' + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { + detail: { text: 'the provider refused the turn', audience: 'log' } + }), + { surface: 'rejection' } + ) }) const first = await host.restartResume.continueAfterRestart([SESSION], 'modal') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index a66eaa07432..b45e08314a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -31,6 +31,8 @@ import { HOST_TEST_THREAD as THREAD, hostTestMessage } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' afterEach(() => vi.useRealTimers()) @@ -98,7 +100,7 @@ it('still continues a chat whose last send acquisition proves was never delivere const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect((await host.journalSnapshot(SESSION)).submissions[0]).toMatchObject({ dispatchState: 'rejected', - reason: 'not_delivered' + rejection: { kind: 'notDelivered' } }) expect(acquire).toHaveBeenCalledTimes(1) expect(dispatch).toHaveBeenCalledTimes(1) @@ -226,7 +228,12 @@ it('waits out a slow provider answer before reporting the continuation', async ( await dispatched.promise await new Promise((resolve) => setTimeout(resolve, 20)) expect(settled).toBe(false) - settlement.resolve({ state: 'rejected', reason: 'provider refused' }) + settlement.resolve({ + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { + surface: 'rejection' + }) + }) expect((await continuing).continued).toMatchObject([{ outcome: 'refused' }]) expect(dispatch).toHaveBeenCalledTimes(1) }) @@ -448,3 +455,29 @@ it('logs teardown capsule publication failure and still releases the provider', warning.mockRestore() await rm(capsulePath, { recursive: true }) }) + +// The resume ledger's reason stays the refusal code, which is what every renderer's guidance keys +// on; the details are filed beside it, never in its place. +it('files a restart refused by a conflicted claim under its code, with its details beside it', async () => { + // The terminal agent that holds the claim is still running, so nothing may take it over. + const { host, store } = await interruptedRestart('turn', true, async () => ({ + outcome: 'identity-matched', + matchedOn: ['spawn-token'] + })) + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { + ...record.lease, + claimStatus: 'conflicted', + ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'terminal' } + } + })) + + await host.restartResume.continueAfterRestart([SESSION], 'modal') + + await vi.waitFor(async () => + expect(await host.restartResume.listFailures()).toMatchObject([ + { reason: 'agent_session_conflict', details: { reason: 'claimConflicted' } } + ]) + ) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index 058f6bc7c95..d053caf006b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -3,6 +3,10 @@ // action removes them — or files what went wrong. Starting the chat's agent again withdraws them. import { randomUUID } from 'node:crypto' +import { + AgentSessionRefusalError, + agentSessionRefusalFromReference +} from '../../../shared/agent-session-wire-refusals' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import type { @@ -231,8 +235,12 @@ export function createStructuredAgentSessionRestartResume( ) if ('done' in started) { continued.push(started.done) - if (started.done.outcome === 'refused') { - throw new Error(started.done.reason ?? 'agent_session_continuation_refused') + const { outcome, reason, refusal } = started.done + if (outcome === 'refused') { + // Thrown as a refusal so the filed failure keeps its details beside the code. + throw refusal + ? new AgentSessionRefusalError(agentSessionRefusalFromReference(refusal, refusal.code)) + : new Error(reason ?? 'agent_session_continuation_refused') } return } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts index 05b3ef96450..a298e23f454 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts @@ -8,6 +8,10 @@ // Claude's `resume` by session id, Codex's thread id — which is native continuation. Nothing // re-sends the user's prompt: that is what makes an agent redo work it already finished. +import { + isAgentSessionRefusalError, + type AgentSessionAnyRefusalDetails +} from '../../../shared/agent-session-wire-refusals' import { forEachWithConcurrency } from '../../../shared/map-with-concurrency' import type { StructuredAgentSessionResumeCandidate } from './structured-agent-session-restart-resume-set' @@ -26,6 +30,8 @@ export type StructuredAgentSessionResumeOutcome = { outcome: 'resumed' | 'refused' /** Refusal code; `agent_session_resume_already_in_progress` names the live owner in `owner`. */ reason?: string + /** A thrown refusal's details, kept apart so `reason` stays the code readers match. */ + details?: AgentSessionAnyRefusalDetails owner?: string } @@ -122,10 +128,12 @@ async function resumeOne( } catch (error) { const reason = error instanceof Error ? error.message : String(error) const owner = resumeAdmissionOwner(error) + const details = isAgentSessionRefusalError(error) ? error.refusal.details : undefined return { sessionId, outcome: 'refused', reason, + ...(details ? { details } : {}), ...(owner === null ? {} : { owner }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts index d5d6bd0d7ab..3c91a9732b9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts @@ -12,6 +12,7 @@ // DELETES the record rather than filtering it forever. What remains are structural checks that are not about work at all: the record still // exists and this build supports it, and the lease is free. +import type { AgentSessionAnyRefusalDetails } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionProviderHandleChainHead, @@ -56,6 +57,8 @@ export type StructuredAgentSessionResumeFailure = StructuredAgentSessionResumeCa outcome: AgentSessionResumeFailureOutcome /** The host's or provider's refusal code, verbatim, so it can be quoted in a report. */ reason: string + /** The refusal's details beside its code in `reason`; absent on older records and non-refusals. */ + details?: AgentSessionAnyRefusalDetails /** Whether naming it in an action would run it again: whether it is still an offer. A * continuation the chat already holds, or the user having moved on, makes a retry a no-op no * matter what the reason says. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index a1bb79bf149..2a1899ab1cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -10,6 +10,7 @@ // a send does. And a journal it cannot open is not a refusal — the chat shows that failure with a // Retry, so the tab is worth publishing either way. +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { adapterSupportsRecord } from './structured-agent-session-provider-support' import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' @@ -26,10 +27,12 @@ export async function revealStructuredAgentSession( ): Promise { const record = deps.store.getRecord(sessionId) if (!record) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } if (!adapterSupportsRecord(deps.adapter, record)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) } // Lease state is not consulted on purpose: this neither claims the lease nor spawns a child, so a // contested or reconciling chat still reveals and the send that follows adjudicates it. Refusing diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts new file mode 100644 index 00000000000..79882025889 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionConversationCommandRecord } from '../../../shared/agent-session-conversation-command' +import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import { + openConversationForWrite, + structuredAgentSessionSendBlock +} from './structured-agent-session-send-preparation' + +function withCommand(command: AgentSessionConversationCommandRecord) { + return { ...agentSessionRecordFixture(), conversationCommand: command } +} + +const COMMAND = { operationId: 'operation-1', callerKey: 'client-1', runtimeFence: 7 } + +describe('a send refused by the conversation command it follows', () => { + it('says a /clear that never committed did not finish, not that the chat was cleared', () => { + const blocked = structuredAgentSessionSendBlock( + withCommand({ + ...COMMAND, + command: 'clear', + state: 'unknown', + phase: 'prepared', + replacementSessionId: 'clear-replacement-1' + }) + ) + + expect(blocked?.refusal).toMatchObject({ + code: 'agent_session_operation_invalid', + details: { reason: 'clearUnconfirmed' }, + message: "The last /clear didn't finish. Start a new chat to continue." + }) + }) + + it('says a committed /clear cleared the conversation', () => { + const blocked = structuredAgentSessionSendBlock( + withCommand({ + ...COMMAND, + command: 'clear', + state: 'completed', + phase: 'committed', + replacementSessionId: 'clear-replacement-1' + }) + ) + + expect(blocked?.refusal).toMatchObject({ + code: 'agent_session_operation_invalid', + details: { reason: 'conversationCleared' } + }) + }) + + it('keeps an unconfirmed /compact as an unconfirmed command', () => { + const blocked = structuredAgentSessionSendBlock( + withCommand({ ...COMMAND, command: 'compact', state: 'unknown', phase: 'prepared' }) + ) + + expect(blocked?.refusal).toMatchObject({ + details: { reason: 'conversationCommandUnconfirmed' } + }) + }) + + it('lets a send follow a /clear whose new conversation failed to start', () => { + expect( + structuredAgentSessionSendBlock( + withCommand({ ...COMMAND, command: 'clear', state: 'completed', phase: 'committed' }) + ) + ).toBeNull() + }) +}) + +describe('a write whose conversation the host could not open', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + const ENVELOPE = { + sessionId: 'session-1', + clientOperationId: 'operation-1', + expectedRuntimeFence: 1, + payloadFingerprint: '' + } + + function refusedBy(error: unknown) { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + return openConversationForWrite(async () => { + throw error + }, ENVELOPE) + } + + it('says a corrupt history is final, in words and not the error', async () => { + const corrupt = Object.assign(new Error('/Users/me/journal.db: file is not a database'), { + code: 'ERR_SQLITE_ERROR', + errcode: 26 + }) + + expect(await refusedBy(corrupt)).toEqual({ + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' }, + message: 'Unable to load this chat.' + } + }) + }) + + it('says any other failed open can clear', async () => { + const denied = Object.assign(new Error('EACCES: permission denied, open /Users/me'), { + code: 'EACCES', + errno: -13 + }) + + expect(await refusedBy(denied)).toEqual({ + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' }, + message: "Orca couldn't open this chat's history right now. Try again." + } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index 70b4b658ba1..259ad16c858 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -13,6 +13,7 @@ import type { import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { CodexAppServerRequestError } from '../../codex/codex-app-server-request-error' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -378,12 +379,17 @@ describe('a send with no live owner', () => { await loseOwner() acquire.mockRejectedValue(new Error('Not signed in. Run codex login')) const params = sendParams('while signed out') - const cause = - 'The provider stopped before it finished starting: Not signed in. Run codex login.' + // The acquire's error is Orca's wrapper, not the provider's words: it goes to the log. No exit + // was observed, so the chat does not say the provider stopped. + const cause = "Codex couldn't restart. Send your message to try again." const id = await accept(params) - expect(await settled(id)).toMatchObject({ dispatchState: 'rejected', reason: cause }) + expect(await settled(id)).toMatchObject({ + dispatchState: 'rejected', + reason: cause, + rejection: { kind: 'restartFailed' } + }) expect(dispatch).not.toHaveBeenCalled() // Accepted, so the ledger answers a resend with the rejection rather than a second attempt. expect( @@ -393,6 +399,38 @@ describe('a send with no live owner', () => { expect(await errorStatuses()).toEqual([cause]) }) + it("keeps Codex's own words behind a refused resume without saying the provider stopped", async () => { + await loseOwner() + const said = `no rollout found for thread id ${THREAD}` + acquire.mockRejectedValue( + new CodexAppServerRequestError('thread/resume', -32600, `thread/resume failed: ${said}`, said) + ) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + const id = await accept(sendParams('after the thread went away')) + + // The sentence names no cause and quotes nothing; Codex's words ride in the fact for Details. + const rejection = { + kind: 'restartFailed', + detail: { text: said, audience: 'person' }, + refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } + } + expect(await settled(id)).toMatchObject({ + dispatchState: 'rejected', + reason: "Codex couldn't restart. Send your message to try again.", + rejection + }) + expect(await errorStatuses()).toEqual([ + "Codex couldn't restart. Send your message to try again." + ]) + // Orca's own text is logged once where the start failed. + expect(warn).toHaveBeenCalledWith( + '[agent-session] provider start failed:', + expect.objectContaining({ message: `thread/resume failed: ${said}` }) + ) + warn.mockRestore() + }) + it('restarts again for a Retry under a new id, and replays a resend of the same id', async () => { await loseOwner() acquire.mockRejectedValue(new Error('Not signed in')) @@ -445,8 +483,14 @@ describe('a send with no live owner', () => { expect(unresumable).toMatchObject({ dispatchState: 'rejected', - reason: - "Codex couldn't restart: This execution host cannot resume the requested structured agent session. Start a new chat to continue." + reason: "Codex couldn't restart. Start a new chat to continue.", + rejection: { + kind: 'restartFailed', + refusal: { + code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' } + } + } }) }) @@ -465,8 +509,13 @@ describe('a send with no live owner', () => { const id = await accept(sendParams('owner being settled')) - const cause = "Codex couldn't restart: Another runtime is still adjudicating this lease." - expect(await settled(id)).toMatchObject({ dispatchState: 'rejected', reason: cause }) + // The refusal's prose stays out of the chat; its code rides in the fact. + const cause = "Codex couldn't restart. Send your message to try again." + expect(await settled(id)).toMatchObject({ + dispatchState: 'rejected', + reason: cause, + rejection: { kind: 'restartFailed', refusal: { code: 'execution_owner_reconciling' } } + }) expect(acquire).not.toHaveBeenCalled() expect(await errorStatuses()).toEqual([cause]) }) @@ -480,9 +529,11 @@ describe('a send with no live owner', () => { const id = await accept(sendParams('bookkeeping failed')) + // Orca's own fault: reported to the log, and the chat says only that Orca failed. expect(await settled(id)).toMatchObject({ dispatchState: 'rejected', - reason: "Codex couldn't restart: spawn-token mint failed." + reason: "Orca ran into a problem, so this didn't go through. Try again.", + rejection: { kind: 'hostFault' } }) expect(hostErrors).toContainEqual( expect.objectContaining({ message: 'spawn-token mint failed' }) @@ -559,7 +610,7 @@ describe('a write fenced to an owner the pane has not seen replaced', () => { expect(await settled(id)).toMatchObject({ dispatchState: 'rejected', - reason: expect.stringContaining('Not signed in') + rejection: { kind: 'restartFailed' } }) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(seenFence + 1) const published = frames.slice(subscribed) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index d95369a0494..a7a0e971055 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -5,16 +5,18 @@ // or a journal that cannot be opened. import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { - AgentSessionMutationEnvelope, - AgentSessionWireRefusal +import { + refuse, + type AgentSessionMutationEnvelope, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import type { AgentSessionWireRefusalCode } from '../../../shared/agent-session-wire-refusals' import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' +import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import { agentSessionWriteNoticeEnglish } from '../../../shared/agent-session-refusal-notice' import { - ownerRestartFailedOutcome, - providerStartupFailureOutcome -} from './structured-agent-session-dead-generation-settlement' + classifyJournalOpenFailure, + type JournalOpenFailure +} from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { AGENT_SESSION_NOT_ATTACHED, @@ -23,29 +25,6 @@ import { import { rewindRefusal } from './structured-rewind-refusal' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -/** - * Whether a refused start leaves the chat anything to start again from. `unresumable`: this host - * has nothing to restart it from — no record, or none it can run — so only a new chat continues. - * A new wire code does not compile until it is classified here. - */ -const START_REFUSAL_RESUMABLE: Record = { - execution_owner_reconciling: true, - agent_session_conflict: true, - agent_session_checkpoint_stale: true, - agent_session_ownership_unknown: true, - agent_session_operation_capacity: true, - structured_agent_session_unsupported: false, - agent_session_operation_conflict: true, - agent_session_operation_expired: true, - agent_session_operation_invalid: true, - agent_session_operation_unknown: true, - agent_session_item_revision_stale: true, - agent_session_already_resolved: true, - agent_session_identity_required: false, - agent_session_journal_unreadable: true, - agent_session_owner_restart_failed: true -} - /** Why the record refuses any send right now, whoever owns it; null when a send may run. */ export function structuredAgentSessionSendBlock( record: AgentSessionRecord | null @@ -62,12 +41,25 @@ export function structuredAgentSessionSendBlock( ) { return { ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: command.replacementSessionId - ? 'This conversation has been cleared. Use the current conversation.' - : 'The conversation operation is unconfirmed.' - } + refusal: + command.phase === 'committed' && command.replacementSessionId + ? refuse( + 'agent_session_operation_invalid', + { reason: 'conversationCleared' }, + 'This conversation has been cleared. Use the current conversation.' + ) + : // A prepared /clear names its replacement before that conversation exists. + command.command === 'clear' + ? refuse( + 'agent_session_operation_invalid', + { reason: 'clearUnconfirmed' }, + "The last /clear didn't finish. Start a new chat to continue." + ) + : refuse( + 'agent_session_operation_invalid', + { reason: 'conversationCommandUnconfirmed' }, + 'The conversation operation is unconfirmed.' + ) } } return null @@ -84,18 +76,21 @@ export async function openConversationForWrite( } return { ok: false, refusal: AGENT_SESSION_NOT_ATTACHED } } catch (error) { + console.warn('[agent-session] opening the conversation for a write failed:', error) + const reason = classifyJournalOpenFailure(error) return { ok: false, - refusal: { - code: 'agent_session_journal_unreadable', - message: `The conversation could not be opened: ${ - error instanceof Error ? error.message : String(error) - }` - } + refusal: refuse('agent_session_journal_unreadable', { reason }, JOURNAL_OPEN_MESSAGE[reason]) } } } +// Released clients print a refusal's message for a send; it fits a Stop too. +const JOURNAL_OPEN_MESSAGE: Record = { + journalCorrupt: agentSessionWriteNoticeEnglish(['historyUnusable']), + journalUnavailable: agentSessionWriteNoticeEnglish(['historyUnavailable', 'tryAgain']) +} + /** The conversation a write lands in, opened when this host holds it closed. */ export function openForWrite( context: Pick, @@ -130,18 +125,9 @@ export function sendPreparation( } } -/** What the chat says, in its row and on every message it rejects, when the delivery loop could - * not make the session ready. A child that died starting reads as any start that died does. */ -export function structuredAgentSessionStartFailureText( - record: AgentSessionRecord | null, - cause: AgentSessionWireRefusal -): string { - if (cause.ownerVerdict === 'exited') { - return providerStartupFailureOutcome(cause.message) - } - return ownerRestartFailedOutcome({ - agentName: record ? TUI_AGENT_DISPLAY_NAMES[record.provider] : 'The agent', - reason: cause.message, - resumable: START_REFUSAL_RESUMABLE[cause.code] - }) +/** Who a failure sentence names: the chat's agent, when the record says. */ +export function structuredAgentSessionFailureWordsContext( + record: AgentSessionRecord | null +): AgentSessionFailureWordsContext { + return record ? { agentName: TUI_AGENT_DISPLAY_NAMES[record.provider] } : {} } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index 5f6e541ae64..f339aa1113f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -101,11 +101,17 @@ function exitBeforeProof(): Promise { type: 'ended', ...currentChild(), reason: EXIT_REASON, + failure: { kind: 'providerExited', detail: { text: EXIT_REASON, audience: 'log' } }, cause: 'unexpected-exit', startupUnproven: true }) } +const STARTUP_FAILURE = { + kind: 'providerStartFailed', + detail: { text: EXIT_REASON, audience: 'log' } +} + async function journalStatuses(): Promise { return (await host.journalSnapshot(SESSION)).items.flatMap((item) => item.body.kind === 'status' ? [item.body.text] : [] @@ -201,14 +207,15 @@ describe('a send into a published session whose child ended before startup', () // host admitted, so nothing pins the session and Retry stays offered, and one row names the cause. expect(await submission(held)).toMatchObject({ dispatchState: 'rejected', - reason: expect.stringContaining(EXIT_REASON), + reason: 'Codex stopped before it finished starting. Send your message to try again.', + rejection: STARTUP_FAILURE, recovered: true }) expect( (await host.journalSnapshot(SESSION)).submissions.filter((e) => e.dispatchState === 'pending') ).toEqual([]) expect((await journalStatuses()).slice(rowsBefore)).toEqual([ - expect.stringMatching(/stopped before it finished starting: .*not signed in/) + 'Codex stopped before it finished starting. Send your message to try again.' ]) // Accepted before the restart it needed, so the chat draws it above the row naming the cause. const snapshot = await host.journalSnapshot(SESSION) @@ -254,12 +261,13 @@ describe('a send while the child of the first start is still proving itself', () expect(await submission(held)).toMatchObject({ dispatchState: 'rejected', - reason: expect.stringContaining(EXIT_REASON), + reason: 'Codex stopped before it finished starting. Send your message to try again.', + rejection: STARTUP_FAILURE, recovered: true }) expect(acquire).toHaveBeenCalledOnce() expect(await journalStatuses()).toEqual([ - expect.stringMatching(/stopped before it finished starting: .*not signed in/) + 'Codex stopped before it finished starting. Send your message to try again.' ]) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(fence + 1) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index 24e55bbba63..d2b9d27c1a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -23,6 +23,8 @@ import { HOST_TEST_THREAD as THREAD, hostTestMessage } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' let store: AgentSessionRecordStore let host: StructuredAgentSessionHost @@ -176,7 +178,9 @@ describe('send', () => { dispatch .mockImplementationOnce(async () => ({ state: 'rejected' as const, - reason: 'provider_write_failed: broken pipe' + ...agentSessionFailureWords(agentSessionFailureFact('writeFailed'), { + surface: 'rejection' + }) })) .mockImplementationOnce(async () => accepted()) const body = hostTestMessage('never written') @@ -185,7 +189,7 @@ describe('send', () => { await host.send(CALLER, params) await expect(delivered(params.envelope.clientOperationId)).resolves.toMatchObject({ dispatchState: 'rejected', - reason: 'provider_write_failed: broken pipe' + reason: 'provider_write_failed' }) // What the user's Retry does with a rejection: a fresh client message id, // which is a first delivery by construction and cannot duplicate the frame diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 3fa8fc51d25..7602afa230b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -383,7 +383,10 @@ describe('settled attach retry', () => { await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: false, - refusal: { message: 'resume rejected', ownerVerdict: 'exited' } + refusal: { + message: "Codex couldn't restart. Send your message to try again.", + ownerVerdict: 'exited' + } }) expect(releaseAcquisition).toHaveBeenCalledTimes(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts index 2134e339292..5598cd3c4f7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts @@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { unexpectedProviderExitOutcome } from './structured-agent-session-dead-generation-settlement' import { setStructuredAgentSessionHost } from './structured-agent-session-registry' import { collectSubscriber, @@ -77,7 +76,10 @@ describe('a turn its gone agent left running', () => { await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) expect(await workingTurnState()).toBe('interrupted') - expect(JSON.stringify(reader.events)).toContain(unexpectedProviderExitOutcome('killed')) + // The death evidence is Orca's log text: the row says only that the provider stopped. + expect(JSON.stringify(reader.events)).toContain( + 'Codex stopped while this response was in progress. You can continue in this conversation.' + ) expect(rig.adapter.acquire).toHaveBeenCalledOnce() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index 00594966101..c0660eb9791 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -1,25 +1,46 @@ +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' -import { boundJournalStatusText } from '../agent-session-journal/journal-prompt-body-bounds' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import type { StructuredAgentSessionStartFailureWords } from './structured-agent-session-failure-text' + +/** A start that failed, keyed like its row, in the words `structuredAgentSessionStartFailure` gave. */ +export type StructuredAgentSessionStartFailure = StructuredAgentSessionStartFailureWords & { + startKey: string | null +} /** * The one row a start that failed leaves in the chat, whoever saw it fail: an error row, so the * reason outlives any error strip. Keyed by the start — the child's generation, or the oldest * message it was for when no child was ever published — so a second report of the same failure - * revises the row instead of adding one. + * lands on the same row instead of adding one. */ export function structuredAgentSessionStartFailureRow( startKey: string, - text: string + words: StructuredAgentSessionStartFailureWords ): JournalLifecycleMutationInput { return { kind: 'item', - identity: { provider: 'orca', clientMessageId: `start-failure:${startKey}` }, - body: { kind: 'status', text: boundJournalStatusText(text), tone: 'error' } + identity: startFailureRowIdentity(startKey), + // The row repeats the sentence the start's rejected messages carry. + body: { kind: 'status', text: words.reason, tone: 'error', failure: words.rejection } } } +/** Whether the start's row is already written. Its words are the ones its rejected messages carry, + * and rejected is terminal, so the exit's later report of the same start must not reword it. */ +export function hasStructuredAgentSessionStartFailureRow( + items: readonly { itemId: string }[], + startKey: string +): boolean { + const itemId = agentJournalItemKey(startFailureRowIdentity(startKey)) + return items.some((item) => item.itemId === itemId) +} + +function startFailureRowIdentity(startKey: string) { + return { provider: 'orca' as const, clientMessageId: `start-failure:${startKey}` } +} + /** * A start the delivery loop needed and did not get: the start's row, and every queued message * rejected with the same words. Writes nothing when nothing is still queued: a start whose @@ -27,7 +48,7 @@ export function structuredAgentSessionStartFailureRow( */ export async function recordStructuredAgentSessionStartFailure( session: Pick & { fence: number }, - failure: { startKey: string | null; text: string } + failure: StructuredAgentSessionStartFailure ): Promise { const oldest = oldestQueuedSubmission(session) if (!oldest) { @@ -38,9 +59,12 @@ export async function recordStructuredAgentSessionStartFailure( settlementId: `start-failure:${startKey}`, fence: session.fence, recovered: true, - mutations: [structuredAgentSessionStartFailureRow(startKey, failure.text)] + mutations: [structuredAgentSessionStartFailureRow(startKey, failure)] + }) + await session.journal.rejectQueuedSubmissions(session.fence, { + reason: failure.reason, + rejection: failure.rejection }) - await session.journal.rejectQueuedSubmissions(session.fence, failure.text) } export function oldestQueuedSubmission( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index cf5c135297c..5ce1fdb3694 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -7,11 +7,14 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + agentSessionFailureFact, + type SubmissionRejectionFact +} from '../../../shared/agent-session-failure' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { providerStartupFailureOutcome } from './structured-agent-session-dead-generation-settlement' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { HOST_TEST_NOW as NOW, @@ -25,7 +28,11 @@ import { const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' -const ADAPTER_FAILURE = 'Claude Code exited before it finished starting: not signed in' +const ADAPTER_FAILURE = agentSessionFailureFact('notSignedIn') +const ADAPTER_FAILURE_TEXT = + 'Codex is not signed in for the selected account. Sign in, then send your message again.' +// The exit's reason is Orca's log text; the row says only that the start stopped. +const EXIT_TEXT = 'Codex stopped before it finished starting. Send your message to try again.' // The first child (generation-1) is lost at setup; the send starts generation-2. const START_ROW = agentJournalItemKey({ provider: 'orca', @@ -40,8 +47,8 @@ let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let generation = 0 -let settleStart: (failure: string | undefined) => void = () => {} -let awaitStarted = vi.fn<() => Promise>() +let settleStart: (failure: SubmissionRejectionFact | undefined) => void = () => {} +let awaitStarted = vi.fn<() => Promise>() let frames: AgentSessionSubscribeEvent[] = [] function exitBeforeProof(): Promise { @@ -106,7 +113,9 @@ beforeEach(async () => { resetHostTestOperationIds() generation = 0 frames = [] - awaitStarted = vi.fn(() => new Promise((resolve) => (settleStart = resolve))) + awaitStarted = vi.fn( + () => new Promise((resolve) => (settleStart = resolve)) + ) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) host = new StructuredAgentSessionHost({ store, @@ -156,14 +165,15 @@ describe('a queued message whose start fails and whose child then exits', () => await eventually(async () => expect(await submission(queued)).toMatchObject({ dispatchState: 'rejected', - reason: ADAPTER_FAILURE + reason: ADAPTER_FAILURE_TEXT, + rejection: ADAPTER_FAILURE }) ) await exitBeforeProof() await host.flushStreamedEvents(SESSION) - expect(await startRows()).toEqual([ADAPTER_FAILURE]) - expect(publishedStartRows()).toEqual([ADAPTER_FAILURE]) + expect(await startRows()).toEqual([ADAPTER_FAILURE_TEXT]) + expect(publishedStartRows()).toEqual([ADAPTER_FAILURE_TEXT]) }) it('leaves the row to the loop when the exit lands while the message still waits', async () => { @@ -177,9 +187,8 @@ describe('a queued message whose start fails and whose child then exits', () => ) await host.flushStreamedEvents(SESSION) - const outcome = providerStartupFailureOutcome(EXIT_REASON) - expect(await startRows()).toEqual([outcome]) + expect(await startRows()).toEqual([EXIT_TEXT]) // Written once, after the message was settled, not first by the exit and again by the loop. - expect(publishedStartRows()).toEqual([outcome]) + expect(publishedStartRows()).toEqual([EXIT_TEXT]) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index 3fd3c24a100..dae8635cd69 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -4,7 +4,6 @@ import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import { providerStartupFailureOutcome } from './structured-agent-session-dead-generation-settlement' import { settleUnexpectedStructuredAgentSessionExit, type StructuredAgentSessionUnexpectedExitContext, @@ -13,7 +12,8 @@ import { const SESSION = 'session-1' const GENERATION = 'generation-1' -const REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' +const REASON = 'claude stream-json exited (code 1): session limit reached' +const STARTUP_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' function startedSession(): StructuredAgentSessionUnexpectedExitSession & { journal: { appendLifecycleBatch: ReturnType } @@ -76,6 +76,8 @@ describe('a provider that ends before it finished starting', () => { await settleUnexpectedStructuredAgentSessionExit(contextFor(session), { ...ended, + // The adapter typed the start's own failure; the host keeps it rather than reword it. + failure: { kind: 'notSignedIn' }, startupUnproven: true }) @@ -85,12 +87,16 @@ describe('a provider that ends before it finished starting', () => { expect.objectContaining({ // The same row the delivery loop writes for a failed start: an error, keyed by it. identity: { provider: 'orca', clientMessageId: `start-failure:${GENERATION}` }, - body: { kind: 'status', text: providerStartupFailureOutcome(REASON), tone: 'error' } + body: { + kind: 'status', + text: 'Claude is not signed in for the selected account. Sign in, then send your message again.', + tone: 'error', + failure: { kind: 'notSignedIn' } + } }) ] }) ) - expect(providerStartupFailureOutcome(REASON)).toContain('not signed in') }) it('keeps an ordinary idle exit silent', async () => { @@ -108,7 +114,10 @@ describe('a provider that ends before it finished starting', () => { child: { generation: GENERATION, fence: 7, phase: 'starting' as const } } - await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) + await settleUnexpectedStructuredAgentSessionExit(contextFor(session), { + ...ended, + failure: { kind: 'providerExited', detail: { text: REASON, audience: 'log' } } + }) expect(session.journal.appendLifecycleBatch).toHaveBeenCalledWith( expect.objectContaining({ @@ -116,7 +125,16 @@ describe('a provider that ends before it finished starting', () => { expect.objectContaining({ // The same row the delivery loop writes for a failed start: an error, keyed by it. identity: { provider: 'orca', clientMessageId: `start-failure:${GENERATION}` }, - body: { kind: 'status', text: providerStartupFailureOutcome(REASON), tone: 'error' } + // The exit's stderr stays out of the sentence, as a log detail beside it. + body: { + kind: 'status', + text: STARTUP_TEXT, + tone: 'error', + failure: { + kind: 'providerStartFailed', + detail: { text: REASON, audience: 'log' } + } + } }) ] }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index aff0ca46f7b..98e50783bdf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -25,7 +25,6 @@ import { import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' -import { unexpectedProviderExitOutcome } from './structured-agent-session-dead-generation-settlement' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' import { @@ -37,6 +36,11 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' + +const UNEXPECTED_PROVIDER_EXIT_OUTCOME = + 'Codex stopped while this response was in progress. You can continue in this conversation.' const CALLER = { callerKey: 'client-1' } /** Short enough to keep the suite fast; the host clock below decides what is idle. */ @@ -217,7 +221,12 @@ beforeEach(async () => { } }) closeSession = vi.fn(async () => true) - dispatch = vi.fn(async () => ({ state: 'rejected' as const, reason: 'unused' })) + dispatch = vi.fn(async () => ({ + state: 'rejected' as const, + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { + surface: 'rejection' + }) + })) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) openHost() }) @@ -690,11 +699,11 @@ describe('an unexpected provider exit', () => { const history = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok && history.page.submissions[0]?.dispatchState).toBe('unknown') // A send whose delivery outcome is unknown IS work in progress, so the reassuring outcome is - // written — carrying the cause, and never the old bare `Provider exited: ` row. + // written — its failure fact beside it, and never the old bare `Provider exited: ` row. const statuses = history.ok ? history.page.items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) : [] - expect(statuses).toEqual([unexpectedProviderExitOutcome('provider exited')]) + expect(statuses).toEqual([UNEXPECTED_PROVIDER_EXIT_OUTCOME]) expect(statuses.some((text) => text.startsWith('Provider exited'))).toBe(false) dispatch.mockResolvedValueOnce({ @@ -776,15 +785,19 @@ describe('an unexpected provider exit', () => { ) expect(acquire).toHaveBeenCalledTimes(2) // The new child's acquire settled the turn from the release's evidence: ended at the exit's - // receipt, with the exit's own reason in the row. + // receipt. The evidence is Orca's log text, so the row says only that the provider stopped. const history = await host.history({ sessionId: SESSION, direction: 'tail' }) const items = history.ok ? history.page.items : [] expect(items.map((item) => readAgentJournalTurn(item.body)).filter(Boolean)).toContainEqual( expect.objectContaining({ turnId: 'turn-1', state: 'interrupted', completedAt: NOW }) ) - expect( - items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) - ).toContain(unexpectedProviderExitOutcome('provider exited')) + const statuses = items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) + expect(statuses).toContainEqual({ + kind: 'status', + text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, + failure: { kind: 'providerExited' } + }) + expect(statuses.map((status) => status.text).join('\n')).not.toContain('provider exited') }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index 3d4e8491986..f490eb2e9cd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -125,7 +125,11 @@ describe('performThreadGoalChange', () => { expect(result).toEqual({ ok: false, - refusal: { code: 'agent_session_operation_invalid', message: 'goals feature is disabled' } + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'providerRejected' }, + message: 'goals feature is disabled' + } }) expect(journal.snapshot().items).toEqual([]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index 3f63679d55f..639daf6ab30 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -1,6 +1,7 @@ // `agentSession.threadGoal`: change the provider thread's goal through the same // admission, ledger and journal path every other session mutation takes. +import { refuse, type AgentSessionRefusalReason } from '../../../shared/agent-session-wire-refusals' import type { AgentJournalItemIdentity, AgentJournalThreadGoal @@ -13,8 +14,11 @@ import type { import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' -function refused(message: string): TurnOutcome { - return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +function refused( + reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, + message: string +): TurnOutcome { + return { ok: false, refusal: refuse('agent_session_operation_invalid', { reason }, message) } } /** Keyed by the operation, so a replayed set upserts its one objective row. */ @@ -44,7 +48,7 @@ export async function performThreadGoalChange( input: { clientOperationId: string; change: AgentSessionThreadGoalChange } ): Promise> { if (!ctx.adapter.changeThreadGoal || !ctx.adapter.supportsThreadGoal?.(ctx.sessionId)) { - return refused('Goals are unavailable for this chat session.') + return refused('goalsUnsupported', 'Goals are unavailable for this chat session.') } const { change } = input const identity = objectiveIdentity(input.clientOperationId) @@ -89,7 +93,7 @@ export async function performThreadGoalChange( } if (!result.ok) { await withdrawObjective() - return refused(result.rejected) + return refused('providerRejected', result.rejected) } return { ok: true, value: { change: change.kind } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts index cb1685405a1..9230e56cbe8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts @@ -1,4 +1,4 @@ -import type { AgentSessionOptionResult } from '../../../shared/agent-session-wire' +import { refuse, type AgentSessionOptionResult } from '../../../shared/agent-session-wire' import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -17,7 +17,11 @@ export async function performSetOption( if (isAgentSessionOptionRejectedError(error)) { return { ok: false, - refusal: { code: 'agent_session_operation_invalid', message: error.message } + refusal: refuse( + 'agent_session_operation_invalid', + { reason: error.refusalReason }, + error.message + ) } } throw error diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts index e235ccb4940..f10b64be1db 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts @@ -1,3 +1,5 @@ +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { agentSessionPromptQuestions, @@ -12,7 +14,11 @@ import type { AgentJournalQuestionItem, AgentJournalResolution } from '../../../shared/agent-session-journal-types' -import type { AgentSessionPromptResult } from '../../../shared/agent-session-wire' +import { + refuse, + type AgentSessionPromptResult, + type AgentSessionRefusalReason +} from '../../../shared/agent-session-wire' import { AgentSessionPromptAnswerRejectedError, AgentSessionPromptUnavailableError @@ -29,8 +35,11 @@ export type AgentSessionPromptRequest = { answers?: AgentSessionQuestionAnswer[] } -function invalid(message: string): TurnOutcome { - return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +function invalid( + reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, + message: string +): TurnOutcome { + return { ok: false, refusal: refuse('agent_session_operation_invalid', { reason }, message) } } /** The one place a client's choice is read; an answer an older client packed into `optionId` is unpacked here, once. */ @@ -73,6 +82,7 @@ export async function performPrompt( const choice = readPromptChoice(prompt, input) if (!choice) { return invalid( + 'optionRejected', input.optionId !== undefined ? `Option ${input.optionId} is not offered by item ${input.itemId}.` : `The answers do not match the questions on item ${input.itemId}.` @@ -81,7 +91,7 @@ export async function performPrompt( const { response } = choice const identity = parseAgentJournalItemKey(input.itemId) if (!identity) { - return invalid(`Item id ${input.itemId} is not a well-formed item key.`) + return invalid('requestMalformed', `Item id ${input.itemId} is not a well-formed item key.`) } const resolution: AgentJournalResolution = { @@ -110,23 +120,23 @@ export async function performPrompt( } }) } catch (error) { - if ( - !committed.item && - (error instanceof AgentSessionPromptUnavailableError || - error instanceof AgentSessionPromptAnswerRejectedError) - ) { - return invalid(error.message) + if (!committed.item && error instanceof AgentSessionPromptUnavailableError) { + return invalid('promptGone', error.message) + } + if (!committed.item && error instanceof AgentSessionPromptAnswerRejectedError) { + return invalid('optionRejected', error.message) } if (!committed.item) { throw error } + // The adapter's error is Orca's; the row says only what the user needs to know. await ctx.journal.appendItem( { provider: 'orca', clientMessageId: `${input.itemId}#delivery` }, { kind: 'status', - text: `Your answer was recorded but the agent did not confirm it: ${ - error instanceof Error ? error.message : String(error) - }` + ...agentSessionFailureWords(agentSessionFailureFact('answerUnconfirmed'), { + surface: 'row' + }) }, { fence: ctx.fence } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 5eac140868f..49642b9a897 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -6,14 +6,22 @@ // row the next attach settles as `unknown`, whereas the reverse would lose a // turn the provider already accepted. +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' import type { AgentJournalMessageItem, + AgentJournalStatusItem, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { - AgentSessionCancelResult, - AgentSessionSendResult, - AgentSessionWireRefusal +import { + refuse, + type AgentSessionCancelResult, + type AgentSessionRefusalReason, + type AgentSessionSendResult, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -23,7 +31,7 @@ import type { StructuredAgentSessionAdapter, StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' -import { providerStartupFailureRejection } from './structured-agent-session-dead-generation-settlement' +import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { validatePendingPrompt } from './structured-agent-session-prompt-state' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' export { performSetOption } from './structured-agent-session-turns-options' @@ -52,8 +60,11 @@ export type TurnOutcome = | { ok: true; value: TValue } | { ok: false; refusal: AgentSessionWireRefusal } -function invalid(message: string): { ok: false; refusal: AgentSessionWireRefusal } { - return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +function invalid( + reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, + message: string +): { ok: false; refusal: AgentSessionWireRefusal } { + return { ok: false, refusal: refuse('agent_session_operation_invalid', { reason }, message) } } /** A thrown adapter error is indistinguishable from a lost reply, so it settles as `unknown` @@ -76,7 +87,10 @@ async function dispatchSafely( }) } catch (error) { if (ctx.providerChildPhase?.() === 'starting') { - return { state: 'rejected', reason: providerStartupFailureRejection(error) } + return { + state: 'rejected', + ...structuredAgentSessionStartFailure({ error }, ctx.failureTextContext) + } } return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) } } @@ -85,13 +99,9 @@ async function dispatchSafely( async function appendStatus( ctx: AgentSessionTurnContext, clientMessageId: string, - text: string + body: AgentJournalStatusItem ): Promise { - await ctx.journal.appendItem( - { provider: 'orca', clientMessageId }, - { kind: 'status', text }, - { fence: ctx.fence } - ) + await ctx.journal.appendItem({ provider: 'orca', clientMessageId }, body, { fence: ctx.fence }) } /** @@ -116,7 +126,10 @@ export async function performSend( .submissions() .find((entry) => entry.clientMessageId === input.clientMessageId) if (existing && existing.payloadFingerprint !== input.payloadFingerprint) { - return invalid(`Message id ${input.clientMessageId} was already used for another send.`) + return invalid( + 'messageIdReused', + `Message id ${input.clientMessageId} was already used for another send.` + ) } if (existing) { return { @@ -127,7 +140,7 @@ export async function performSend( try { await ctx.journal.appendSubmission({ ...input, fence: ctx.fence, handoverRecorded: true }) } catch { - return invalid('The message could not be recorded and was not sent.') + return invalid('journalWriteFailed', 'The message could not be recorded and was not sent.') } return { ok: true, @@ -141,7 +154,10 @@ export async function performSend( export type AgentSessionHandoverContext = Pick< AgentSessionTurnContext, 'sessionId' | 'journal' | 'fence' | 'adapter' | 'providerChildPhase' -> +> & { + /** Who a start failure met at dispatch names, as the start's own row does. */ + failureTextContext?: AgentSessionFailureWordsContext +} /** * Hands one queued submission to the provider. The `dispatch{pending}` row goes first: a crash @@ -157,7 +173,7 @@ export async function handOverSubmission( await ctx.journal.resolveDispatch({ clientMessageId, state: 'rejected', - reason: 'The message could not be read back and was not sent.', + ...agentSessionFailureWords(agentSessionFailureFact('hostFault'), { surface: 'rejection' }), fence: ctx.fence }) return @@ -179,7 +195,15 @@ export async function handOverSubmission( providerIdentity: outcome.providerIdentity, fence: ctx.fence } - : { clientMessageId, state: outcome.state, reason: outcome.reason, fence: ctx.fence } + : outcome.state === 'rejected' + ? { + clientMessageId, + state: 'rejected', + reason: outcome.reason, + rejection: outcome.rejection, + fence: ctx.fence + } + : { clientMessageId, state: 'unknown', reason: outcome.reason, fence: ctx.fence } ) } catch (error) { // A failed resolution must not strand a pending row; an unknown result is @@ -228,7 +252,7 @@ export async function performCancel( } } let cancelled = false - let note = 'Cancellation requested.' + let note: AgentJournalStatusItem = { kind: 'status', text: 'Cancellation requested.' } try { const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) cancelled = input.scope @@ -251,15 +275,17 @@ export async function performCancel( }) ).cancelled if (!cancelled) { - note = 'The provider had already finished this turn.' + note = { kind: 'status', text: 'The provider had already finished this turn.' } } } catch (error) { if (input.prompt) { throw error } - note = `Cancellation was not confirmed: ${ - error instanceof Error ? error.message : String(error) - }` + // The adapter's error is Orca's; the row says only that the stop is unconfirmed. + note = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) + } } if (cancelled && input.prompt) { await ctx.flushStreamedEvents() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts index 952fa70657d..9a27415dc71 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { projectStructuredAgentSessionStatus } from '../../../shared/structured-agent-session-projection' -import { releaseStructuredAgentSessionUnansweredDispatches } from './structured-agent-session-host-mutations' +import { releaseStructuredAgentSessionUnansweredDispatches } from './structured-agent-session-unanswered-dispatch-release' const FENCE = 7 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts new file mode 100644 index 00000000000..713838c177e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts @@ -0,0 +1,43 @@ +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' + +/** + * Releases sends the provider can no longer be holding. + * + * A dispatch whose RPC timed out is recorded `unknown` — doubt, never proof of + * non-delivery — and a live `unknown` reads as work still owed, so the session + * shows working until something re-derives it. The provider reporting its thread + * not running, with no turn open, IS that re-derivation. + * + * `pending` is deliberately untouched: that send's dispatch has not returned yet + * and may be in flight right now. And `recovered` only retires the obligation — + * it never makes a send re-deliverable, because the provider may well have run it. + */ +export async function releaseStructuredAgentSessionUnansweredDispatches( + context: Pick & { + deps: { store: Pick } + }, + input: { sessionId: string; reason: string } +): Promise { + const session = context.sessions.get(input.sessionId) + if (!session) { + return + } + const stranded = session.journal + .submissions() + .filter((entry) => entry.dispatchState === 'unknown' && entry.recovered !== true) + if (stranded.length === 0) { + return + } + for (const entry of stranded) { + await session.journal.resolveDispatch({ + clientMessageId: entry.clientMessageId, + state: 'unknown', + // The earlier reason names a sharper fact than this one does. + reason: entry.reason ?? input.reason, + fence: structuredAgentSessionConversationFence(context.deps.store, input.sessionId), + recovered: true + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 6fa8e91a97e..0685af60549 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -7,16 +7,16 @@ import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import { - settleStaleStructuredAgentSessionState, - unexpectedProviderExitOutcome -} from './structured-agent-session-dead-generation-settlement' +import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { settleUnexpectedStructuredAgentSessionExit, type StructuredAgentSessionUnexpectedExitContext, type StructuredAgentSessionUnexpectedExitSession } from './structured-agent-session-unexpected-exit' +const exitOutcome = (agent: string): string => + `${agent} stopped while this response was in progress. You can continue in this conversation.` + const SESSION = 'session-1' const GENERATION = 'generation-1' @@ -219,7 +219,11 @@ describe('provider-exit settlement', () => { provider: 'orca', clientMessageId: `provider-exit:${SESSION}:7:${GENERATION}` }, - body: { kind: 'status', text: unexpectedProviderExitOutcome('provider exited') } + body: { + kind: 'status', + text: exitOutcome('The agent'), + failure: { kind: 'providerExited' } + } }, { kind: 'item', @@ -307,7 +311,8 @@ describe('provider-exit settlement', () => { expect.objectContaining({ body: { kind: 'status', - text: unexpectedProviderExitOutcome('provider exited after completing the turn') + text: exitOutcome('Claude'), + failure: { kind: 'providerExited' } } }) ]) @@ -355,7 +360,11 @@ describe('provider-exit settlement', () => { expect.objectContaining({ mutations: [ expect.objectContaining({ - body: { kind: 'status', text: unexpectedProviderExitOutcome('provider exited') } + body: { + kind: 'status', + text: exitOutcome('Claude'), + failure: { kind: 'providerExited' } + } }) ] }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 834bc219e01..3a47d1240f2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -1,3 +1,5 @@ +import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -71,6 +73,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< fence: child.fence, cause: 'exit', reason: unexpectedEvent.reason, + ...(unexpectedEvent.failure ? { failure: unexpectedEvent.failure } : {}), duringStartup: exitedDuringStartup, // The adapter publishes an exit only once it saw the root go, first-hand or proven. rootGone: true @@ -103,6 +106,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< stableSettlementId, verdict: { state: 'interrupted', completedAt: observedAt }, exitedDuringStartup, + failureTextContext: structuredAgentSessionFailureWordsContext(record), // A failed start always says why: no response was running to carry the reason. showUnexpectedExitOutcome: exitedDuringStartup || @@ -151,6 +155,7 @@ async function retryUnexpectedExitSettlement(input: { stableSettlementId: string verdict: StructuredAgentSessionTurnVerdict exitedDuringStartup: boolean + failureTextContext: AgentSessionFailureWordsContext showUnexpectedExitOutcome?: boolean }): Promise { return settleStructuredAgentSessionDeadGeneration({ @@ -161,7 +166,8 @@ async function retryUnexpectedExitSettlement(input: { verdict: input.verdict, pendingSubmissionReason: 'provider_exited_before_acknowledgement', showUnexpectedExitOutcome: input.showUnexpectedExitOutcome, - unexpectedExitReason: input.event.reason, + ...(input.event.failure ? { exitFailure: input.event.failure } : {}), + failureTextContext: input.failureTextContext, ...(input.exitedDuringStartup ? { exitedDuringStartup: { generation: input.event.acquisitionGeneration } } : {}), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts index 04883637359..957ac2f275d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts @@ -183,7 +183,9 @@ describe('a fresh chat whose Claude start fails', () => { releaseCreate() } await settleExits() - const startFailure = `The provider stopped before it finished starting: ${LAUNCH_FAILURE}.` + // The row says the provider stopped; its stderr stays out of the sentence. + const startFailure = + 'Claude stopped before it finished starting. Send your message to try again.' // Opening the chat: the create's start, once, and its row. expect(claude.connections).toHaveLength(1) expect(await timeline()).toEqual([startFailure]) diff --git a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.test.ts b/src/main/native-chat/agent-session-wire/structured-compaction-recovery.test.ts new file mode 100644 index 00000000000..4c565bcf4e2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-compaction-recovery.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { settleInterruptedCompaction } from './structured-compaction-recovery' + +describe('settleInterruptedCompaction', () => { + it("records the unconfirmed compaction in its fact's own words, on the row and the command", async () => { + const command = { + command: 'compact' as const, + state: 'unknown' as const, + phase: 'prepared' as const, + operationId: 'op-1', + callerKey: 'client-1', + runtimeFence: 1 + } + const setConversationCommand = vi.fn(async () => undefined) + const store = { + getRecord: () => ({ conversationCommand: command }), + setConversationCommand, + recordOperationOutcome: vi.fn(async () => undefined) + } + const appendItem = vi.fn(async () => ({ cursor: { epoch: 'e', sequence: 1 } })) + + await settleInterruptedCompaction( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the function reads only the members stubbed above. + store as unknown as AgentSessionRecordStore, + 'session-1', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the function calls only `appendItem`. + { appendItem } as unknown as AgentSessionJournal, + 2 + ) + + const words = { + text: 'Compaction completion is unconfirmed.', + failure: { kind: 'compactionUnconfirmed' } + } + expect(appendItem).toHaveBeenCalledWith( + { provider: 'orca', clientMessageId: 'compact:op-1' }, + { kind: 'status', ...words }, + { fence: 2 } + ) + expect(setConversationCommand).toHaveBeenCalledWith( + 'session-1', + 2, + expect.objectContaining({ error: words.text, failure: words.failure, state: 'unknown' }) + ) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts b/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts index 64f1ee652e7..b581d746f62 100644 --- a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts @@ -1,3 +1,5 @@ +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -26,13 +28,21 @@ export async function settleInterruptedCompaction( if (command?.command !== 'compact' || command.phase !== 'prepared') { return } - const error = 'Previous compaction completion could not be confirmed after session recovery.' + const words = agentSessionFailureWords(agentSessionFailureFact('compactionUnconfirmed'), { + surface: 'row' + }) await journal.appendItem( { provider: 'orca', clientMessageId: `compact:${command.operationId}` }, - { kind: 'status', text: error }, + { kind: 'status', ...words }, { fence } ) - const recovered = { ...command, phase: 'committed' as const, state: 'unknown' as const, error } + const recovered = { + ...command, + phase: 'committed' as const, + state: 'unknown' as const, + error: words.text, + failure: words.failure + } await store.setConversationCommand(sessionId, fence, recovered) await store.recordOperationOutcome({ callerKey: command.callerKey, diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts index 5a3370c910f..fe3e443c495 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts @@ -29,12 +29,12 @@ describe('conversationCommandBlocked background tasks', () => { contextWith({ state: 'monitoring', supportsTaskStop: true }), RECORD ) - expect(blocked).toBe('Stop background tasks before using this command.') + expect(blocked?.message).toBe('Stop background tasks before using this command.') }) it('asks for a stop on a host that predates the stop-capability field', () => { const blocked = conversationCommandBlocked(contextWith({ state: 'monitoring' }), RECORD) - expect(blocked).toBe('Stop background tasks before using this command.') + expect(blocked?.message).toBe('Stop background tasks before using this command.') }) it('asks the user to wait when the provider exposes no stop at all', () => { @@ -43,7 +43,7 @@ describe('conversationCommandBlocked background tasks', () => { contextWith({ state: 'monitoring', supportsStopAll: false }), RECORD ) - expect(blocked).toBe('Wait for background tasks to finish before using this command.') + expect(blocked?.message).toBe('Wait for background tasks to finish before using this command.') }) it('still refuses on the open turn, not on the work the strip now shows', () => { @@ -63,7 +63,7 @@ describe('conversationCommandBlocked background tasks', () => { } ] }) as unknown as ReturnType - expect(conversationCommandBlocked(ctx, RECORD)).toBe( + expect(conversationCommandBlocked(ctx, RECORD)?.message).toBe( 'Wait for the current turn to finish before using this command.' ) }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts index fe6e5501975..cecdbbf0b13 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts @@ -1,33 +1,51 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import { + refuse, + type AgentSessionRefusalReason, + type AgentSessionWireRefusal +} from '../../../shared/agent-session-wire-refusals' + +function blocked( + reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, + message: string +): AgentSessionWireRefusal { + return refuse('agent_session_operation_invalid', { reason }, message) +} export function conversationCommandBlocked( ctx: AgentSessionTurnContext, record: AgentSessionRecord -): string | null { +): AgentSessionWireRefusal | null { const items = ctx.journal.snapshot().items if (record.rewind?.phase === 'prepared' || record.rewind?.phase === 'provider-succeeded') { - return 'agent_session_rewind:outcome-unknown' + return blocked('rewindUnconfirmed', 'agent_session_rewind:outcome-unknown') } if ( record.conversationCommand?.command === 'clear' && record.conversationCommand.phase === 'committed' && record.conversationCommand.replacementSessionId ) { - return 'This conversation has been cleared. Open the current conversation to continue.' + return blocked( + 'conversationCleared', + 'This conversation has been cleared. Open the current conversation to continue.' + ) } if ( record.conversationCommand?.state === 'unknown' && record.conversationCommand.phase === 'prepared' ) { - return 'The previous conversation operation is unconfirmed.' + return blocked( + 'conversationCommandUnconfirmed', + 'The previous conversation operation is unconfirmed.' + ) } if (record.lease.handoffStage || record.lease.handoffOperationId) { - return 'Wait for the session handoff to finish.' + return blocked('handoffInFlight', 'Wait for the session handoff to finish.') } if (activeStructuredAgentSessionTurnId(items)) { - return 'Wait for the current turn to finish before using this command.' + return blocked('turnActive', 'Wait for the current turn to finish before using this command.') } if ( items.some( @@ -36,16 +54,22 @@ export function conversationCommandBlocked( item.body.resolution.state === 'pending' ) ) { - return 'Resolve the pending question or approval before using this command.' + return blocked( + 'promptPending', + 'Resolve the pending question or approval before using this command.' + ) } const backgroundTasks = ctx.adapter.backgroundTaskState?.(ctx.sessionId) if (backgroundTasks?.state === 'monitoring') { // Only ask for a stop the host can actually perform. A provider that // exposes neither a targeted nor an untargeted stop would otherwise leave // the command refused behind an instruction nobody can follow. - return backgroundTasks.supportsTaskStop || backgroundTasks.supportsStopAll !== false - ? 'Stop background tasks before using this command.' - : 'Wait for background tasks to finish before using this command.' + return blocked( + 'backgroundTasksRunning', + backgroundTasks.supportsTaskStop || backgroundTasks.supportsStopAll !== false + ? 'Stop background tasks before using this command.' + : 'Wait for background tasks to finish before using this command.' + ) } if ( ctx.journal.submissions().some( @@ -55,7 +79,10 @@ export function conversationCommandBlocked( (entry.dispatchState === 'unknown' && entry.recovered !== true && entry.fence === ctx.fence) ) ) { - return 'Resolve pending or unconfirmed messages before using this command.' + return blocked( + 'messagesUnsettled', + 'Resolve pending or unconfirmed messages before using this command.' + ) } return null } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts index e5a0283ba83..662ec70e8dd 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts @@ -1,3 +1,4 @@ +import { refuse } from '../../../shared/agent-session-wire-refusals' import { sendStructuredAgentSessionTurn } from './structured-agent-session-host-mutations' import { runStructuredConversationCommand, @@ -20,10 +21,11 @@ export class StructuredConversationCommandController { this.pending.has(params.envelope.sessionId) ? Promise.resolve({ ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: 'Wait for the conversation operation to finish.' - } + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'conversationCommandInFlight' }, + 'Wait for the conversation operation to finish.' + ) }) : sendStructuredAgentSessionTurn(this.context(), caller, params) @@ -33,10 +35,11 @@ export class StructuredConversationCommandController { if (pending && pending.key !== key) { return Promise.resolve({ ok: false as const, - refusal: { - code: 'agent_session_operation_invalid' as const, - message: 'Wait for the conversation operation to finish.' - } + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'conversationCommandInFlight' }, + 'Wait for the conversation operation to finish.' + ) }) } const entry = pending ?? { key, count: 0 } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index e6e6d70d622..445326f60b0 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -6,7 +6,11 @@ import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-ses import type { AgentSessionConversationCommand } from '../../../shared/agent-session-conversation-command' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + AgentSessionAcquisitionRefusal, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import type { StructuredSessionCompactionResult } from './structured-session-compaction' import { HOST_TEST_NOW, HOST_TEST_SESSION, @@ -43,7 +47,7 @@ function commandParams(command: AgentSessionConversationCommand) { beforeEach(async () => { resetHostTestOperationIds() acquisitions = 0 - compact.mockReset().mockResolvedValue({}) + compact.mockReset().mockResolvedValue({ outcome: 'compacted' }) directory = await mkdtemp(join(tmpdir(), 'orca-conversation-command-')) store = await AgentSessionRecordStore.open({ directory: join(directory, 'store'), @@ -127,12 +131,47 @@ describe('host conversation commands', () => { }) it('reports provider compaction failure without a stuck lifecycle', async () => { - compact.mockResolvedValue({ error: 'Not enough messages to compact.' }) + const detail = { text: 'Not enough messages to compact.', audience: 'person' as const } + compact.mockResolvedValue({ outcome: 'failed', detail }) + const failure = { kind: 'compactionFailed', detail } expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ ok: true, - value: { state: 'completed', error: 'Not enough messages to compact.' } + value: { + state: 'completed', + error: 'Compaction failed: Not enough messages to compact.', + failure + } }) expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand?.state).toBe('completed') + const rows = await host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + expect(rows.ok && rows.page.items.map((item) => item.body)).toContainEqual({ + kind: 'status', + text: 'Compaction failed: Not enough messages to compact.', + failure + }) + }) + + it('says only that compaction failed when the provider gave no words', async () => { + compact.mockResolvedValue({ outcome: 'failed' }) + expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ + ok: true, + value: { error: 'Compaction failed.', failure: { kind: 'compactionFailed' } } + }) + }) + + it('records a compaction the provider never confirmed as unconfirmed, not failed', async () => { + compact.mockResolvedValue({ outcome: 'unconfirmed' }) + const failure = { kind: 'compactionUnconfirmed' } + expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ + ok: true, + value: { error: 'Compaction completion is unconfirmed.', failure } + }) + const rows = await host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + expect(rows.ok && rows.page.items.map((item) => item.body)).toContainEqual({ + kind: 'status', + text: 'Compaction completion is unconfirmed.', + failure + }) }) it('keeps an unknown compaction from being executed again', async () => { @@ -236,7 +275,13 @@ describe('host conversation commands', () => { }) expect(await host.conversationCommand(caller, commandParams('clear'))).toMatchObject({ ok: true, - value: { state: 'completed', replacementSessionId: undefined, error: expect.any(String) } + value: { + state: 'completed', + replacementSessionId: undefined, + // The refusal's message is Orca's log text; the result words its situation. + error: "Codex couldn't start. Start a new chat to continue.", + failure: { kind: 'startFailed', refusal: { code: 'structured_agent_session_unsupported' } } + } }) expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) expect(acquisitions).toBe(1) @@ -245,6 +290,31 @@ describe('host conversation commands', () => { }) }) + it('tells the user to run /clear again when the replacement could not start', async () => { + vi.mocked(adapter.acquire).mockRejectedValueOnce(new Error('spawn codex ENOENT')) + expect(await host.conversationCommand(caller, commandParams('clear'))).toMatchObject({ + ok: true, + value: { state: 'completed', error: "Codex couldn't start. Run /clear again." } + }) + }) + + it('keeps the situation a refused replacement start named', async () => { + vi.mocked(adapter.acquire).mockRejectedValueOnce( + new AgentSessionAcquisitionRefusal('Codex is not signed in.', 'notSignedIn') + ) + expect(await host.conversationCommand(caller, commandParams('clear'))).toMatchObject({ + ok: true, + value: { + state: 'completed', + replacementSessionId: undefined, + // The next step is the command the user ran, not a message into the old conversation. + error: 'Codex is not signed in for the selected account. Sign in, then run /clear again.', + failure: { kind: 'notSignedIn' } + } + }) + expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) + }) + it('runs a command whose fence the client has not caught up to', async () => { const params = commandParams('compact') params.envelope.expectedRuntimeFence++ @@ -252,7 +322,7 @@ describe('host conversation commands', () => { expect(compact).toHaveBeenCalledTimes(1) }) it('allows cancellation while compaction is awaiting completion and refuses a second client', async () => { - let finish!: (value: {}) => void + let finish!: (value: StructuredSessionCompactionResult) => void compact.mockImplementation( () => new Promise((resolve) => { @@ -280,7 +350,7 @@ describe('host conversation commands', () => { }) expect(cancel).toMatchObject({ ok: true, value: { cancelled: true } }) expect(adapter.cancelTurn).toHaveBeenCalled() - finish({}) + finish({ outcome: 'compacted' }) await running }) @@ -306,7 +376,7 @@ describe('host conversation commands', () => { compact.mockRejectedValue(new Error('connection lost')) const params = commandParams('compact') await expect(host.conversationCommand(caller, params)).rejects.toThrow() - await compact.mock.calls[0]![0].onLateResult?.({}) + await compact.mock.calls[0]![0].onLateResult?.({ outcome: 'compacted' }) expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true, replayed: true, @@ -343,7 +413,7 @@ describe('host conversation commands', () => { replayed: true, value: { state: 'unknown' } }) - compact.mockResolvedValue({}) + compact.mockResolvedValue({ outcome: 'compacted' }) expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ ok: true, value: { state: 'completed' } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 77dc2082edf..2075c24d7bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -16,10 +16,55 @@ import { } from './structured-agent-session-attach' import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -import { openWithAgent } from './structured-agent-session-send-preparation' +import { + openWithAgent, + structuredAgentSessionFailureWordsContext +} from './structured-agent-session-send-preparation' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import type { StructuredAgentSessionHost } from './structured-agent-session-host' import { conversationCommandBlocked } from './structured-conversation-command-admission' +import { + agentSessionFailureFact, + type AgentSessionFailureFact +} from '../../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import { structuredAgentSessionStartFailureFact } from './structured-agent-session-failure-text' +import type { StructuredSessionCompactionResult } from './structured-session-compaction' + +/** A compaction that did not succeed, keeping only what the provider wrote for a person. */ +function compactionFailure( + result: StructuredSessionCompactionResult +): AgentSessionFailureFact | undefined { + switch (result.outcome) { + case 'compacted': + return undefined + case 'unconfirmed': + return agentSessionFailureFact('compactionUnconfirmed') + case 'failed': + return agentSessionFailureFact('compactionFailed', { detail: result.detail }) + } +} + +function compactionStatusBody(failure: AgentSessionFailureFact | undefined) { + return failure + ? { kind: 'status' as const, ...agentSessionFailureWords(failure, { surface: 'row' }) } + : { kind: 'status' as const, text: 'Conversation compacted.' } +} + +/** A command's `error` is the sentence its row shows. */ +function conversationCommandFailure( + failure: AgentSessionFailureFact | undefined, + context: AgentSessionFailureWordsContext = {} +) { + if (!failure) { + return {} + } + const words = agentSessionFailureWords(failure, { ...context, surface: 'row' }) + return { error: words.text, failure: words.failure } +} export type ConversationCommandParams = { envelope: AgentSessionMutationEnvelope @@ -93,10 +138,7 @@ export function runStructuredConversationCommand( ? null : conversationCommandBlocked(ctx, record) if (blocked) { - return { - ok: false, - refusal: { code: 'agent_session_operation_invalid', message: blocked } - } + return { ok: false, refusal: blocked } } const replacementSessionId = command === 'clear' @@ -116,7 +158,7 @@ export function runStructuredConversationCommand( ...(replacementSessionId ? { replacementSessionId } : {}) } await store.setConversationCommand(sessionId, ctx.fence, prepared) - let error: string | undefined + let failure: AgentSessionFailureFact | undefined if (command === 'clear' && replacementSessionId) { const attach: AgentSessionAttachParams = { envelope: { @@ -152,12 +194,19 @@ export function runStructuredConversationCommand( ) { throw new Error(acquired.refusal.message) } + // The refusal's message is Orca's log text; the result keeps its situation instead. const failed = { ...prepared, replacementSessionId: undefined, phase: 'committed' as const, state: 'completed' as const, - error: acquired.refusal.message.slice(0, 4096) + ...conversationCommandFailure( + structuredAgentSessionStartFailureFact({ + refusal: acquired.refusal, + newSession: true + }), + { ...structuredAgentSessionFailureWordsContext(record), command: 'clear' } + ) } await store.setConversationCommand(sessionId, ctx.fence, failed) return { ok: true, value: failed } @@ -180,7 +229,7 @@ export function runStructuredConversationCommand( { fence: ctx.fence } ) try { - error = ( + failure = compactionFailure( await ctx.adapter.compact({ turnId: `compact:${clientOperationId}`, sessionId, @@ -194,16 +243,15 @@ export function runStructuredConversationCommand( return } await host.flushStreamedEvents(sessionId) - await ctx.journal.appendItem( - identity, - { kind: 'status', text: result.error ?? 'Conversation compacted.' }, - { fence: ctx.fence } - ) + const late = compactionFailure(result) + await ctx.journal.appendItem(identity, compactionStatusBody(late), { + fence: ctx.fence + }) await store.setConversationCommand(sessionId, ctx.fence, { ...prepared, phase: 'committed', state: 'completed', - ...(result.error ? { error: result.error.slice(0, 4096) } : {}) + ...conversationCommandFailure(late) }) await store.recordOperationOutcome({ callerKey: caller.callerKey, @@ -216,27 +264,25 @@ export function runStructuredConversationCommand( }) }) }) - ).error + ) await host.flushStreamedEvents(sessionId) } catch (cause) { await ctx.journal.appendItem( identity, - { kind: 'status', text: 'Compaction completion is unconfirmed.' }, + compactionStatusBody(agentSessionFailureFact('compactionUnconfirmed')), { fence: ctx.fence } ) throw cause } - await ctx.journal.appendItem( - identity, - { kind: 'status', text: error ?? 'Conversation compacted.' }, - { fence: ctx.fence } - ) + await ctx.journal.appendItem(identity, compactionStatusBody(failure), { + fence: ctx.fence + }) } const completed = { ...prepared, phase: 'committed' as const, state: 'completed' as const, - ...(error ? { error: error.slice(0, 4096) } : {}) + ...conversationCommandFailure(failure) } await store.setConversationCommand(sessionId, ctx.fence, completed) return { ok: true, value: completed } diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts index 6fa4d32293b..9de1925791f 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts @@ -2,6 +2,13 @@ import { describe, expect, it } from 'vitest' import { AgentSessionRewindRecordSchema } from '../../../shared/agent-session-rewind' import { restoreRewindJournalBody } from './structured-rewind-journal-body' +/** A row this build cannot place: still a row, never its stored JSON, and not a failure — a + * hostFault's "Try again" would be false for a placeholder. */ +const UNPLACEABLE = { + kind: 'status', + text: 'Orca could not show this item after the rewind.' +} + describe('rewind recovery of newer durable records', () => { it('keeps an unknown message role and block readable without discarding the row', () => { expect( @@ -43,16 +50,13 @@ describe('rewind recovery of newer durable records', () => { input: { path: 'file' }, state: 'paused-by-provider' } - expect(restoreRewindJournalBody(body)).toEqual({ kind: 'status', text: JSON.stringify(body) }) + expect(restoreRewindJournalBody(body)).toEqual(UNPLACEABLE) const status = { kind: 'status' as const, text: 'state', turnLifecycle: { turnId: 'turn', state: 'future-state' } } - expect(restoreRewindJournalBody(status)).toEqual({ - kind: 'status', - text: JSON.stringify(status) - }) + expect(restoreRewindJournalBody(status)).toEqual(UNPLACEABLE) }) it.each(['interrupted', 'unverifiable'] as const)( 'keeps a %s turn and its recorded endpoints', @@ -82,10 +86,7 @@ describe('rewind recovery of newer durable records', () => { } expect(restoreRewindJournalBody(turn)).toEqual(turn) const unknown = { ...turn, state: 'future-state' } - expect(restoreRewindJournalBody(unknown)).toEqual({ - kind: 'status', - text: JSON.stringify(unknown) - }) + expect(restoreRewindJournalBody(unknown)).toEqual(UNPLACEABLE) }) it('keeps a known turn outcome across rewind recovery in both journal shapes', () => { const turn = { diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts index addc234e2a1..99489b18769 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts @@ -9,10 +9,15 @@ import { NATIVE_CHAT_ROLES } from '../../../shared/native-chat-types' type StoredBody = AgentSessionRewindRecord['retained'][number]['body'] -/** Unknown future values remain visible evidence, never invented turn or prompt state. */ +/** A row this build cannot place stays visible as a row, never invented turn or prompt state — + * and never as its stored JSON, which is Orca's record, not something a person reads. */ export function restoreRewindJournalBody(body: StoredBody): AgentJournalItemBody { let normalized: unknown = body - const fallback = () => ({ kind: 'status', text: JSON.stringify(body) }) + // A placeholder, not a failure anyone can act on, so it carries no fact. + const fallback = () => ({ + kind: 'status', + text: 'Orca could not show this item after the rewind.' + }) if (body.kind === 'message') { normalized = { ...body, diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts b/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts index 5ea205a527c..af309b13f40 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts @@ -2,23 +2,28 @@ import { AGENT_SESSION_REWIND_REASONS, type AgentSessionRewindReason } from '../../../shared/agent-session-rewind' -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { refuse, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' export function rewindRefusal(reason: AgentSessionRewindReason): { ok: false refusal: AgentSessionWireRefusal } { - const knownReason = + const rewindReason = AGENT_SESSION_REWIND_REASONS.find((value) => value === reason) ?? 'outcome-unknown' + const message = `agent_session_rewind:${rewindReason}` return { ok: false, - refusal: { - code: - knownReason === 'outcome-unknown' - ? 'agent_session_operation_unknown' - : 'agent_session_operation_invalid', - message: `agent_session_rewind:${knownReason}`, - rewindReason: knownReason - } + refusal: + rewindReason === 'outcome-unknown' + ? refuse( + 'agent_session_operation_unknown', + { reason: 'rewindUnconfirmed', rewindReason }, + message + ) + : refuse( + 'agent_session_operation_invalid', + { reason: 'rewindRefused', rewindReason }, + message + ) } } diff --git a/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts index 13a22b655b3..8936a4a28d0 100644 --- a/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts @@ -6,7 +6,7 @@ describe('structured compaction lifecycle', () => { const tracker = new StructuredSessionCompaction() const finished = vi.fn() const result = tracker - .run('session', 'thread', async () => ({})) + .run('session', 'thread', async () => undefined) .then((value) => { finished() return value @@ -28,7 +28,7 @@ describe('structured compaction lifecycle', () => { threadId: 'thread', turn: { id: 'compact-turn', status: 'completed' } }) - await expect(result).resolves.toEqual({}) + await expect(result).resolves.toEqual({ outcome: 'compacted' }) }) it('observes notifications arriving before the request acknowledgment', async () => { @@ -41,7 +41,10 @@ describe('structured compaction lifecycle', () => { turn: { id: 'c', status: 'failed', error: { message: 'Unavailable' } } }) }) - ).resolves.toEqual({ error: 'Unavailable' }) + ).resolves.toEqual({ + outcome: 'failed', + detail: { text: 'Unavailable', audience: 'person' } + }) }) it.each(['success', 'failed'])( @@ -63,7 +66,12 @@ describe('structured compaction lifecycle', () => { result: '' }) await expect(result).resolves.toEqual( - state === 'success' ? {} : { error: 'Not enough messages to compact.' } + state === 'success' + ? { outcome: 'compacted' } + : { + outcome: 'failed', + detail: { text: 'Not enough messages to compact.', audience: 'person' } + } ) } ) @@ -72,26 +80,26 @@ describe('structured compaction lifecycle', () => { const tracker = new StructuredSessionCompaction() const pending = tracker.run('s', 'p', async () => {}) tracker.ended('s') - await expect(pending).resolves.toEqual({ error: 'The provider exited during compaction.' }) + await expect(pending).resolves.toEqual({ outcome: 'unconfirmed' }) const next = tracker.run('s', 'p', async () => { tracker.claude('s', { type: 'system', subtype: 'compact_boundary', session_id: 'p' }) tracker.claude('s', { type: 'result', subtype: 'success', session_id: 'p' }) }) - await expect(next).resolves.toEqual({}) + await expect(next).resolves.toEqual({ outcome: 'compacted' }) }) it('reconciles a terminal frame after timeout without repeating the provider request', async () => { vi.useFakeTimers() try { const tracker = new StructuredSessionCompaction(10) const late = vi.fn(async () => {}) - const invoke = vi.fn(async () => ({})) + const invoke = vi.fn(async () => undefined) const result = tracker.run('s', 'p', invoke, late) const rejected = expect(result).rejects.toThrow('unconfirmed') await vi.advanceTimersByTimeAsync(11) await rejected tracker.claude('s', { type: 'system', subtype: 'compact_boundary', session_id: 'p' }) tracker.claude('s', { type: 'result', subtype: 'success', session_id: 'p' }) - expect(late).toHaveBeenCalledWith({}) + expect(late).toHaveBeenCalledWith({ outcome: 'compacted' }) expect(invoke).toHaveBeenCalledTimes(1) } finally { vi.useRealTimers() @@ -100,9 +108,34 @@ describe('structured compaction lifecycle', () => { it('does not mistake an unrelated completed turn for compaction', async () => { const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 't', async () => ({})) + const result = tracker.run('s', 't', async () => undefined) tracker.codex('s', 'turn/started', { threadId: 't', turn: { id: 'c' } }) tracker.codex('s', 'turn/completed', { threadId: 't', turn: { id: 'c', status: 'completed' } }) - await expect(result).resolves.toEqual({ error: 'Compaction did not complete.' }) + await expect(result).resolves.toEqual({ outcome: 'unconfirmed' }) + }) + + it('fails, not merely unconfirmed, a Codex compaction turn that did not complete', async () => { + const tracker = new StructuredSessionCompaction() + const result = tracker.run('s', 't', async () => undefined) + tracker.codex('s', 'turn/started', { threadId: 't', turn: { id: 'c' } }) + tracker.codex('s', 'turn/completed', { + threadId: 't', + turn: { id: 'c', status: 'interrupted' } + }) + await expect(result).resolves.toEqual({ outcome: 'failed' }) + }) + + it('leaves a Claude turn that ended without confirming compaction unconfirmed', async () => { + const tracker = new StructuredSessionCompaction() + const result = tracker.run('s', 'p', async () => {}) + tracker.claude('s', { type: 'result', subtype: 'success', session_id: 'p' }) + await expect(result).resolves.toEqual({ outcome: 'unconfirmed' }) + }) + + it('fails a Claude compaction whose turn ended in error', async () => { + const tracker = new StructuredSessionCompaction() + const result = tracker.run('s', 'p', async () => {}) + tracker.claude('s', { type: 'result', subtype: 'error_during_execution', session_id: 'p' }) + await expect(result).resolves.toEqual({ outcome: 'failed' }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts b/src/main/native-chat/agent-session-wire/structured-session-compaction.ts index 328c283dc11..5f5c88d0b07 100644 --- a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts +++ b/src/main/native-chat/agent-session-wire/structured-session-compaction.ts @@ -1,10 +1,32 @@ +import { providerDiagnostic, type ProviderDiagnostic } from '../../../shared/agent-session-failure' + +/** How a compaction ended. `unconfirmed`: the provider never said whether it compacted, so it may + * have. `detail` is the provider's own words for a failure, when it gave any. */ +export type StructuredSessionCompactionResult = + | { outcome: 'compacted' } + | { outcome: 'failed'; detail?: ProviderDiagnostic } + | { outcome: 'unconfirmed' } + +/** A compaction that never began: refused before it was sent, or by the provider at the request. */ +export type StructuredSessionCompactionRefusal = Extract< + StructuredSessionCompactionResult, + { outcome: 'failed' } +> + +function compactionFailed( + detail: ProviderDiagnostic | undefined +): StructuredSessionCompactionRefusal { + return { outcome: 'failed', ...(detail ? { detail } : {}) } +} + type PendingCompaction = { identity: string commandTurnId?: string turnId?: string - error?: string + /** The provider reported the compaction failed, with its words when it gave any. */ + failed?: { detail?: ProviderDiagnostic } compacted: boolean - finish: (result: { error?: string }) => void + finish: (result: StructuredSessionCompactionResult) => void } function record(value: unknown): Record { @@ -26,17 +48,17 @@ export class StructuredSessionCompaction { async run( sessionId: string, identity: string, - invoke: () => Promise, - onLateResult?: (result: { error?: string }) => Promise, + invoke: () => Promise, + onLateResult?: (result: StructuredSessionCompactionResult) => Promise, commandTurnId?: string - ): Promise<{ error?: string }> { + ): Promise { if (this.pending.has(sessionId)) { throw new Error('Compaction is already running.') } let timer: ReturnType let expired = false - const completion = new Promise<{ error?: string }>((resolve, reject) => { - const finish = (result: { error?: string }) => { + const completion = new Promise((resolve, reject) => { + const finish = (result: StructuredSessionCompactionResult) => { this.pending.delete(sessionId) if (expired && onLateResult) { void onLateResult(result).catch((error) => @@ -60,9 +82,9 @@ export class StructuredSessionCompaction { // Observe rejection even while invoke is waiting for its own receipt. void completion.catch(() => {}) try { - const admission = record(await invoke()) - if (typeof admission.error === 'string') { - this.pending.get(sessionId)?.finish({ error: admission.error }) + const refusal = await invoke() + if (refusal) { + this.pending.get(sessionId)?.finish(compactionFailed(refusal.detail)) } return await completion } catch (error) { @@ -89,7 +111,8 @@ export class StructuredSessionCompaction { } ended(sessionId: string): void { - this.pending.get(sessionId)?.finish({ error: 'The provider exited during compaction.' }) + // It may have compacted before it exited. + this.pending.get(sessionId)?.finish({ outcome: 'unconfirmed' }) } codex(sessionId: string, method: string, value: unknown): void { @@ -108,9 +131,11 @@ export class StructuredSessionCompaction { if (method === 'turn/completed' && turn.id === pending.turnId) { const error = record(turn.error).message pending.finish( - turn.status === 'completed' && pending.compacted - ? {} - : { error: typeof error === 'string' ? error : 'Compaction did not complete.' } + turn.status !== 'completed' + ? compactionFailed( + typeof error === 'string' ? providerDiagnostic(error, 'person') : undefined + ) + : { outcome: pending.compacted ? 'compacted' : 'unconfirmed' } ) } } @@ -121,8 +146,12 @@ export class StructuredSessionCompaction { return } if (message.compact_result === 'failed') { - pending.error = - typeof message.compact_error === 'string' ? message.compact_error : 'Compaction failed.' + pending.failed = { + detail: + typeof message.compact_error === 'string' + ? providerDiagnostic(message.compact_error, 'person') + : undefined + } } if (message.compact_result === 'success' || message.subtype === 'compact_boundary') { pending.compacted = true @@ -132,12 +161,13 @@ export class StructuredSessionCompaction { message.is_error === true || (typeof message.subtype === 'string' && message.subtype.startsWith('error')) ) { - pending.error ??= 'Compaction did not complete.' + pending.failed ??= {} } - const error = - pending.error ?? - (pending.compacted ? undefined : 'Compaction was not confirmed by the provider.') - pending.finish(error ? { error } : {}) + pending.finish( + pending.failed + ? compactionFailed(pending.failed.detail) + : { outcome: pending.compacted ? 'compacted' : 'unconfirmed' } + ) } } } diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts index cbb0857c859..915bbad5e54 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts @@ -1,4 +1,4 @@ -import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' +import type { AgentJournalPlainStatusItem } from '../../../shared/agent-session-journal-types' import { boundInlineText, boundPayload, @@ -12,7 +12,7 @@ import { } from './provider-frame-disposition' export type UnhandledProviderFrameJournalItem = { - body: AgentJournalStatusItem + body: AgentJournalPlainStatusItem /** Why the frame surfaced. Error frames are exempt from generic-row caps. */ classification: 'timeline-substantive' | 'error-surface' } diff --git a/src/main/native-chat/claude-structured-managed-account-support.ts b/src/main/native-chat/claude-structured-managed-account-support.ts index dccf6216bda..3b42c44c443 100644 --- a/src/main/native-chat/claude-structured-managed-account-support.ts +++ b/src/main/native-chat/claude-structured-managed-account-support.ts @@ -43,12 +43,19 @@ export function structuredClaudeMatchesActiveManagedAccount( // apart after the fact: honest deselection, where ambient auth is the truth and the UI names no // identity, and the WSL-only case, where the prune emptied the host slot and persisted null // while the UI still names the WSL account. The presence of any WSL-bound account decides. - return !accounts.some((candidate) => candidate.managedAuthRuntime === 'wsl') + return !hasWslBoundClaudeAccount(settings) } const active = accounts.find((candidate) => candidate.id === activeHostId) return active ? active.managedAuthRuntime !== 'wsl' : false } +/** Whether any managed Claude account lives in WSL, the account shape the gate refuses over. */ +export function hasWslBoundClaudeAccount(settings: ClaudeManagedAccountGateSettings): boolean { + return (settings.claudeManagedAccounts ?? []).some( + (candidate) => candidate.managedAuthRuntime === 'wsl' + ) +} + /** Reads the gate's settings, answering null when they cannot be read so callers refuse. */ export function readClaudeManagedAccountGateSettings( getSettings: () => ClaudeManagedAccountGateSettings diff --git a/src/main/native-chat/structured-agent-session-history-adoption.ts b/src/main/native-chat/structured-agent-session-history-adoption.ts index d470735b032..e64e8f799d8 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -5,6 +5,7 @@ // journal, and a call site written there would compile however wrong it was. The runtime hands over // the facts it owns — the account homes it recognises, the records it holds — and this decides. +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import type { AgentSessionProviderHandle } from '../../shared/agent-session-journal-types' import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' @@ -106,10 +107,11 @@ export function findConflictingStructuredAdoption(input: { export function structuredAdoptionConflictError( ownership: StructuredAgentSessionAdoptionOwnership ): Error { - return new Error( + return agentSessionRefusalError( agentSessionLeaseAdmitsWriter(ownership.lease) ? 'agent_session_conflict' - : 'agent_session_ownership_unknown' + : 'agent_session_ownership_unknown', + { reason: 'conversationHeldElsewhere' } ) } @@ -152,5 +154,7 @@ export async function resolveStructuredAgentSessionAdoption(input: { } // Refuse rather than fall back to the default home. Resuming under a home that does not hold the // conversation is how a "resume" silently becomes a blank chat wearing the old chat's name. - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptNotFound' + }) } diff --git a/src/main/runtime/agent-session-conversation-command-record.ts b/src/main/runtime/agent-session-conversation-command-record.ts index f09e5d50fc5..297b9dd34e7 100644 --- a/src/main/runtime/agent-session-conversation-command-record.ts +++ b/src/main/runtime/agent-session-conversation-command-record.ts @@ -1,3 +1,4 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionStoreState } from './agent-session-record-store-file' import { AgentSessionTabTable } from './agent-session-tab-table' import type { AgentSessionConversationCommandRecord } from '../../shared/agent-session-conversation-command' @@ -10,7 +11,7 @@ export function commitConversationCommandRecord( ): void { const record = state.records.get(sessionId) if (!record || record.lease.runtimeFence !== fence) { - throw new Error('agent_session_checkpoint_stale') + throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'leaseMoved' }) } state.records.set(sessionId, { ...record, conversationCommand: command }) if ( @@ -19,7 +20,7 @@ export function commitConversationCommandRecord( command.replacementSessionId ) { if (!state.records.has(command.replacementSessionId)) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } state.sessionTabs ??= new AgentSessionTabTable() state.sessionTabs.move(sessionId, command.replacementSessionId) diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 12c28345479..41e8d300e1e 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -6,6 +6,7 @@ * observed process identity, and a proved provider handle — in that order, at one fence. */ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { adjudicateAgentSessionRestart, evaluateAgentSessionAcquisition, @@ -41,10 +42,10 @@ export function withLease( export function assertFence(lease: AgentSessionLease, fence: number): void { if (lease.runtimeFence !== fence) { - throw new Error('agent_session_checkpoint_stale') + throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'leaseMoved' }) } if (lease.unreconciled) { - throw new Error('execution_owner_reconciling') + throw agentSessionRefusalError('execution_owner_reconciling', { reason: 'hostReconciling' }) } } @@ -66,7 +67,7 @@ export function reserveAgentSessionOwner(args: { probe: args.probe }) if (decision.decision === 'refused') { - throw new Error(decision.code) + throw agentSessionRefusalError(decision.code, decision.details) } if (decision.decision === 'retry-reservation') { return { record, disposition: 'retry-reservation' } @@ -106,11 +107,15 @@ export function commitAgentSessionProcessIdentity( const { record } = args assertFence(record.lease, args.fence) if (record.lease.claimStatus !== 'reserved' || record.lease.ownerProcess !== null) { - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'spawnIdentityMismatch' + }) } if (record.lease.reservedSpawnToken !== args.process.spawnToken) { // Why: a child that cannot echo the reserved token is not the process Orca started. - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'spawnIdentityMismatch' + }) } return withLease(record, { ...record.lease, @@ -137,7 +142,9 @@ export function proveAgentSessionOwner(args: { record.lease.handoffStage !== 'new-owner-proving' || record.lease.ownerProcess === null ) { - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'spawnIdentityMismatch' + }) } if (args.link.handle.provider !== record.provider) { throw new Error('agent_session_provider_handle_provider_mismatch') diff --git a/src/main/runtime/agent-session-operation-admission.test.ts b/src/main/runtime/agent-session-operation-admission.test.ts index 0b8b9ff91f2..41df221ad12 100644 --- a/src/main/runtime/agent-session-operation-admission.test.ts +++ b/src/main/runtime/agent-session-operation-admission.test.ts @@ -48,6 +48,10 @@ describe('global agent-session operation admission', () => { fingerprint: 'different-send', now: NOW + 1 }).decision - ).toEqual({ decision: 'refused', code: 'agent_session_operation_conflict' }) + ).toEqual({ + decision: 'refused', + code: 'agent_session_operation_conflict', + details: { reason: 'operationIdReused' } + }) }) }) diff --git a/src/main/runtime/agent-session-provider-handle-transition.ts b/src/main/runtime/agent-session-provider-handle-transition.ts index f0408901868..57d2ecb172c 100644 --- a/src/main/runtime/agent-session-provider-handle-transition.ts +++ b/src/main/runtime/agent-session-provider-handle-transition.ts @@ -1,3 +1,4 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { agentSessionProviderHandleChainHead, appendAgentSessionProviderHandleLink, @@ -20,7 +21,7 @@ export function recordAgentSessionProviderHandle(args: { throw new Error('agent_session_provider_handle_invalid') } if (record.lease.claimStatus !== 'live' && record.lease.handoffStage !== 'new-owner-proving') { - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'leaseMoved' }) } const providerHandleChain = appendAgentSessionProviderHandleLink( record.providerHandleChain, diff --git a/src/main/runtime/agent-session-record-options.ts b/src/main/runtime/agent-session-record-options.ts index c2477f99e2e..697b63a0422 100644 --- a/src/main/runtime/agent-session-record-options.ts +++ b/src/main/runtime/agent-session-record-options.ts @@ -1,3 +1,4 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionOptionsReplacement, AgentSessionRecord @@ -11,7 +12,7 @@ export function replaceAgentSessionRecordOptions( // At rest the host is the only writer: a pick is intent the next start replays. const atRest = lease.claimStatus === 'released' && lease.ownerProcess === null if (lease.runtimeFence !== replacement.fence || (lease.claimStatus !== 'live' && !atRest)) { - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'leaseMoved' }) } return { ...record, options: { ...replacement.options }, updatedAt: replacement.now } } diff --git a/src/main/runtime/agent-session-record-store-tab-table.test.ts b/src/main/runtime/agent-session-record-store-tab-table.test.ts index e0ecfec2835..56bcb6debb6 100644 --- a/src/main/runtime/agent-session-record-store-tab-table.test.ts +++ b/src/main/runtime/agent-session-record-store-tab-table.test.ts @@ -9,6 +9,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { AgentSessionRecordStore } from './agent-session-record-store' import { agentSessionStorePath } from './agent-session-record-store-file' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' @@ -94,6 +95,34 @@ describe('chat tab table', () => { expect(store.getRecord('session-beta')).toBeNull() }) + it('refuses showing a tab with the situation typed, as every chat refusal is', async () => { + const store = await open() + await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) + await store.setSessionTabVisibility('session-alpha', true, 'tab-alpha') + await store.reserveOwner( + reserveRequest({ + sessionId: 'session-beta', + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + + // The message stays the code: readers of a thrown refusal treat it as one. + await expect( + store.setSessionTabVisibility('session-beta', true, 'tab-alpha') + ).rejects.toSatisfy( + (error) => + isAgentSessionRefusalError(error) && + error.message === 'agent_session_conflict' && + error.refusal.details?.reason === 'tabIdTaken' + ) + await expect(store.setSessionTabVisibility('session-gone', true)).rejects.toSatisfy( + (error) => + isAgentSessionRefusalError(error) && + error.message === 'agent_session_identity_required' && + error.refusal.details?.reason === 'recordMissing' + ) + }) + it('frees a reserved id once its chat is hidden', async () => { const store = await open() await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index b2bf3632301..7df509dd810 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -1,3 +1,4 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { commitConversationCommandRecord } from './agent-session-conversation-command-record' import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' /** Durable single-writer session records and their operation ledger. */ @@ -326,11 +327,9 @@ export class AgentSessionRecordStore { return this.transact(() => { const record = this.state.records.get(sessionId) if (!record) { - throw new Error( - this.isSessionUnreadable(sessionId) - ? 'execution_owner_reconciling' - : 'agent_session_identity_required' - ) + throw this.isSessionUnreadable(sessionId) + ? agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) + : agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } const next = apply(record) this.state.records.set(sessionId, next) diff --git a/src/main/runtime/agent-session-recovery-capsule-entries.ts b/src/main/runtime/agent-session-recovery-capsule-entries.ts index de8a452c87a..a5d1d899723 100644 --- a/src/main/runtime/agent-session-recovery-capsule-entries.ts +++ b/src/main/runtime/agent-session-recovery-capsule-entries.ts @@ -4,6 +4,10 @@ // — can be read on its own. Every value parsed here re-enters from a file this process did not // necessarily write, including one written by an older or newer build. +import { + readAgentSessionRefusalReference, + type AgentSessionAnyRefusalDetails +} from '../../shared/agent-session-wire-refusals' import { z } from 'zod' import { AGENT_SESSION_RESUME_FAILURE_OUTCOMES, @@ -28,6 +32,7 @@ const failureSchema = z.object({ failedAt: z.number().int().nonnegative(), outcome: z.enum(AGENT_SESSION_RESUME_FAILURE_OUTCOMES), reason: z.string().max(MAX_FAILURE_FIELD_LENGTH), + details: z.unknown().optional(), latestPrompt: z.string().max(MAX_FAILURE_FIELD_LENGTH), latestUserItemId: z.string().max(MAX_FAILURE_FIELD_LENGTH).nullable() }) @@ -47,7 +52,11 @@ export type AgentSessionResumeFailureRecord = { marker: AgentSessionResumeMarker failedAt: number outcome: AgentSessionResumeFailureOutcome + /** The refusal code, as it always was; the renderer's guidance keys on it. */ reason: string + /** The refusal's details beside the code; absent on older records and non-refusals. A record + * an unreleased build wrote with a `cause` instead reads as having none. */ + details?: AgentSessionAnyRefusalDetails /** The prompt the offer quoted, snapshotted because the session may no longer be readable. */ latestPrompt: string /** The chat's newest user message when this was filed, as the marker records it at teardown. A @@ -109,7 +118,16 @@ function parseFailures(value: unknown): AgentSessionResumeFailureRecord[] { return (Array.isArray(value) ? value : []).flatMap((failure: unknown) => { const parsed = failureSchema.safeParse(failure) const marker = parsed.success ? parseAgentSessionResumeMarker(parsed.data.marker) : null - return parsed.success && marker ? [{ ...parsed.data, marker }] : [] + if (!parsed.success || !marker) { + return [] + } + const { details: stored, ...rest } = parsed.data + // `reason` is the refusal code, so the details are read against it. + const details = readAgentSessionRefusalReference({ + code: rest.reason, + details: stored + })?.details + return [{ ...rest, marker, ...(details ? { details } : {}) }] }) } diff --git a/src/main/runtime/agent-session-recovery-capsule.test.ts b/src/main/runtime/agent-session-recovery-capsule.test.ts index 2150e5c7d51..d70fde57af3 100644 --- a/src/main/runtime/agent-session-recovery-capsule.test.ts +++ b/src/main/runtime/agent-session-recovery-capsule.test.ts @@ -342,6 +342,41 @@ describe('durable restart offers', () => { expect(JSON.parse(await readFile(filePath, 'utf8')).failed).toEqual([readable]) }) + it('keeps refusal details beside the code, read back against that code', async () => { + const readable = { + marker: marker(), + failedAt: NOW, + outcome: 'refused', + reason: 'agent_session_conflict', + latestPrompt: '', + latestUserItemId: null + } + await writeFile( + filePath, + JSON.stringify({ + version: 2, + entries: [], + failed: [ + { ...readable, details: { reason: 'claimConflicted', note: 'dropped' } }, + // An unreleased build wrote a cause here; it still parses, naming nothing. + { ...readable, marker: marker({ sessionId: 'older' }), cause: 'claimConflicted' }, + // A reason the code does not list is not this code's. + { + ...readable, + marker: marker({ sessionId: 'foreign' }), + details: { reason: 'promptGone' } + } + ] + }) + ) + + expect(await capsule.listFailed(NOW)).toEqual([ + { ...readable, details: { reason: 'claimConflicted' } }, + { ...readable, marker: marker({ sessionId: 'older' }) }, + { ...readable, marker: marker({ sessionId: 'foreign' }) } + ]) + }) + it('reads a malformed failure list as no failures', async () => { await writeFile( filePath, diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index 9b74186ffbe..d86383ddae7 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -9,6 +9,7 @@ * inside a transaction, which is what makes the record and its operation row land together. */ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { agentSessionOperationKey, evaluateAgentSessionOperation, @@ -112,7 +113,7 @@ export function requireAgentSessionRecordForReplay( if (!record) { // Why: the recorded effect is no longer reconstructable, and re-running it would be a second // spawn rather than a replay. - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'recordMissing' }) } return record } @@ -128,11 +129,13 @@ export function admitPendingAgentSessionReservationReplay( probe: request.probe }) if (decision.decision === 'refused') { - throw new Error(decision.code) + throw agentSessionRefusalError(decision.code, decision.details) } if (decision.decision !== 'retry-reservation') { // A replay may continue only its still-present reservation. - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'replaySuperseded' + }) } return record } @@ -170,10 +173,10 @@ export function applyAgentSessionReservation( const existing = state.records.get(request.sessionId) if (!existing) { if (state.unreadableRecords.has(request.sessionId)) { - throw new Error('execution_owner_reconciling') + throw agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) } if (request.expectedFence !== null) { - throw new Error('agent_session_checkpoint_stale') + throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'recordMissing' }) } assertReservedTabUnheld(state, request) return { record: createAgentSessionRecord(request, reservation), disposition: 'created' } @@ -185,7 +188,7 @@ export function applyAgentSessionReservation( existing.accountHome.path !== request.accountHome.path ) { // Why: location, provider, and account are the session identity; changing one is a fork. - throw new Error('agent_session_conflict') + throw agentSessionRefusalError('agent_session_conflict', { reason: 'identityMismatch' }) } // A create may take over only a record that never bound a conversation and whose last // attempt is proven gone: that is the same as creating it fresh, under a fresh provider id. @@ -194,7 +197,7 @@ export function applyAgentSessionReservation( !request.adoptedHandleLink && agentSessionLeaseOwnerVerdict(existing.lease) === 'exited' if (request.expectedFence === null && !recreatable) { - throw new Error('agent_session_conflict') + throw agentSessionRefusalError('agent_session_conflict', { reason: 'sessionExists' }) } assertReservedTabUnheld(state, request) const pinned = { @@ -240,7 +243,9 @@ function assertAdoptedConversationUnowned( (link) => agentSessionProviderHandleRoot(link.handle) === root ) if (holdsSameConversation) { - throw new Error('agent_session_conflict') + throw agentSessionRefusalError('agent_session_conflict', { + reason: 'conversationHeldElsewhere' + }) } } } @@ -258,11 +263,13 @@ function assertReservedTabUnheld( return } if (!isAgentSessionSurfaceTabId(request.surfaceTabId)) { - throw new Error('agent_session_operation_invalid') + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'requestMalformed' + }) } const holder = state.sessionTabs?.sessionIdFor(request.surfaceTabId) if (holder !== undefined && holder !== request.sessionId) { - throw new Error('agent_session_conflict') + throw agentSessionRefusalError('agent_session_conflict', { reason: 'tabIdTaken' }) } } @@ -324,7 +331,7 @@ export function commitAgentSessionReservation( if (decision.decision === 'refused') { // An aged-out row proves nothing more: a released reservation runs no effect. if (decision.code !== 'agent_session_operation_expired' || !continued) { - throw new Error(decision.code) + throw agentSessionRefusalError(decision.code, decision.details) } const row = pendingAgentSessionOperationRow({ ...request.operation, now: request.now }) return reserveWithOperationRow(state, continued, row, leaseTtlMs) diff --git a/src/main/runtime/agent-session-surface-release-transition.ts b/src/main/runtime/agent-session-surface-release-transition.ts index 5c57c18ba41..7da1e7633ef 100644 --- a/src/main/runtime/agent-session-surface-release-transition.ts +++ b/src/main/runtime/agent-session-surface-release-transition.ts @@ -8,6 +8,7 @@ // The fence still moves, so the next owner is a new generation: an attach or settlement still // holding the stopped owner's fence is refused as stale rather than acting on its successor. +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import { assertFence, withLease } from './agent-session-lease-transitions' @@ -37,7 +38,7 @@ export function releaseAgentSessionOwnerAfterSurfaceClose(args: { const { record } = args assertFence(record.lease, args.expectedFence) if (!isSurfaceReleasableAgentSessionRecord(record)) { - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'leaseMoved' }) } return withLease(record, { ...record.lease, diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts index 3855d431989..6022aac8bf2 100644 --- a/src/main/runtime/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session-tab-table.ts @@ -1,4 +1,5 @@ import { isAgentSessionId, type AgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' import type { AgentSessionStoreState } from './agent-session-record-store-file' @@ -92,7 +93,7 @@ export class AgentSessionTabTable { private put(tabId: string, sessionId: string): void { const owner = this.sessionByTab.get(tabId) if (owner !== undefined && owner !== sessionId) { - throw new Error('agent_session_conflict') + throw agentSessionRefusalError('agent_session_conflict', { reason: 'tabIdTaken' }) } this.hide(sessionId) this.sessionByTab.set(tabId, sessionId) @@ -120,7 +121,7 @@ export function setAgentSessionTabVisibility( tabId?: string ): void { if (visible && !state.records.has(sessionId)) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } state.sessionTabs ??= new AgentSessionTabTable() if (visible) { diff --git a/src/main/runtime/claude-structured-exit-mid-response.test.ts b/src/main/runtime/claude-structured-exit-mid-response.test.ts new file mode 100644 index 00000000000..fd30df7dad0 --- /dev/null +++ b/src/main/runtime/claude-structured-exit-mid-response.test.ts @@ -0,0 +1,72 @@ +// A Claude CLI that exits on its own after its start landed, with a message handed to it: the chat +// says Claude stopped, by name. Against the production runtime, adapter, record store and host, +// with only the CLI process scripted. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' +import { + createScriptedClaudeRuntime, + scriptedClaudeExitError +} from './structured-claude-scripted-runtime-test-support' + +const SESSION = 'claude-exit-mid-response' +const CALLER = { callerKey: 'client-1' } + +let claude = createScriptedClaudeRuntime([SESSION]) +let operations = 0 + +afterEach(async () => { + vi.restoreAllMocks() + await claude.dispose() + claude = createScriptedClaudeRuntime([SESSION]) +}) + +async function send(host: StructuredAgentSessionHost, text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}`, + expectedRuntimeFence: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent, JSON.stringify(sent)).toMatchObject({ ok: true }) +} + +describe('a started Claude CLI that exits while a response is in progress', () => { + it('says Claude stopped, and that the conversation can continue', async () => { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + await send(host, 'hello') + await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + + claude.child(SESSION).exit(scriptedClaudeExitError('claude stream-json exited (code 137)')) + await waitForStructuredAgentSessionRecovery() + + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && item.body.failure + ? [{ text: item.body.text, kind: item.body.failure.kind }] + : [] + ) + ).toEqual([ + { + text: 'Claude stopped while this response was in progress. You can continue in this conversation.', + kind: 'providerExited' + } + ]) + ) + }) +}) diff --git a/src/main/runtime/claude-structured-failed-start-resume.test.ts b/src/main/runtime/claude-structured-failed-start-resume.test.ts index 32362cbafdc..cd4f44d59f8 100644 --- a/src/main/runtime/claude-structured-failed-start-resume.test.ts +++ b/src/main/runtime/claude-structured-failed-start-resume.test.ts @@ -55,7 +55,7 @@ describe('a Claude chat whose CLI exits the moment it is spawned', () => { // Before the spawn returns, the start time of a dead pid is unreadable; during that read, the // child is found closed afterwards. Both must answer with what the CLI said. it.each(['spawn', 'start-time-read'] as const)( - "refuses the create with the CLI's own diagnostic when it exits at %s", + "refuses the create in a sentence, not the CLI's diagnostic, when it exits at %s", async (at) => { const diagnostic = 'claude stream-json exited (code 1): claude: not signed in' claude.behave(SESSION, { exitsDuringSpawn: { diagnostic, at } }) @@ -65,7 +65,10 @@ describe('a Claude chat whose CLI exits the moment it is spawned', () => { expect(created).toMatchObject({ ok: false, - refusal: { message: expect.stringContaining('not signed in'), ownerVerdict: 'exited' } + refusal: { + message: 'Claude stopped before it finished starting. Send your message to try again.', + ownerVerdict: 'exited' + } }) } ) diff --git a/src/main/runtime/claude-structured-resumed-start-failure.test.ts b/src/main/runtime/claude-structured-resumed-start-failure.test.ts index 8d379388a40..7a0c66e1b85 100644 --- a/src/main/runtime/claude-structured-resumed-start-failure.test.ts +++ b/src/main/runtime/claude-structured-resumed-start-failure.test.ts @@ -7,11 +7,15 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import type { AgentSessionSubscribeEvent } from '../../shared/agent-session-wire' import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' -import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { + createScriptedClaudeRuntime, + scriptedClaudeExitError +} from './structured-claude-scripted-runtime-test-support' const SESSION = 'claude-resumed-start' const CALLER = { callerKey: 'client-1' } const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in' +const STARTUP_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' let claude = createScriptedClaudeRuntime([SESSION]) @@ -62,18 +66,19 @@ describe('a reopened Claude chat whose CLI dies before initialize', () => { // Accepted first; the delivery loop starts the second child after. await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(2)) - claude.child(SESSION).exit(new Error(DIAGNOSTIC)) + claude.child(SESSION).exit(scriptedClaudeExitError(DIAGNOSTIC)) await waitForStructuredAgentSessionRecovery() - await vi.waitFor(() => - expect(statusTexts(events)).toContainEqual( - expect.stringMatching(/stopped before it finished starting: .*not signed in/) - ) - ) + await vi.waitFor(() => expect(statusTexts(events)).toContainEqual(STARTUP_TEXT)) // Never written, so it did not happen: refused, not left in doubt. const submission = (await host.journalSnapshot(SESSION)).submissions.find( (entry) => entry.clientMessageId === (sent.ok && sent.value.clientMessageId) ) - expect(submission).toMatchObject({ dispatchState: 'rejected' }) + // The stderr the exit carried is beside the sentence, as a log detail, and never in it. + expect(submission).toMatchObject({ + dispatchState: 'rejected', + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed', detail: { text: DIAGNOSTIC, audience: 'log' } } + }) }) }) diff --git a/src/main/runtime/claude-structured-send-held-for-startup.test.ts b/src/main/runtime/claude-structured-send-held-for-startup.test.ts index 989c7db1b96..efb8d3cde16 100644 --- a/src/main/runtime/claude-structured-send-held-for-startup.test.ts +++ b/src/main/runtime/claude-structured-send-held-for-startup.test.ts @@ -10,11 +10,15 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' -import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { + createScriptedClaudeRuntime, + scriptedClaudeExitError +} from './structured-claude-scripted-runtime-test-support' const SESSION = 'claude-send-held' const CALLER = { callerKey: 'client-1' } const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in (rig)' +const STARTUP_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' let claude = createScriptedClaudeRuntime([SESSION]) let operations = 0 @@ -67,7 +71,7 @@ async function submission(host: StructuredAgentSessionHost, clientMessageId: str /** The CLI keeps dying at startup: the latest child exits with the diagnostic once it exists. */ async function failLatestStart(host: StructuredAgentSessionHost, count: number): Promise { await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(count)) - claude.child(SESSION).exit(new Error(DIAGNOSTIC)) + claude.child(SESSION).exit(scriptedClaudeExitError(DIAGNOSTIC)) await waitForStructuredAgentSessionRecovery() await vi.waitFor(() => expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') @@ -82,7 +86,7 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { ok: true }) await failLatestStart(host, 1) - expect(await statusRows(host)).toEqual([expect.stringContaining('not signed in')]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT]) const releasedFence = fence(host) // The delivery loop asks for the child back and the message waits for its start; the CLI @@ -99,13 +103,11 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { expect(await submission(host, held)).toMatchObject({ dispatchState: 'rejected', // Worded for the user: the red line under the composer shows it as it stands. - reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed' } }) ) - expect(await statusRows(host)).toEqual([ - expect.stringContaining('not signed in'), - expect.stringMatching(/stopped before it finished starting: .*not signed in \(rig\)/) - ]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) // The restart moved the fence twice: its acquisition, and the exit that released it. expect(fence(host)).toBe(releasedFence + 2) expect(claude.children(SESSION)).toHaveLength(2) @@ -154,12 +156,11 @@ describe('a send while the first Claude start is still answering initialize', () expect(await submission(host, held)).toMatchObject({ dispatchState: 'rejected', // Worded for the user: the red line under the composer shows it as it stands. - reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed' } }) ) - expect(await statusRows(host)).toEqual([ - expect.stringMatching(/stopped before it finished starting: .*not signed in \(rig\)/) - ]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT]) expect(fence(host)).toBe(startedFence + 1) expect(claude.children(SESSION)).toHaveLength(1) expect(claude.child(SESSION).calls).not.toContain('send') diff --git a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts index 76596ee7f64..183879abe1a 100644 --- a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts +++ b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts @@ -11,11 +11,15 @@ import type { AgentSessionSubscribeEvent } from '../../shared/agent-session-wire import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' -import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { + createScriptedClaudeRuntime, + scriptedClaudeExitError +} from './structured-claude-scripted-runtime-test-support' const SESSION = 'claude-send-restart-dies-first' const CALLER = { callerKey: 'client-1' } const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in (rig)' +const STARTUP_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => unknown): Promise { @@ -91,7 +95,7 @@ async function submission(host: StructuredAgentSessionHost, clientMessageId: str async function failLatestStart(host: StructuredAgentSessionHost, count: number): Promise { await eventually(() => expect(claude.children(SESSION)).toHaveLength(count)) - claude.child(SESSION).exit(new Error(DIAGNOSTIC)) + claude.child(SESSION).exit(scriptedClaudeExitError(DIAGNOSTIC)) await waitForStructuredAgentSessionRecovery() await eventually(() => expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') @@ -143,13 +147,11 @@ describe('a send whose restarted Claude child dies before it proves its start', expect(await submission(host, sent)).toMatchObject({ dispatchState: 'rejected', // Worded for the user: the red line under the composer shows it as it stands. - reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed' } }) ) - expect(await statusRows(host)).toEqual([ - expect.stringContaining('not signed in'), - expect.stringMatching(/stopped before it finished starting: .*not signed in \(rig\)/) - ]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) expect(fence(host)).toBe(releasedFence + 2) expect(claude.children(SESSION)).toHaveLength(2) expect(claude.child(SESSION).calls).not.toContain('send') @@ -180,15 +182,13 @@ describe('a send whose restarted Claude child dies before it proves its start', await eventually(async () => expect(await submission(host, sent)).toMatchObject({ dispatchState: 'rejected', - reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed' } }) ) await waitForStructuredAgentSessionRecovery() - expect(await statusRows(host)).toEqual([ - `The provider stopped before it finished starting: ${DIAGNOSTIC}.`, - `The provider stopped before it finished starting: ${DIAGNOSTIC}.` - ]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) } ) @@ -214,9 +214,7 @@ describe('a send whose restarted Claude child dies before it proves its start', await eventually(() => { const seen = received(events) expect(seen.submissions.get(sent)).toBe('rejected') - expect(seen.statusTexts).toContainEqual( - expect.stringMatching(/stopped before it finished starting: .*not signed in \(rig\)/) - ) + expect(seen.statusTexts).toContainEqual(STARTUP_TEXT) // The subscriber ended up on the fence the exit published, not the one the restart did. expect(seen.fences.at(-1)).toBe(fence(host)) }) @@ -227,8 +225,8 @@ describe('a send whose restarted Claude child dies before it proves its start', }) describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber open throughout', () => { - const STARTUP_FAILURE = - 'The provider stopped before it finished starting: claude stream-json exited (code 1): claude: not signed in (rig).' + // The stderr rides beside the sentence as a log detail; the sentence is the same every time. + const STARTUP_FAILURE = STARTUP_TEXT /** Status rows a subscriber has been shown, one per row whatever frame carried it. */ function shownRows(events: AgentSessionSubscribeEvent[]): Map { diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 83ae363b7ae..82db5d6c542 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -364,9 +364,28 @@ describe('a structured Claude session over agentSession.*', () => { claudeAuthPolicy = { stripAuthEnv: true } // The default overlay carries ANTHROPIC_AUTH_TOKEN, which the terminal path // refuses at spawn-env.ts:25 rather than letting it beat the pinned account. - const refused = await call('agentSession.create', createIntentParams()) - - expect(JSON.stringify(refused)).toContain('explicit Anthropic auth environment') + const params = createIntentParams() + const sentence = + 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' + const refused = await call('agentSession.create', params) + + // The thrown answer keeps its wire code; only its words are the ones its replay reads. + expect(refused).toMatchObject({ + ok: false, + error: { code: 'runtime_error', message: sentence } + }) + // Its replay reads the same sentence, beside the situation it names. + expect(await call('agentSession.create', params)).toMatchObject({ + ok: true, + result: { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'managedAccountEnvOverride' }, + message: sentence + } + } + }) // Refused before spawn: no provider child was ever opened. expect(claude.connections).toHaveLength(0) }) @@ -379,11 +398,11 @@ describe('a structured Claude session over agentSession.*', () => { await waitForStructuredAgentSessionRecovery() const guidance = itemsOf(await subscribe()).find((item) => item.body?.kind === 'status') + // The adapter typed the refusal, so the row names the situation rather than quoting Orca. expect(guidance?.body).toMatchObject({ kind: 'status', - text: expect.stringMatching( - /stopped before it finished starting: .*not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s - ) + text: 'Claude is not signed in for the selected account. Sign in, then send your message again.', + failure: { kind: 'notSignedIn' } }) expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) // A failed start is not auto-resumed into the same failure. @@ -393,7 +412,7 @@ describe('a structured Claude session over agentSession.*', () => { // The root's death is first-hand. Its descendants were never snapshottable, or one was seen // alive; either way the lease follows the root, so the reservation goes with it. it.each(['unverifiable', 'live'] as const)( - 'releases a session whose CLI self-exited during create with its tree %s, with its diagnostic intact', + 'releases a session whose CLI self-exited during create with its tree %s, refused in a sentence', async (tree) => { claude.setSelfExit({ message: 'claude stream-json exited (code 1): claude: not signed in', @@ -409,7 +428,8 @@ describe('a structured Claude session over agentSession.*', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - message: expect.stringContaining('claude: not signed in'), + // The CLI's stderr is log text; the person reads what the chat's start failure says. + message: 'Claude stopped before it finished starting. Send your message to try again.', ownerVerdict: 'exited' } } @@ -444,7 +464,7 @@ describe('a structured Claude session over agentSession.*', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - message: expect.stringContaining('claude: not signed in'), + message: 'Claude stopped before it finished starting. Send your message to try again.', ownerVerdict: 'exited' } } diff --git a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts new file mode 100644 index 00000000000..863cbaeaebf --- /dev/null +++ b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts @@ -0,0 +1,139 @@ +// A Claude start can fail on Orca's side while the CLI is still running: a saved option it can't +// restore, or an init frame naming another session. Orca ends that child itself, so the chat must +// not say Claude stopped on its own; only an exit Orca saw says that. Against the production +// runtime, adapter, record store and host, with only the CLI process scripted. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' +import { + createScriptedClaudeRuntime, + scriptedClaudeExitError +} from './structured-claude-scripted-runtime-test-support' + +const SESSION = 'claude-startup-fault' +const CALLER = { callerKey: 'client-1' } +const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in (rig)' +const STOPPED_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' + +let claude = createScriptedClaudeRuntime([SESSION]) +let operations = 0 + +afterEach(async () => { + vi.restoreAllMocks() + await claude.dispose() + claude = createScriptedClaudeRuntime([SESSION]) +}) + +function fence(host: StructuredAgentSessionHost): number { + return host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 +} + +async function send(host: StructuredAgentSessionHost, text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}`, + expectedRuntimeFence: fence(host), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent, JSON.stringify(sent)).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending' } } + }) + return sent.ok ? sent.value.clientMessageId : '' +} + +async function failureRows(host: StructuredAgentSessionHost) { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && item.body.failure + ? [{ text: item.body.text, kind: item.body.failure.kind }] + : [] + ) +} + +async function submission(host: StructuredAgentSessionHost, clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +async function released(host: StructuredAgentSessionHost): Promise { + await waitForStructuredAgentSessionRecovery() + await vi.waitFor(() => + expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + ) +} + +describe('a Claude start that Orca fails while the CLI is still running', () => { + it('reads as a start that could not happen when a saved option cannot be restored', async () => { + claude.behave(SESSION, { optionWritesFail: true }) + const host = await claude.install() + await expect( + host.attach( + CALLER, + claude.attachParams(SESSION, null, { options: { permissionMode: 'plan' } }) + ) + ).resolves.toMatchObject({ ok: true }) + await released(host) + + expect(claude.child(SESSION).calls).toContain('set_permission_mode') + expect(await failureRows(host)).toEqual([ + { text: expect.stringMatching(/^Claude couldn't start\./), kind: 'startFailed' } + ]) + }) + + it('rejects a held message as a start that could not happen when init names another session', async () => { + claude.behave(SESSION, { initHangs: true, initNamesForeignSession: true }) + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const held = await send(host, 'hello') + + claude.child(SESSION).answerInit() + await released(host) + + await vi.waitFor(async () => + expect(await submission(host, held)).toMatchObject({ + dispatchState: 'rejected', + reason: expect.stringMatching(/^Claude couldn't start\./), + rejection: { kind: 'startFailed' } + }) + ) + expect(await failureRows(host)).toEqual([ + { text: expect.stringMatching(/^Claude couldn't start\./), kind: 'startFailed' } + ]) + expect(claude.child(SESSION).calls).not.toContain('send') + }) + + it('still says Claude stopped when the CLI exits on its own before its start lands', async () => { + claude.behave(SESSION, { initHangs: true }) + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const held = await send(host, 'hello') + + claude.child(SESSION).exit(scriptedClaudeExitError(DIAGNOSTIC)) + await released(host) + + await vi.waitFor(async () => + expect(await submission(host, held)).toMatchObject({ + dispatchState: 'rejected', + reason: STOPPED_TEXT, + rejection: { kind: 'providerStartFailed' } + }) + ) + expect(await failureRows(host)).toEqual([{ text: STOPPED_TEXT, kind: 'providerStartFailed' }]) + }) +}) diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index f2143b9772c..ce2cf31b9ce 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { OrcaRuntimeWithGetWorktreePs } from './orca-runtime-get-worktree-ps' import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support' import { supportsClaudeStructuredLocation } from '../claude/claude-structured-location-support' @@ -156,7 +157,9 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR ): Promise { const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) if (!support.supported) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) } const settings = this.requireStore().getSettings() const launchEnv = resolveTuiAgentLaunchEnv(input.agent, settings.agentDefaultEnv) diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index 02ae6a335f2..624557c1f7f 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -15,6 +15,11 @@ import { nestedWorkerDepthExceededMessage } from '../../../shared/nested-worker-depth' import { OrchestrationError } from '../orchestration/orchestration-error' +import { + AgentSessionRefusalError, + agentSessionRefusalError, + refuseUnclassified +} from '../../../shared/agent-session-wire-refusals' class LineageError extends Error { code = 'LINEAGE_PARENT_NOT_FOUND' @@ -297,3 +302,63 @@ describe('structured worker dispatch preamble errors', () => { }) }) }) + +describe('thrown agent-session refusals', () => { + const meta = { runtimeId: 'runtime-1' } + + // Released clients classify a thrown refusal by its wire code and message; both must read + // exactly as the bare `Error(code)` this replaced. + it.each([ + [ + 'agent_session_ownership_unknown', + 'agent_session_ownership_unknown', + agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'noLiveOwner' }) + ], + [ + 'structured_agent_session_unsupported', + 'runtime_error', + agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostDisabled' }) + ], + [ + 'agent_session_checkpoint_stale', + 'agent_session_checkpoint_stale', + agentSessionRefusalError('agent_session_checkpoint_stale', { + reason: 'fenceStale', + currentFence: 4 + }) + ] + ] as const)( + 'keeps %s on the wire as it was, and adds its details in data', + (code, wire, error) => { + const before = mapRuntimeError('req_1', meta, new Error(code)) + const after = mapRuntimeError('req_1', meta, error) + expect(after.error.code).toBe(before.error.code) + expect(after.error.code).toBe(wire) + expect(after.error.message).toBe(before.error.message) + expect(after.error.message).toBe(code) + expect(after.error.data).toEqual({ refusal: { code, details: error.refusal.details } }) + expect(error.refusal.details?.reason).toBeDefined() + } + ) + + it('carries no details in data when the refusal named none', () => { + const response = mapRuntimeError( + 'req_1', + meta, + new AgentSessionRefusalError( + refuseUnclassified('agent_session_conflict', 'Another process claims this session.') + ) + ) + expect(response.error).toEqual({ + code: 'agent_session_conflict', + message: 'agent_session_conflict', + data: { refusal: { code: 'agent_session_conflict' } } + }) + }) + + it('exposes no code property another passthrough could claim', () => { + expect( + 'code' in agentSessionRefusalError('agent_session_conflict', { reason: 'claimConflicted' }) + ).toBe(false) + }) +}) diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index ec7f7a0559d..6d122c02d45 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -2,6 +2,11 @@ // runtime/browser error allowlists define the contract the CLI relies on to // format human-facing messages. Centralizing this mapping keeps the allowlist // auditable in one place instead of spread across per-method branches. +import { + agentSessionRefusalReference, + isAgentSessionRefusalError, + type AgentSessionRefusalError +} from '../../../shared/agent-session-wire-refusals' import type { RpcEnvelopeMeta, RpcFailure, RpcSuccess } from './core' import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES } from '../../../shared/orchestration-session-caller-codes' import { computerUseErrorRecoveryData } from '../../../shared/computer-use-error-recovery' @@ -160,6 +165,9 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure { const message = error instanceof Error ? error.message : String(error) + if (isAgentSessionRefusalError(error)) { + return agentSessionRefusalErrorResponse(id, meta, error) + } if ( error instanceof Error && 'code' in error && @@ -230,6 +238,27 @@ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknow return errorResponse(id, meta, 'runtime_error', message) } +/** + * A thrown agent-session refusal, mapped before any `'code' in error` passthrough so no other + * subsystem's code set can claim it. Wire code and message are exactly what the bare `Error(code)` + * it replaced produced — released clients classify both — and the refusal's details ride only in + * `data`, which they ignore. + */ +function agentSessionRefusalErrorResponse( + id: string, + meta: RpcEnvelopeMeta, + error: AgentSessionRefusalError +): RpcFailure { + const { code } = error.refusal + return errorResponse( + id, + meta, + RUNTIME_PASSTHROUGH_CODES.has(code) ? code : 'runtime_error', + code, + { refusal: agentSessionRefusalReference(error.refusal) } + ) +} + export const computerErrorData = computerUseErrorRecoveryData // Why: browser errors carry a structured .code property (BrowserError from diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts index 3d6981ed4a1..336cd4c212b 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -585,7 +585,8 @@ describe('the inner attach reserves under its own id', () => { }) expect(forwarded).toEqual({ decision: 'refused', - code: 'agent_session_operation_conflict' + code: 'agent_session_operation_conflict', + details: { reason: 'operationIdReused' } }) const derived = deriveAgentLaunchChildOperationId(OPERATION_ID) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index e50ac1a6fbb..0d1f04cb83b 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import { dispatchWriteFailureReason } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { DISPATCH_REJECTED_WRITE_FAILED } from '../../../../shared/structured-agent-session-dispatch-rejection' const hostRef: { current: unknown } = { current: null } const createSpy = vi.fn() @@ -314,13 +314,33 @@ describe('structured worker dispatch preamble', () => { expect(isUnknownWorkerStartOutcome(error, 'dispatch_input')).toBe(false) }) + it('ends the message with one period whether the reason is a sentence or a marker', async () => { + const sentence = await send( + hostWithSubmission({ + dispatchState: 'rejected', + reason: 'The provider stopped before this message was sent.' + }) + ).catch((thrown: unknown) => thrown) + expect(sentence).toMatchObject({ + message: + 'The dispatch preamble was not delivered: The provider stopped before this message was sent.' + }) + const marker = await send( + hostWithSubmission({ dispatchState: 'unknown', reason: 'provider child exited' }) + ).catch((thrown: unknown) => thrown) + expect(marker).toMatchObject({ + message: + 'The dispatch preamble was submitted but not acknowledged (unknown): provider child exited.' + }) + }) + it('reports a refused transport write as undelivered, never as unknown', async () => { // The state a provably-unwritten frame now settles. Nothing reached the provider, // so there is no running turn for a coordinator to go and look at. const error = await send( hostWithSubmission({ dispatchState: 'rejected', - reason: dispatchWriteFailureReason(new Error('broken pipe')) + reason: DISPATCH_REJECTED_WRITE_FAILED }) ).catch((thrown: unknown) => thrown) expect((error as { code?: string }).code).toBe('dispatch_preamble_undelivered') diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts index 999445bf2d0..dad93face82 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -199,6 +199,11 @@ export async function discardStructuredWorkerSession( retireSettledStructuredWorkerTab(sessionId, runtime) } +/** A submission reason as a clause: host sentences end in a period, legacy markers do not. */ +function reasonClause(reason: string | null | undefined): string { + return (reason ?? 'no reason given').replace(/[.\s]+$/, '') +} + /** What a preamble send reads of the host. */ type StructuredWorkerPreambleHost = Pick< StructuredAgentSessionHost, @@ -261,7 +266,9 @@ export async function sendStructuredWorkerPreamble(args: { // pending receipt; only this one lets the caller retry knowing nothing landed. throw new OrchestrationError( 'dispatch_preamble_undelivered', - `The dispatch preamble was not delivered: ${submission.reason ?? 'no reason given'}.` + `The dispatch preamble was not delivered${ + submission.rejection ? ` (${submission.rejection.kind})` : '' + }: ${reasonClause(submission.reason)}.` ) } // Only `accepted` is an acknowledgement — the same rule the mail lane already applies. A thrown @@ -270,7 +277,7 @@ export async function sendStructuredWorkerPreamble(args: { // `outcome_unknown` receipt whose nextCommands send the coordinator to look. throw new OrchestrationError( 'operation_unknown', - `The dispatch preamble was submitted but not acknowledged (${submission.dispatchState}): ${submission.reason ?? 'no reason given'}.` + `The dispatch preamble was submitted but not acknowledged (${submission.dispatchState}): ${reasonClause(submission.reason)}.` ) } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 8202f793289..a3d598a69d8 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -26,6 +26,7 @@ import { restTestSend, type RestTestRig } from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import * as providerSupport from '../../../native-chat/agent-session-wire/structured-agent-session-provider-support' import { OrcaRuntimeService } from '../../orca-runtime' import type { RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' @@ -87,6 +88,7 @@ beforeEach(async () => { }) afterEach(async () => { + vi.restoreAllMocks() setStructuredAgentSessionHost(null) await rig.dispose() }) @@ -198,6 +200,42 @@ describe('the accessor', () => { expect(rig.host.hasSession(SESSION)).toBe(false) }) + it('refuses a read it cannot open with the reason, under the same code and message', async () => { + const [missing] = await call('agentSession.history', { + sessionId: 'session-never-created', + direction: 'tail' + }) + expect(missing).toMatchObject({ + ok: false, + error: { + code: 'agent_session_identity_required', + message: 'agent_session_identity_required', + data: { + refusal: { + code: 'agent_session_identity_required', + details: { reason: 'recordMissing' } + } + } + } + }) + + await restingChat() + vi.spyOn(providerSupport, 'adapterSupportsRecord').mockReturnValue(false) + const [unsupported] = await call('agentSession.history', { + sessionId: SESSION, + direction: 'tail' + }) + expect(unsupported).toMatchObject({ + ok: false, + error: { + // Not a passthrough code: released clients match the message, as before. + code: 'runtime_error', + message: 'structured_agent_session_unsupported', + data: { refusal: { details: { reason: 'hostUnsupported' } } } + } + }) + }) + it('opens a corrupt journal through the recovering open and still accepts a send (P2-03)', async () => { await foundRestTestChat(rig) await rig.host.flushAllStreamedEvents() @@ -376,9 +414,13 @@ describe('every close withdraws what is queued (P2-29)', () => { ) await close() + // A close's rejection is a sentence with its fact beside it, never a marker. await vi.waitFor(() => - expect(JSON.stringify(reader)).toContain('provider_closed_before_delivery') + expect(JSON.stringify(reader)).toContain( + '"reason":"The chat closed before this message was sent.","submittedAt"' + ) ) + expect(JSON.stringify(reader)).toContain('"rejection":{"kind":"chatClosed"}') expect(rig.host.hasSession(SESSION)).toBe(false) expect(rig.adapter.acquire).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 1a385922d2a..0c3b7b08713 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -12,6 +12,7 @@ * in. Both callers run the same two halves, so orchestration gets that guarantee too. */ +import { refuse } from '../../../../shared/agent-session-wire-refusals' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import type { AgentSessionAttachResult, @@ -144,10 +145,11 @@ export async function commitStructuredAgentSessionCreate(args: { console.warn('[agent-session] create committed before tab publication failed', error) return { ok: false, - refusal: { - code: 'agent_session_operation_unknown', - message: 'The chat may have been created, but its tab could not be confirmed.' - } + refusal: refuse( + 'agent_session_operation_unknown', + { reason: 'tabUnconfirmed' }, + 'The chat may have been created, but its tab could not be confirmed.' + ) } } // Read after publishing, which is what gives the chat its tab. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index f3ad78c5c5c..e4ff861d1ac 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -8,6 +8,7 @@ // for old mobile clients while structured chat is enabled so they receive a fallback row, and that // path constructs the host. `agentSession.*` stays refused either way, which is what this gate is for. +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' @@ -27,7 +28,9 @@ export function supportsStructuredSessions(ctx: RpcContext): boolean { export function requireStructuredCapability(ctx: RpcContext): void { if (!supportsStructuredSessions(ctx)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'clientCapabilityMissing' + }) } } @@ -35,7 +38,9 @@ export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHo requireStructuredCapability(ctx) const host = getStructuredAgentSessionHost() if (!host) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostDisabled' + }) } return host } @@ -64,11 +69,15 @@ export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHo */ export function requireStructuredCleanupHost(ctx: RpcContext): StructuredAgentSessionHost { if (!supportsStructuredAgentSessionCapability(ctx)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'clientCapabilityMissing' + }) } const host = getStructuredAgentSessionHost() if (!host) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostDisabled' + }) } return host } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 02099c9ddcf..140be5e1c39 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -24,6 +24,8 @@ import { OrcaRuntimeService } from '../../orca-runtime' import type { RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' const CONNECTION = 'connection-1' const CLIENT = { @@ -81,7 +83,12 @@ beforeEach(async () => { // acquire that throws leaves an unverifiable owner nothing may replace. releaseAcquisition: vi.fn(async () => true), closeSession, - dispatch: async () => ({ state: 'rejected', reason: 'unused' }), + dispatch: async () => ({ + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { + surface: 'rejection' + }) + }), cancelTurn: async () => ({ cancelled: false }), answerPrompt: async () => undefined, setOption: async () => undefined diff --git a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts index 24d9fa2aec4..4038fe9e40d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts @@ -12,6 +12,9 @@ import { AGENT_SESSION_WIRE_REFUSAL_CODES, + agentSessionRefusalFromReference, + isAgentSessionRefusalError, + refuseUnclassified, type AgentSessionWireRefusal, type AgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' @@ -40,19 +43,25 @@ function wireRefusalCode(error: unknown): AgentSessionWireRefusalCode | null { return null } +const PRECOMMIT_REFUSAL_MESSAGE = 'Orca cannot open a structured agent chat for this workspace.' + function precommitRefusal(error: unknown): AgentSessionWireRefusal { + // A refusal the host raised keeps its situation; a bare code names none. + if (isAgentSessionRefusalError(error)) { + return agentSessionRefusalFromReference(error.refusal, PRECOMMIT_REFUSAL_MESSAGE) + } const code = wireRefusalCode(error) if (code) { - return { code, message: 'Orca cannot open a structured agent chat for this workspace.' } + return refuseUnclassified(code, PRECOMMIT_REFUSAL_MESSAGE) } const message = error instanceof Error ? error.message : String(error) // A code-less failure here is often a defect, not a policy answer; the refusal keeps the user - // moving, the log keeps the cause findable. + // moving, the log keeps the cause findable. Nothing names its situation, so it carries no reason. console.warn('[agent-session] create refused before it committed anything', error) - return { - code: UNCODED_PRECOMMIT_REFUSAL_CODE, - message: `Orca could not prepare a structured agent chat for this workspace: ${message}` - } + return refuseUnclassified( + UNCODED_PRECOMMIT_REFUSAL_CODE, + `Orca could not prepare a structured agent chat for this workspace: ${message}` + ) } /** diff --git a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts index 47f30a5030d..903f8d440af 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts @@ -9,7 +9,11 @@ // reads its own record and answers with that record's workspace and provider, so a client knowing // only a session id cannot aim the publication somewhere else. -import { isAgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' +import { + agentSessionRefusalFromReference, + isAgentSessionRefusalError, + isAgentSessionWireRefusalCode +} from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionReveal } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' import { refuseAgentSessionMutation } from '../../../native-chat/agent-session-wire/structured-agent-session-mutation-admission' import { defineMethod } from '../core' @@ -37,13 +41,14 @@ export const STRUCTURED_AGENT_SESSION_REVEAL_METHODS = [ if (!isAgentSessionWireRefusalCode(code)) { throw error } - return refuseAgentSessionMutation({ - code, - message: + return refuseAgentSessionMutation( + agentSessionRefusalFromReference( + isAgentSessionRefusalError(error) ? error.refusal : { code }, code === 'structured_agent_session_unsupported' ? 'This host cannot open that chat.' : 'This chat is no longer on this host.' - }) + ) + ) } await ctx.runtime.publishStructuredAgentSessionTab({ workspaceId: revealed.workspaceId, diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index a238c3050e4..dc01a2fee8f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -5,6 +5,7 @@ // not exist rather than receiving the journal or mutation surface. Session-tab // inventory may expose only a metadata placeholder for an incapable mobile client. +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' import { agentSessionFingerprintConflict } from '../../../../shared/agent-session-mutation-envelope' import type { z } from 'zod' import { @@ -76,7 +77,9 @@ async function resolveClientSuppliedAttach(params: z.infer, await ensureHostInstalled(ctx) const host = requireHost(ctx) if (!host.supportsCreate(params.location, params.agent)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) } const { agent: _attachAgent, provider: _attachProvider, ...attachWithoutAgent } = params const attachParams = { @@ -131,7 +134,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ params: CreateSupportParams, handler: async (params, ctx) => { if (!supportsStructuredSessions(ctx)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'clientCapabilityMissing' + }) } return ctx.runtime.getStructuredAgentSessionCreateSupport(params.worktree, params.agent) } @@ -142,7 +147,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ handler: async (params, ctx) => { requireStructuredCapability(ctx) if (params.envelope.expectedRuntimeFence !== null) { - throw new Error('agent_session_operation_invalid') + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'requestMalformed' + }) } // Everything up to `attach` is pre-commit, and answers with a refusal rather than a throw so // a client can tell "nothing was created" from "the outcome is unknown". diff --git a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts index 75cbf2e6231..7b22a45d09d 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts @@ -2,6 +2,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import type { @@ -128,7 +130,13 @@ describe('a request that failed reads as failed through the feed, the ingest and fence: 1, handoverRecorded: true }) - await journal.rejectQueuedSubmissions(1, 'Claude is not signed in.') + await journal.rejectQueuedSubmissions( + 1, + agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), { + surface: 'rejection', + agentName: 'Claude' + }) + ) const summary = publishedSummary(journal) expect(summary).toMatchObject({ status: 'idle', turnOutcome: 'failure', latestPrompt: 'hello' }) @@ -205,7 +213,10 @@ describe('a request that failed reads as failed through the feed, the ingest and fence: 1, handoverRecorded: true }) - await journal.rejectQueuedSubmissions(1, 'provider_cancelled_before_start') + await journal.rejectQueuedSubmissions( + 1, + agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + ) expect(publishedSummary(journal)).toMatchObject({ status: null }) expect(ingest(publishedSummary(journal)).ps).toBeUndefined() diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 71854f903cd..4160b81c4de 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -60,6 +60,7 @@ export function structuredClaudeLifecycleEvent( type: 'ended', sessionId: event.sessionId, reason: event.reason, + ...(event.failure ? { failure: event.failure } : {}), cause: event.cause, fence: event.fence, acquisitionGeneration: event.acquisitionGeneration, diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index af1bc9a4af7..33aa18f0570 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -1,6 +1,7 @@ // A structured-session runtime whose Claude children are scripted: the production runtime, // adapter, record store and host, with only the CLI process replaced. +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import { mkdir, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -20,6 +21,10 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +/** The error a real CLI's exit reaches the adapter as: Orca's message, the stderr as a log detail. */ +export function scriptedClaudeExitError(diagnostic: string): Error { + return withProviderDiagnostic(new Error(diagnostic), providerDiagnostic(diagnostic, 'log')) +} export type ScriptedClaudeBehavior = { /** Initialize never answers; only the child's exit settles it. */ initHangs?: boolean @@ -37,6 +42,11 @@ export type ScriptedClaudeBehavior = { optionWritesHang?: boolean /** Startup's own settings read goes unanswered. */ startupSettingsReadHangs?: boolean + /** Every option write loses its answer while the CLI keeps running (not a refusal), so a + * start that restores one faults. */ + optionWritesFail?: boolean + /** The init frame names another provider session than the one launched. */ + initNamesForeignSession?: boolean } export type ScriptedClaudeChild = { @@ -86,7 +96,13 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { const never = (): Promise => new Promise(() => {}) const optionWrite = (subtype: string): Promise => { child.calls.push(subtype) - return control(subtype, () => (behavior.optionWritesHang ? never() : Promise.resolve())) + return control(subtype, () => + behavior.optionWritesFail + ? Promise.reject(new Error('Query closed before response received')) + : behavior.optionWritesHang + ? never() + : Promise.resolve() + ) } let settingsReads = 0 const child: ScriptedClaudeChild = { @@ -110,7 +126,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { handlers.onMessage?.({ type: 'system', subtype: 'init', - session_id: providerSessionId, + session_id: behavior.initNamesForeignSession ? 'foreign-session' : providerSessionId, model: 'claude-sonnet-5', apiKeySource: 'none' }) @@ -175,7 +191,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { } const exitAtSpawn = (child: ScriptedClaudeChild, diagnostic: string): void => { child.connection.closed = true - child.exit(new Error(diagnostic)) + child.exit(scriptedClaudeExitError(diagnostic)) } /** A pid whose process is gone has no start time to read. */ const readProcessStartTime = async (pid: number): Promise => { diff --git a/src/main/sqlite/sqlite-read-failure.ts b/src/main/sqlite/sqlite-read-failure.ts index 6cdb75f343a..6919bf9e9cb 100644 --- a/src/main/sqlite/sqlite-read-failure.ts +++ b/src/main/sqlite/sqlite-read-failure.ts @@ -6,7 +6,9 @@ /** Primary result codes; extended codes pack the primary code in the low byte. */ const SQLITE_BUSY = 5 const SQLITE_LOCKED = 6 +const SQLITE_CORRUPT = 11 const SQLITE_CANTOPEN = 14 +const SQLITE_NOTADB = 26 // Shared with the Codex index-heal pass, which only ever sees a relayed message // string (app-server RPC drops `errcode`), so message matching is not optional. @@ -20,6 +22,23 @@ function primaryErrcode(error: unknown): number | null { return typeof errcode === 'number' && Number.isFinite(errcode) ? errcode & 0xff : null } +/** node:sqlite marks its errors `ERR_SQLITE_ERROR`; Bun names its class `SQLiteError`. */ +function isSqliteDriverError(error: unknown): boolean { + return ( + error instanceof Error && + (('code' in error && error.code === 'ERR_SQLITE_ERROR') || error.name === 'SQLiteError') + ) +} + +/** True only when SQLite itself reports the database damaged or not a database at all. */ +export function isSqliteCorruption(error: unknown): boolean { + if (!isSqliteDriverError(error)) { + return false + } + const errcode = primaryErrcode(error) + return errcode === SQLITE_CORRUPT || errcode === SQLITE_NOTADB +} + function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error) } diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts index c4cbe3fc392..260c910d01e 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts @@ -1,11 +1,11 @@ import { afterEach, describe, expect, it } from 'vitest' import { i18n } from '@/i18n/i18n' import en from '@/i18n/locales/en.json' +import { agentSessionWriteNoticeEnglish } from '../../../../shared/agent-session-refusal-notice' import { AGENT_SESSION_WRITE_NOTICE_COPY, - agentSessionWriteNoticeEnglish, type AgentSessionWriteNoticeSentence -} from '../../../../shared/agent-session-refusal-notice' +} from '../../../../shared/agent-session-write-notice-copy' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' const SENTENCES = Object.keys(AGENT_SESSION_WRITE_NOTICE_COPY).filter( diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index b8d4b919db8..812197d6e33 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -2,16 +2,20 @@ // shared English as its fallback so desktop and mobile never say it differently. import { translate } from '@/i18n/i18n' +import { agentSessionWriteNoticeParts } from '../../../../shared/agent-session-refusal-notice' import { AGENT_SESSION_WRITE_NOTICE_COPY as COPY, - agentSessionWriteNoticeParts, - type AgentSessionWriteFailure, - type AgentSessionWriteKind, type AgentSessionWriteNoticePart, type AgentSessionWriteNoticeSentence -} from '../../../../shared/agent-session-refusal-notice' +} from '../../../../shared/agent-session-write-notice-copy' +import type { + AgentSessionWriteFailure, + AgentSessionWriteKind +} from '../../../../shared/agent-session-write-failure' const SENTENCES: Record string> = { + notDoneReadHistory: () => + translate('components.native-chat.writeNotice.notDoneReadHistory', COPY.notDoneReadHistory), notDoneSend: () => translate('components.native-chat.writeNotice.notDoneSend', COPY.notDoneSend), tryAgainComposerSend: () => translate('components.native-chat.writeNotice.tryAgainComposerSend', COPY.tryAgainComposerSend), @@ -36,8 +40,76 @@ const SENTENCES: Record string> = { translate('components.native-chat.writeNotice.questionChanged', COPY.questionChanged), historyUnreadable: () => translate('components.native-chat.writeNotice.historyUnreadable', COPY.historyUnreadable), + historyUnusable: () => + translate('components.native-chat.writeNotice.historyUnusable', COPY.historyUnusable), + historyUnavailable: () => + translate('components.native-chat.writeNotice.historyUnavailable', COPY.historyUnavailable), unsupported: () => translate('components.native-chat.writeNotice.unsupported', COPY.unsupported), - unreachable: () => translate('components.native-chat.writeNotice.unreachable', COPY.unreachable) + unreachable: () => translate('components.native-chat.writeNotice.unreachable', COPY.unreachable), + recordFailed: () => + translate('components.native-chat.writeNotice.recordFailed', COPY.recordFailed), + conversationCleared: () => + translate('components.native-chat.writeNotice.conversationCleared', COPY.conversationCleared), + openCurrentConversation: () => + translate( + 'components.native-chat.writeNotice.openCurrentConversation', + COPY.openCurrentConversation + ), + clearUnfinished: () => + translate('components.native-chat.writeNotice.clearUnfinished', COPY.clearUnfinished), + commandRunning: () => + translate('components.native-chat.writeNotice.commandRunning', COPY.commandRunning), + waitForCommand: () => + translate('components.native-chat.writeNotice.waitForCommand', COPY.waitForCommand), + agentStarting: () => + translate('components.native-chat.writeNotice.agentStarting', COPY.agentStarting), + waitForStart: () => + translate('components.native-chat.writeNotice.waitForStart', COPY.waitForStart), + turnActive: () => translate('components.native-chat.writeNotice.turnActive', COPY.turnActive), + waitForTurn: () => translate('components.native-chat.writeNotice.waitForTurn', COPY.waitForTurn), + promptPending: () => + translate('components.native-chat.writeNotice.promptPending', COPY.promptPending), + answerFirst: () => translate('components.native-chat.writeNotice.answerFirst', COPY.answerFirst), + backgroundTasksRunning: () => + translate( + 'components.native-chat.writeNotice.backgroundTasksRunning', + COPY.backgroundTasksRunning + ), + waitForBackgroundTasks: () => + translate( + 'components.native-chat.writeNotice.waitForBackgroundTasks', + COPY.waitForBackgroundTasks + ), + messagesUnsettled: () => + translate('components.native-chat.writeNotice.messagesUnsettled', COPY.messagesUnsettled), + settleEarlierMessage: () => + translate('components.native-chat.writeNotice.settleEarlierMessage', COPY.settleEarlierMessage), + optionRejected: () => + translate('components.native-chat.writeNotice.optionRejected', COPY.optionRejected), + goalsUnsupported: () => + translate('components.native-chat.writeNotice.goalsUnsupported', COPY.goalsUnsupported), + agentRefused: () => + translate('components.native-chat.writeNotice.agentRefused', COPY.agentRefused), + ownerUnproven: () => + translate('components.native-chat.writeNotice.ownerUnproven', COPY.ownerUnproven), + reopenChat: () => translate('components.native-chat.writeNotice.reopenChat', COPY.reopenChat), + terminalAgentHoldsChat: () => + translate( + 'components.native-chat.writeNotice.terminalAgentHoldsChat', + COPY.terminalAgentHoldsChat + ), + quitTerminalAgent: () => + translate('components.native-chat.writeNotice.quitTerminalAgent', COPY.quitTerminalAgent), + hostReconciling: () => + translate('components.native-chat.writeNotice.hostReconciling', COPY.hostReconciling), + waitMoment: () => translate('components.native-chat.writeNotice.waitMoment', COPY.waitMoment), + recordUnreadable: () => + translate('components.native-chat.writeNotice.recordUnreadable', COPY.recordUnreadable), + chatNotFound: () => + translate('components.native-chat.writeNotice.chatNotFound', COPY.chatNotFound), + startNewChat: () => + translate('components.native-chat.writeNotice.startNewChat', COPY.startNewChat), + tryAgain: () => translate('components.native-chat.writeNotice.tryAgain', COPY.tryAgain) } export function agentSessionWriteNoticeText(parts: readonly AgentSessionWriteNoticePart[]): string { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts index da60412183f..9b8b3c537a6 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts @@ -115,7 +115,18 @@ describe("a saved message's last failure", () => { it.each([ { kind: 'refused', code: 'agent_session_checkpoint_stale' }, + { kind: 'refused', code: 'agent_session_conflict', details: { reason: 'chatStarting' } }, + { + kind: 'refused', + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven', ownerVerdict: 'unverifiable' } + }, { kind: 'rejected', reason: 'Claude messages support at most 20 images' }, + { + kind: 'rejected', + reason: 'Claude accepts at most 20 images in one message, so this message was not sent.', + rejection: { kind: 'attachmentInvalid', attachment: { reason: 'tooMany', limit: 20 } } + }, { kind: 'rejected', reason: null }, { kind: 'failed' } ])('reads back $kind as it was saved', (lastFailure) => { @@ -137,4 +148,61 @@ describe("a saved message's last failure", () => { expect(read.map((saved) => saved.clientMessageId)).toEqual(['client-1', 'client-2']) expect(read.every((saved) => saved.lastFailure === undefined)).toBe(true) }) + + // Written by a build that kept only the code or the reason; it reads as that build meant it. + it('reads an entry saved before failures carried details as it was', () => { + localStorage.setItem( + 'orca:desktopStructuredAgentSessionOutbox:v1:session-a', + JSON.stringify([ + { + ...entry('session-a', 'client-1'), + lastFailure: { kind: 'refused', code: 'agent_session_checkpoint_stale' } + }, + { ...entry('session-a', 'client-2'), lastFailure: { kind: 'rejected', reason: 'Nope.' } } + ]) + ) + + expect(readOutbox('session-a').map((saved) => saved.lastFailure)).toEqual([ + { kind: 'refused', code: 'agent_session_checkpoint_stale' }, + { kind: 'rejected', reason: 'Nope.' } + ]) + }) + + it('keeps only what a newer build saved that this one can place', () => { + localStorage.setItem( + 'orca:desktopStructuredAgentSessionOutbox:v1:session-a', + JSON.stringify([ + { + ...entry('session-a', 'client-1'), + lastFailure: { + kind: 'refused', + code: 'agent_session_checkpoint_stale', + details: { reason: 'fromTheFuture', currentFence: 4 } + } + }, + { + ...entry('session-a', 'client-2'), + lastFailure: { + kind: 'rejected', + reason: 'Claude stopped before it finished starting.', + rejection: { kind: 'providerStartFailed', detail: { text: 'exit 1', audience: 'log' } } + } + }, + { + ...entry('session-a', 'client-3'), + lastFailure: { kind: 'rejected', reason: 'Nope.', rejection: { kind: 'fromTheFuture' } } + } + ]) + ) + + expect(readOutbox('session-a').map((saved) => saved.lastFailure)).toEqual([ + { kind: 'refused', code: 'agent_session_checkpoint_stale' }, + { + kind: 'rejected', + reason: 'Claude stopped before it finished starting.', + rejection: { kind: 'providerStartFailed' } + }, + { kind: 'rejected', reason: 'Nope.' } + ]) + }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts index 5d20ce6a110..328d76d0534 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts @@ -14,7 +14,7 @@ import { agentSessionRefusalFailure, agentSessionRpcErrorFailure, agentSessionWriteKindForMethod as writeKind -} from '../../../../shared/agent-session-refusal-notice' +} from '../../../../shared/agent-session-write-failure' import { agentSessionRefusalOperationState } from '../../../../shared/agent-session-refusal-retry' import { structuredAgentSessionPayloadFingerprint } from '../../../../shared/structured-agent-session-mutation' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx index ac09dde1da8..0336e81401a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx @@ -147,4 +147,47 @@ describe('a chat write the host refused', () => { expect(mocks.toastError).not.toHaveBeenCalled() expect(result.current.error).toBeNull() }) + + it('says the reason the host named, for a Stop and for a command', async () => { + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.options' + ? Promise.resolve(OPTIONS) + : Promise.resolve({ + ok: false, + refusal: + method === 'agentSession.cancel' + ? { + code: 'agent_session_conflict', + message: 'The chat is still starting.', + details: { reason: 'chatStarting' } + } + : { + code: 'agent_session_operation_invalid', + message: 'Wait for the current turn to finish before using this command.', + details: { reason: 'turnActive' } + } + }) + ) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'claude', + isVisible: true + }) + ) + + await act(async () => { + await expect(result.current.cancel('turn-1')).resolves.toBeNull() + await expect(result.current.runConversationCommand('compact')).resolves.toEqual({ + accepted: false, + error: + "The agent is still responding. The command didn't run. Wait for the agent to finish responding, or stop it." + }) + }) + + expect(mocks.toastError).toHaveBeenCalledWith( + "The agent is still starting. The agent wasn't stopped. Wait for the agent to finish starting." + ) + }) }) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index a7ae87bb1af..1e6ca9ecd65 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2715,22 +2715,54 @@ "usedOneSummary": "Used 1 tool" }, "writeNotice": { + "agentRefused": "The agent turned this down.", + "agentStarting": "The agent is still starting.", + "answerFirst": "Answer the question or approval first.", + "backgroundTasksRunning": "Background tasks are still running.", "capacity": "Orca has received too many requests in the last day.", + "chatNotFound": "The Orca running this chat has no record of it.", + "clearUnfinished": "The last /clear didn't finish.", + "commandRunning": "A /compact or /clear is still running.", + "conversationCleared": "This conversation has been cleared.", + "goalsUnsupported": "This agent doesn't support goals.", + "historyUnavailable": "Orca couldn't open this chat's history right now.", "historyUnreadable": "Orca couldn't read this chat's saved history.", + "historyUnusable": "Unable to load this chat.", + "hostReconciling": "Orca is still checking on this chat after restarting.", + "messagesUnsettled": "A message you sent earlier hasn't been confirmed yet.", "notDoneAnswer": "Your answer was not sent.", "notDoneCommand": "The command didn't run.", "notDoneGoal": "The goal wasn't changed.", "notDoneOption": "The setting wasn't changed.", + "notDoneReadHistory": "This chat's history couldn't be loaded.", "notDoneSend": "Your message was not sent.", "notDoneStop": "The agent wasn't stopped.", "notDoneStopTask": "The background task wasn't stopped.", "notDoneStopTasks": "The background tasks weren't stopped.", + "openCurrentConversation": "Open the current conversation to continue.", + "optionRejected": "The agent didn't accept this setting.", "outcomeUnknown": "Orca couldn't confirm what happened. Check the chat.", + "ownerUnproven": "Orca hasn't confirmed that this chat's previous agent stopped.", + "promptPending": "The agent is waiting for an answer to a question or approval.", "questionChanged": "This question was already answered or has changed.", + "quitTerminalAgent": "Quit that agent to continue the chat here.", + "recordFailed": "Orca couldn't record it in this chat's history.", + "recordUnreadable": "Orca couldn't read this chat's saved state.", + "reopenChat": "Reopen the chat to check again.", "restartFailed": "The agent couldn't restart.", + "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", + "startNewChat": "Start a new chat to continue.", + "terminalAgentHoldsChat": "This chat is still open in a terminal agent.", + "tryAgain": "Try again.", "tryAgainComposerSend": "Send it again.", + "turnActive": "The agent is still responding.", "unreachable": "Orca couldn't reach the agent.", - "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again." + "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again.", + "waitForBackgroundTasks": "Wait for the background tasks to finish.", + "waitForCommand": "Wait for the /compact or /clear to finish.", + "waitForStart": "Wait for the agent to finish starting.", + "waitForTurn": "Wait for the agent to finish responding, or stop it.", + "waitMoment": "Wait a moment." } }, "onboarding": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a90c49d9bfe..cc182bd9734 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17477,6 +17477,7 @@ }, "native-chat": { "writeNotice": { + "notDoneReadHistory": "This chat's history couldn't be loaded.", "notDoneSend": "Your message was not sent.", "tryAgainComposerSend": "Send it again.", "notDoneStop": "The agent wasn't stopped.", @@ -17491,8 +17492,39 @@ "outcomeUnknown": "Orca couldn't confirm what happened. Check the chat.", "questionChanged": "This question was already answered or has changed.", "historyUnreadable": "Orca couldn't read this chat's saved history.", + "historyUnusable": "Unable to load this chat.", + "historyUnavailable": "Orca couldn't open this chat's history right now.", "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again.", - "unreachable": "Orca couldn't reach the agent." + "unreachable": "Orca couldn't reach the agent.", + "recordFailed": "Orca couldn't record it in this chat's history.", + "conversationCleared": "This conversation has been cleared.", + "openCurrentConversation": "Open the current conversation to continue.", + "clearUnfinished": "The last /clear didn't finish.", + "commandRunning": "A /compact or /clear is still running.", + "waitForCommand": "Wait for the /compact or /clear to finish.", + "agentStarting": "The agent is still starting.", + "waitForStart": "Wait for the agent to finish starting.", + "turnActive": "The agent is still responding.", + "waitForTurn": "Wait for the agent to finish responding, or stop it.", + "promptPending": "The agent is waiting for an answer to a question or approval.", + "answerFirst": "Answer the question or approval first.", + "backgroundTasksRunning": "Background tasks are still running.", + "waitForBackgroundTasks": "Wait for the background tasks to finish.", + "messagesUnsettled": "A message you sent earlier hasn't been confirmed yet.", + "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", + "optionRejected": "The agent didn't accept this setting.", + "goalsUnsupported": "This agent doesn't support goals.", + "agentRefused": "The agent turned this down.", + "ownerUnproven": "Orca hasn't confirmed that this chat's previous agent stopped.", + "reopenChat": "Reopen the chat to check again.", + "terminalAgentHoldsChat": "This chat is still open in a terminal agent.", + "quitTerminalAgent": "Quit that agent to continue the chat here.", + "hostReconciling": "Orca is still checking on this chat after restarting.", + "waitMoment": "Wait a moment.", + "recordUnreadable": "Orca couldn't read this chat's saved state.", + "chatNotFound": "The Orca running this chat has no record of it.", + "startNewChat": "Start a new chat to continue.", + "tryAgain": "Try again." }, "taskList": { "title": "Tasks", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ef1d9d7ed8d..42417b214f3 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14928,6 +14928,7 @@ "components": { "native-chat": { "writeNotice": { + "notDoneReadHistory": "No se pudo cargar el historial de este chat.", "notDoneSend": "Su mensaje no se envió.", "tryAgainComposerSend": "Vuelva a enviarlo.", "notDoneStop": "El agente no se detuvo.", @@ -14942,8 +14943,39 @@ "outcomeUnknown": "Orca no pudo confirmar qué pasó. Revise el chat.", "questionChanged": "Esta pregunta ya se respondió o ha cambiado.", "historyUnreadable": "Orca no pudo leer el historial guardado de este chat.", + "historyUnusable": "No se puede cargar este chat.", + "historyUnavailable": "Orca no pudo abrir el historial de este chat en este momento.", "unsupported": "La versión de Orca que ejecuta este chat no lo admite. Actualice Orca y vuelva a intentarlo.", - "unreachable": "Orca no pudo comunicarse con el agente." + "unreachable": "Orca no pudo comunicarse con el agente.", + "recordFailed": "Orca no pudo registrarlo en el historial de este chat.", + "conversationCleared": "Esta conversación se borró.", + "openCurrentConversation": "Abra la conversación actual para continuar.", + "clearUnfinished": "El último /clear no terminó.", + "commandRunning": "Todavía se está ejecutando un /compact o /clear.", + "waitForCommand": "Espere a que termine el /compact o /clear.", + "agentStarting": "El agente todavía se está iniciando.", + "waitForStart": "Espere a que el agente termine de iniciarse.", + "turnActive": "El agente todavía está respondiendo.", + "waitForTurn": "Espere a que el agente termine de responder o deténgalo.", + "promptPending": "El agente está esperando una respuesta a una pregunta o aprobación.", + "answerFirst": "Responda primero a la pregunta o aprobación.", + "backgroundTasksRunning": "Todavía hay tareas en segundo plano en ejecución.", + "waitForBackgroundTasks": "Espere a que terminen las tareas en segundo plano.", + "messagesUnsettled": "Un mensaje que envió antes todavía no se ha confirmado.", + "settleEarlierMessage": "Espere a que se entregue su mensaje anterior o vuelva a intentarlo.", + "optionRejected": "El agente no aceptó este ajuste.", + "goalsUnsupported": "Este agente no admite objetivos.", + "agentRefused": "El agente lo rechazó.", + "ownerUnproven": "Orca no ha confirmado que el agente anterior de este chat se haya detenido.", + "reopenChat": "Vuelva a abrir el chat para comprobarlo de nuevo.", + "terminalAgentHoldsChat": "Este chat sigue abierto en un agente de terminal.", + "quitTerminalAgent": "Cierre ese agente para continuar el chat aquí.", + "hostReconciling": "Orca todavía está comprobando este chat después de reiniciarse.", + "waitMoment": "Espere un momento.", + "recordUnreadable": "Orca no pudo leer el estado guardado de este chat.", + "chatNotFound": "La versión de Orca que ejecuta este chat no tiene ningún registro de él.", + "startNewChat": "Inicie un chat nuevo para continuar.", + "tryAgain": "Vuelva a intentarlo." }, "composer": { "imageUnsupported": "Este agente no admite pegar imágenes.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 8cea33b33ef..115bd685b56 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17411,6 +17411,7 @@ "components": { "native-chat": { "writeNotice": { + "notDoneReadHistory": "L'historique de ce chat n'a pas pu être chargé.", "notDoneSend": "Votre message n'a pas été envoyé.", "tryAgainComposerSend": "Renvoyez-le.", "notDoneStop": "L'agent n'a pas été arrêté.", @@ -17425,8 +17426,39 @@ "outcomeUnknown": "Orca n'a pas pu confirmer ce qui s'est passé. Vérifiez le chat.", "questionChanged": "Cette question a déjà reçu une réponse ou a changé.", "historyUnreadable": "Orca n'a pas pu lire l'historique enregistré de ce chat.", + "historyUnusable": "Impossible de charger ce chat.", + "historyUnavailable": "Orca n'a pas pu ouvrir l'historique de ce chat pour le moment.", "unsupported": "L'instance d'Orca qui exécute ce chat ne le prend pas en charge. Mettez Orca à jour, puis réessayez.", - "unreachable": "Orca n'a pas pu joindre l'agent." + "unreachable": "Orca n'a pas pu joindre l'agent.", + "recordFailed": "Orca n'a pas pu l'enregistrer dans l'historique de ce chat.", + "conversationCleared": "Cette conversation a été effacée.", + "openCurrentConversation": "Ouvrez la conversation actuelle pour continuer.", + "clearUnfinished": "Le dernier /clear ne s'est pas terminé.", + "commandRunning": "Un /compact ou /clear est encore en cours.", + "waitForCommand": "Attendez la fin du /compact ou /clear.", + "agentStarting": "L'agent est encore en cours de démarrage.", + "waitForStart": "Attendez que l'agent ait fini de démarrer.", + "turnActive": "L'agent est encore en train de répondre.", + "waitForTurn": "Attendez que l'agent ait fini de répondre, ou arrêtez-le.", + "promptPending": "L'agent attend une réponse à une question ou à une demande d'approbation.", + "answerFirst": "Répondez d'abord à la question ou à la demande d'approbation.", + "backgroundTasksRunning": "Des tâches en arrière-plan sont encore en cours.", + "waitForBackgroundTasks": "Attendez la fin des tâches en arrière-plan.", + "messagesUnsettled": "Un message que vous avez envoyé plus tôt n'a pas encore été confirmé.", + "settleEarlierMessage": "Attendez que votre message précédent soit transmis, ou renvoyez-le.", + "optionRejected": "L'agent n'a pas accepté ce réglage.", + "goalsUnsupported": "Cet agent ne prend pas en charge les objectifs.", + "agentRefused": "L'agent l'a refusé.", + "ownerUnproven": "Orca n'a pas confirmé l'arrêt de l'agent précédent de ce chat.", + "reopenChat": "Rouvrez le chat pour vérifier à nouveau.", + "terminalAgentHoldsChat": "Ce chat est encore ouvert dans un agent de terminal.", + "quitTerminalAgent": "Quittez cet agent pour continuer le chat ici.", + "hostReconciling": "Orca vérifie encore ce chat après son redémarrage.", + "waitMoment": "Patientez un instant.", + "recordUnreadable": "Orca n'a pas pu lire l'état enregistré de ce chat.", + "chatNotFound": "L'instance d'Orca qui exécute ce chat n'en a aucune trace.", + "startNewChat": "Démarrez un nouveau chat pour continuer.", + "tryAgain": "Réessayez." }, "composer": { "imageUnsupported": "Le collage d'image n'est pas pris en charge pour cet agent.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 0594851d010..fc54f4c97d5 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17347,6 +17347,7 @@ }, "native-chat": { "writeNotice": { + "notDoneReadHistory": "このチャットの履歴を読み込めませんでした。", "notDoneSend": "メッセージは送信されませんでした。", "tryAgainComposerSend": "もう一度送信してください。", "notDoneStop": "Agent は停止されませんでした。", @@ -17361,8 +17362,39 @@ "outcomeUnknown": "Orca は何が起きたかを確認できませんでした。チャットを確認してください。", "questionChanged": "この質問はすでに回答済みか、変更されています。", "historyUnreadable": "Orca はこのチャットの保存済み履歴を読み取れませんでした。", + "historyUnusable": "このチャットを読み込めません。", + "historyUnavailable": "Orca は現在このチャットの履歴を開けませんでした。", "unsupported": "このチャットを実行している Orca はこの操作に対応していません。Orca を更新してから、もう一度お試しください。", - "unreachable": "Orca は Agent に接続できませんでした。" + "unreachable": "Orca は Agent に接続できませんでした。", + "recordFailed": "Orca はこのチャットの履歴に記録できませんでした。", + "conversationCleared": "この会話はクリアされました。", + "openCurrentConversation": "続けるには、現在の会話を開いてください。", + "clearUnfinished": "前回の /clear は完了しませんでした。", + "commandRunning": "/compact または /clear がまだ実行中です。", + "waitForCommand": "/compact または /clear が終わるまでお待ちください。", + "agentStarting": "Agent はまだ起動中です。", + "waitForStart": "Agent の起動が終わるまでお待ちください。", + "turnActive": "Agent はまだ応答中です。", + "waitForTurn": "Agent の応答が終わるまで待つか、Agent を停止してください。", + "promptPending": "Agent は質問または承認への回答を待っています。", + "answerFirst": "先に質問または承認に回答してください。", + "backgroundTasksRunning": "バックグラウンドタスクがまだ実行中です。", + "waitForBackgroundTasks": "バックグラウンドタスクが終わるまでお待ちください。", + "messagesUnsettled": "以前に送信したメッセージがまだ確認されていません。", + "settleEarlierMessage": "以前のメッセージが届くまで待つか、再試行してください。", + "optionRejected": "Agent はこの設定を受け入れませんでした。", + "goalsUnsupported": "この Agent は目標に対応していません。", + "agentRefused": "Agent がこれを拒否しました。", + "ownerUnproven": "Orca は、このチャットの以前の Agent が停止したことをまだ確認できていません。", + "reopenChat": "もう一度確認するには、チャットを開き直してください。", + "terminalAgentHoldsChat": "このチャットはまだターミナルの Agent で開かれています。", + "quitTerminalAgent": "ここでチャットを続けるには、その Agent を終了してください。", + "hostReconciling": "Orca は再起動後、このチャットをまだ確認しています。", + "waitMoment": "しばらくお待ちください。", + "recordUnreadable": "Orca はこのチャットの保存済みの状態を読み取れませんでした。", + "chatNotFound": "このチャットを実行している Orca には、このチャットの記録がありません。", + "startNewChat": "続けるには、新しいチャットを開始してください。", + "tryAgain": "もう一度お試しください。" }, "composer": { "imageUnsupported": "この Agent では画像の貼り付けはサポートされていません。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ef64252a6f2..23bddadf139 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17347,6 +17347,7 @@ }, "native-chat": { "writeNotice": { + "notDoneReadHistory": "이 채팅의 기록을 불러오지 못했습니다.", "notDoneSend": "메시지가 전송되지 않았습니다.", "tryAgainComposerSend": "다시 보내세요.", "notDoneStop": "에이전트가 중지되지 않았습니다.", @@ -17361,8 +17362,39 @@ "outcomeUnknown": "Orca가 무슨 일이 있었는지 확인하지 못했습니다. 채팅을 확인하세요.", "questionChanged": "이 질문은 이미 답변되었거나 변경되었습니다.", "historyUnreadable": "Orca가 이 채팅의 저장된 기록을 읽지 못했습니다.", + "historyUnusable": "이 채팅을 불러올 수 없습니다.", + "historyUnavailable": "Orca가 지금 이 채팅의 기록을 열지 못했습니다.", "unsupported": "이 채팅을 실행 중인 Orca가 이 기능을 지원하지 않습니다. Orca를 업데이트한 후 다시 시도하세요.", - "unreachable": "Orca가 에이전트에 연결하지 못했습니다." + "unreachable": "Orca가 에이전트에 연결하지 못했습니다.", + "recordFailed": "Orca가 이 채팅의 기록에 저장하지 못했습니다.", + "conversationCleared": "이 대화는 지워졌습니다.", + "openCurrentConversation": "계속하려면 현재 대화를 여세요.", + "clearUnfinished": "마지막 /clear가 완료되지 않았습니다.", + "commandRunning": "/compact 또는 /clear가 아직 실행 중입니다.", + "waitForCommand": "/compact 또는 /clear가 끝날 때까지 기다리세요.", + "agentStarting": "에이전트가 아직 시작 중입니다.", + "waitForStart": "에이전트가 시작을 마칠 때까지 기다리세요.", + "turnActive": "에이전트가 아직 응답 중입니다.", + "waitForTurn": "에이전트가 응답을 마칠 때까지 기다리거나 에이전트를 중지하세요.", + "promptPending": "에이전트가 질문 또는 승인에 대한 응답을 기다리고 있습니다.", + "answerFirst": "먼저 질문 또는 승인에 응답하세요.", + "backgroundTasksRunning": "백그라운드 작업이 아직 실행 중입니다.", + "waitForBackgroundTasks": "백그라운드 작업이 끝날 때까지 기다리세요.", + "messagesUnsettled": "앞서 보낸 메시지가 아직 확인되지 않았습니다.", + "settleEarlierMessage": "앞서 보낸 메시지가 전달될 때까지 기다리거나 다시 시도하세요.", + "optionRejected": "에이전트가 이 설정을 받아들이지 않았습니다.", + "goalsUnsupported": "이 에이전트는 목표를 지원하지 않습니다.", + "agentRefused": "에이전트가 이를 거부했습니다.", + "ownerUnproven": "Orca가 이 채팅의 이전 에이전트가 중지되었는지 아직 확인하지 못했습니다.", + "reopenChat": "다시 확인하려면 채팅을 다시 여세요.", + "terminalAgentHoldsChat": "이 채팅은 아직 터미널 에이전트에서 열려 있습니다.", + "quitTerminalAgent": "여기에서 채팅을 계속하려면 해당 에이전트를 종료하세요.", + "hostReconciling": "Orca가 다시 시작된 후 이 채팅을 아직 확인하고 있습니다.", + "waitMoment": "잠시 기다리세요.", + "recordUnreadable": "Orca가 이 채팅의 저장된 상태를 읽지 못했습니다.", + "chatNotFound": "이 채팅을 실행하는 Orca에 이 채팅의 기록이 없습니다.", + "startNewChat": "계속하려면 새 채팅을 시작하세요.", + "tryAgain": "다시 시도하세요." }, "composer": { "imageUnsupported": "이 에이전트는 이미지 붙여넣기를 지원하지 않습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0b269ce3b62..1356f6b8cf6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17312,6 +17312,7 @@ "components": { "native-chat": { "writeNotice": { + "notDoneReadHistory": "无法加载此聊天的历史记录。", "notDoneSend": "您的消息未发送。", "tryAgainComposerSend": "请重新发送。", "notDoneStop": "智能体未停止。", @@ -17326,8 +17327,39 @@ "outcomeUnknown": "Orca 无法确认发生了什么。请查看聊天。", "questionChanged": "此问题已被回答或已更改。", "historyUnreadable": "Orca 无法读取此聊天的已保存历史记录。", + "historyUnusable": "无法加载此聊天。", + "historyUnavailable": "Orca 目前无法打开此聊天的历史记录。", "unsupported": "运行此聊天的 Orca 不支持此操作。请更新 Orca 后重试。", - "unreachable": "Orca 无法连接到智能体。" + "unreachable": "Orca 无法连接到智能体。", + "recordFailed": "Orca 无法将其记录到此聊天的历史记录中。", + "conversationCleared": "此对话已被清除。", + "openCurrentConversation": "请打开当前对话以继续。", + "clearUnfinished": "上一次 /clear 未完成。", + "commandRunning": "/compact 或 /clear 仍在运行。", + "waitForCommand": "请等待 /compact 或 /clear 完成。", + "agentStarting": "智能体仍在启动。", + "waitForStart": "请等待智能体完成启动。", + "turnActive": "智能体仍在回复。", + "waitForTurn": "请等待智能体完成回复,或将其停止。", + "promptPending": "智能体正在等待您回答问题或审批。", + "answerFirst": "请先回答问题或审批。", + "backgroundTasksRunning": "后台任务仍在运行。", + "waitForBackgroundTasks": "请等待后台任务完成。", + "messagesUnsettled": "您之前发送的一条消息尚未确认。", + "settleEarlierMessage": "请等待之前的消息送达,或重试该消息。", + "optionRejected": "智能体未接受此设置。", + "goalsUnsupported": "此智能体不支持目标。", + "agentRefused": "智能体拒绝了此请求。", + "ownerUnproven": "Orca 尚未确认此聊天之前的智能体已停止。", + "reopenChat": "请重新打开聊天以再次检查。", + "terminalAgentHoldsChat": "此聊天仍在终端智能体中打开。", + "quitTerminalAgent": "请退出该智能体,以便在此处继续聊天。", + "hostReconciling": "Orca 重启后仍在检查此聊天。", + "waitMoment": "请稍候。", + "recordUnreadable": "Orca 无法读取此聊天的已保存状态。", + "chatNotFound": "运行此聊天的 Orca 没有此聊天的记录。", + "startNewChat": "请开始新的聊天以继续。", + "tryAgain": "请重试。" }, "composer": { "imageUnsupported": "此智能体不支持粘贴图片。", diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index 36754757504..091e7543f88 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -25,7 +25,7 @@ import { agentSessionRefusalFailure, agentSessionRpcErrorFailure, type AgentSessionWriteFailure -} from '../../../shared/agent-session-refusal-notice' +} from '../../../shared/agent-session-write-failure' /** The options a launch starts with, replaced whole so readers can compare by identity. */ export type StructuredLaunchSelection = { diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 20b95a89b69..84c8fa6e191 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -8,6 +8,7 @@ import { structuredAgentSessionSendRequest, type StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' +import { agentSessionRefusalFailure } from '../../../shared/agent-session-write-failure' import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation' import { mutateStructuredAgentSessionLaunchPrompt, @@ -111,7 +112,7 @@ async function dispatchStructuredLaunchPrompt( mutateEntry(entry, (current) => requeueStructuredAgentSessionSendRefusal( current, - result.refusal.code, + agentSessionRefusalFailure(result.refusal), () => createStructuredAgentSessionOperationId(createBrowserUuid), entry.lastAttemptAt !== null ) diff --git a/src/shared/agent-session-conversation-command.ts b/src/shared/agent-session-conversation-command.ts index e1c038de934..8b5ecb6ccf0 100644 --- a/src/shared/agent-session-conversation-command.ts +++ b/src/shared/agent-session-conversation-command.ts @@ -1,11 +1,20 @@ +import type { AgentSessionFailureFact } from './agent-session-failure' +import type { AgentSessionFailureSentence } from './agent-session-failure-words' + export type AgentSessionConversationCommand = 'clear' | 'compact' export type AgentSessionConversationCommandResult = { command: AgentSessionConversationCommand state: 'completed' | 'unknown' replacementSessionId?: string - error?: string -} +} & + /** `error`: a sentence for a person; released clients print it as it is. */ + ( + | { error?: string; failure?: undefined } + /** What failed, typed, beside its sentence, both from `agentSessionFailureWords`; `failure` is + * absent from older hosts. */ + | { error: AgentSessionFailureSentence; failure: AgentSessionFailureFact } + ) export type AgentSessionConversationCommandRecord = AgentSessionConversationCommandResult & { runtimeFence?: number diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts new file mode 100644 index 00000000000..859d3782cc8 --- /dev/null +++ b/src/shared/agent-session-failure-words.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS, + AGENT_SESSION_FAILURE_KINDS, + isSubmissionRejectionFact, + readAgentSessionFailureFact, + type AgentSessionFailureFact, + type AgentSessionFailureKind, + type SubmissionRejectionKind +} from './agent-session-failure' +import { + agentSessionFailureSentence, + agentSessionFailureWords, + type AgentSessionFailureSurface +} from './agent-session-failure-words' +import { + classifyDispatchRejection, + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_CODEX_QUEUE_FULL, + DISPATCH_REJECTED_QUEUE_FULL, + DISPATCH_REJECTED_WRITE_FAILED +} from './structured-agent-session-dispatch-rejection' + +/** What Orca's own text looks like: a code or marker, a uuid, a path, an exception. */ +const ORCA_INTERNAL = /\b[a-z]+_[a-z_]+\b|[0-9a-f]{8}-[0-9a-f]{4}-|[/\\][\w.-]+[/\\]|Error:|ENOENT/ + +const SURFACES: readonly AgentSessionFailureSurface[] = ['row', 'rejection'] +const LEGACY_MARKER_KINDS: ReadonlySet = new Set([ + 'cancelled', + 'writeFailed', + 'queueFull' +]) + +/** Every fact shape a kind can carry: bare, with a person or a log detail, with a refusal, and + * for an attachment each reason with and without its limit. */ +function factsFor(kind: AgentSessionFailureKind): AgentSessionFailureFact[] { + const facts: AgentSessionFailureFact[] = [ + { kind }, + { kind, detail: { text: 'Context window exceeded.', audience: 'person' } }, + { + kind, + // A log detail and a refusal ride with the fact; neither may reach the sentence. + detail: { text: 'Error: ENOENT /Users/me/.claude/x agent_session_conflict', audience: 'log' }, + refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } } + } + ] + if (kind === 'providerRetrying') { + facts.push({ kind, retry: { error: 'rate_limit', status: 429 } }) + facts.push({ kind, retry: { error: 'overloaded', status: 529 } }) + facts.push({ kind, retry: { status: 500 } }) + } + if (kind === 'attachmentInvalid') { + for (const reason of AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS) { + facts.push({ kind, attachment: { reason } }) + facts.push({ kind, attachment: { reason, limit: 5 * 1024 * 1024 } }) + } + } + return facts +} + +describe('the words written beside a failure fact', () => { + describe.each(SURFACES)('on the %s surface', (surface) => { + it.each(AGENT_SESSION_FAILURE_KINDS)('for %s are one sentence a person can read', (kind) => { + for (const fact of factsFor(kind)) { + for (const [agentName, command] of [ + ['Claude', undefined], + [undefined, undefined], + ['Codex', 'clear'] + ] as const) { + const sentence = agentSessionFailureSentence(fact, surface, { agentName, command }) + expect([fact, sentence]).toEqual([fact, expect.stringMatching(/[^.]\.$/)]) + expect(sentence).not.toMatch(/\.\./) + expect(sentence).not.toMatch(ORCA_INTERNAL) + const context = { agentName, command, provider: 'claude' } as const + if (surface === 'row') { + expect(agentSessionFailureWords(fact, { ...context, surface }).text).toBe(sentence) + } else if (isSubmissionRejectionFact(fact) && !LEGACY_MARKER_KINDS.has(fact.kind)) { + expect(agentSessionFailureWords(fact, { ...context, surface }).reason).toBe(sentence) + } + } + } + }) + }) + + it('writes the legacy markers released clients hide, byte for byte, on a rejection', () => { + const reason = (kind: SubmissionRejectionKind, provider?: 'claude' | 'codex') => + agentSessionFailureWords({ kind }, { surface: 'rejection', provider }).reason + expect(reason('cancelled')).toBe(DISPATCH_REJECTED_CANCELLED) + expect(DISPATCH_REJECTED_CANCELLED).toBe('provider_cancelled_before_start') + expect(reason('queueFull', 'claude')).toBe(DISPATCH_REJECTED_QUEUE_FULL) + expect(DISPATCH_REJECTED_QUEUE_FULL).toBe('claude structured dispatch queue is full') + expect(reason('queueFull', 'codex')).toBe(DISPATCH_REJECTED_CODEX_QUEUE_FULL) + expect(DISPATCH_REJECTED_CODEX_QUEUE_FULL).toBe('codex structured dispatch queue is full') + // Bare: the error that once followed it belongs in the log. + expect(reason('writeFailed')).toBe(DISPATCH_REJECTED_WRITE_FAILED) + expect(DISPATCH_REJECTED_WRITE_FAILED).toBe('provider_write_failed') + // With no provider to name its marker, a full queue gets a sentence. + expect(reason('queueFull')).toBe( + 'Too many messages were waiting for the agent, so this one was not sent.' + ) + for (const kind of LEGACY_MARKER_KINDS) { + const written = agentSessionFailureWords( + { kind }, + { surface: 'rejection', provider: 'codex' } + ) + expect(classifyDispatchRejection({ reason: written.reason })).toMatchObject({ kind }) + } + }) + + it('names /clear as the next step for the start a /clear needed', () => { + const clear = (kind: AgentSessionFailureKind) => + agentSessionFailureSentence({ kind }, 'row', { agentName: 'Codex', command: 'clear' }) + expect(clear('notSignedIn')).toBe( + 'Codex is not signed in for the selected account. Sign in, then run /clear again.' + ) + expect(clear('startFailed')).toBe("Codex couldn't start. Run /clear again.") + expect(clear('restartFailed')).toBe("Codex couldn't restart. Run /clear again.") + expect(clear('providerStartFailed')).toBe( + 'Codex stopped before it finished starting. Run /clear again.' + ) + }) + + it('names the agent that stopped starting, and a failed start or restart gives a next step', () => { + const sentence = (kind: AgentSessionFailureKind, agentName?: string) => + agentSessionFailureSentence({ kind }, 'rejection', { agentName }) + expect(sentence('providerStartFailed', 'Claude')).toBe( + 'Claude stopped before it finished starting. Send your message to try again.' + ) + expect(sentence('providerStartFailed')).toBe( + 'The agent stopped before it finished starting. Send your message to try again.' + ) + expect(sentence('startFailed', 'Codex')).toBe( + "Codex couldn't start. Send your message to try again." + ) + expect(sentence('restartFailed')).toBe( + "The agent couldn't restart. Send your message to try again." + ) + // Nothing to restart from: a new chat is the only step, so no retry is offered. + expect( + agentSessionFailureSentence( + { + kind: 'startFailed', + refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } } + }, + 'row', + { agentName: 'Codex' } + ) + ).toBe("Codex couldn't start. Start a new chat to continue.") + }) + + it('names the exit a row reports differently from the message it left unsent', () => { + expect( + agentSessionFailureWords({ kind: 'providerExited' }, { surface: 'row', agentName: 'Claude' }) + .text + ).toBe( + 'Claude stopped while this response was in progress. You can continue in this conversation.' + ) + expect( + agentSessionFailureWords( + { kind: 'providerExited' }, + { surface: 'rejection', agentName: 'Codex' } + ).reason + ).toBe('Codex stopped before this message was sent.') + expect(agentSessionFailureWords({ kind: 'providerExited' }, { surface: 'row' }).text).toBe( + 'The agent stopped while this response was in progress. You can continue in this conversation.' + ) + expect( + agentSessionFailureWords({ kind: 'providerExited' }, { surface: 'rejection' }).reason + ).toBe('The agent stopped before this message was sent.') + }) + + it('says what frees a chat a terminal agent still holds, without naming its process', () => { + expect( + agentSessionFailureSentence( + { + kind: 'restartFailed', + refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } + }, + 'row', + { agentName: 'Codex' } + ) + ).toBe( + "Codex couldn't restart. This chat is still open in a terminal agent. Quit that agent to continue the chat here." + ) + }) + + it('says which limit an attachment broke, in megabytes', () => { + const sentence = (attachment: AgentSessionFailureFact['attachment']) => + agentSessionFailureSentence({ kind: 'attachmentInvalid', attachment }, 'rejection', { + agentName: 'Claude' + }) + expect(sentence({ reason: 'tooLarge', limit: 5 * 1024 * 1024 })).toBe( + 'An image on this message is larger than 5 MB, so the message was not sent.' + ) + expect(sentence({ reason: 'totalTooLarge', limit: 20 * 1024 * 1024 })).toBe( + 'The images on this message add up to more than 20 MB, so the message was not sent.' + ) + expect(sentence({ reason: 'tooMany', limit: 20 })).toBe( + 'Claude accepts at most 20 images in one message, so this message was not sent.' + ) + expect(sentence(undefined)).toBe("An attachment on this message can't be sent to the agent.") + }) +}) + +describe('an attachment problem as a reader meets it', () => { + it('keeps what it can place and drops what it cannot', () => { + expect( + readAgentSessionFailureFact({ + kind: 'attachmentInvalid', + attachment: { reason: 'tooMany', limit: 20 } + }) + ).toEqual({ kind: 'attachmentInvalid', attachment: { reason: 'tooMany', limit: 20 } }) + expect( + readAgentSessionFailureFact({ + kind: 'attachmentInvalid', + attachment: { reason: 'tooMany', limit: 'twenty' } + }) + ).toEqual({ kind: 'attachmentInvalid', attachment: { reason: 'tooMany' } }) + // A reason a newer host added reads as the generic attachment failure. + expect( + readAgentSessionFailureFact({ + kind: 'attachmentInvalid', + attachment: { reason: 'futureReason' } + }) + ).toEqual({ kind: 'attachmentInvalid' }) + }) +}) diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts new file mode 100644 index 00000000000..f02d02abdea --- /dev/null +++ b/src/shared/agent-session-failure-words.ts @@ -0,0 +1,262 @@ +// The sentence a person reads beside each failure fact, and the one constructor that writes both. +// +// A row's `text`, a rejected message's `reason` and a conversation command's `error` are what +// every released client prints as they are, so the host writes them here, from the fact, and +// nowhere else: never Orca's own error text, a refusal's message, or a probe's evidence. A +// provider's words reach the sentence only when the provider wrote them for a person. The table +// is also the English default for a client that chooses its own copy from the fact. + +import { + isSubmissionRejectionFact, + type AgentSessionAttachmentProblem, + type AgentSessionAttachmentProblemReason, + type AgentSessionFailureFact, + type AgentSessionFailureKind, + type ProviderDiagnostic, + type SubmissionRejectionFact, + type SubmissionRejectionKind +} from './agent-session-failure' +import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals' +import { + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_CODEX_QUEUE_FULL, + DISPATCH_REJECTED_QUEUE_FULL, + DISPATCH_REJECTED_WRITE_FAILED +} from './structured-agent-session-dispatch-rejection' + +declare const failureSentence: unique symbol + +/** Words only `agentSessionFailureWords` makes, so no writer can put its own beside a fact. */ +export type AgentSessionFailureSentence = string & { readonly [failureSentence]: true } + +/** A status row that reports a failure. */ +export type AgentSessionFailureRowWords = { + text: AgentSessionFailureSentence + failure: AgentSessionFailureFact +} + +/** A rejection as the host writes it: the sentence (or, for the cases released clients already + * hide, the legacy marker) they print, and the fact newer ones read. */ +export type AgentJournalDispatchRejection = { + reason: AgentSessionFailureSentence + rejection: SubmissionRejectionFact +} + +/** `row`: a status row, about the chat. `rejection`: a rejected message's reason, about it. */ +export type AgentSessionFailureSurface = 'row' | 'rejection' + +export type AgentSessionFailureWordsContext = { + /** The chat's agent, when the writer knows it. */ + agentName?: string + /** Names the legacy queue-full marker; without it a full queue is worded as a sentence. */ + provider?: 'claude' | 'codex' + /** The conversation command a failed start was for, so the next step is to run it again + * rather than to send a message. */ + command?: 'clear' +} + +/** + * Whether a refused start leaves the chat anything to start again from. `false`: this host has + * nothing to restart it from — no record, or none it can run — so only a new chat continues. + * A new wire code does not compile until it is classified here. + */ +export const START_REFUSAL_RESUMABLE: Record = { + execution_owner_reconciling: true, + agent_session_conflict: true, + agent_session_checkpoint_stale: true, + agent_session_ownership_unknown: true, + agent_session_operation_capacity: true, + structured_agent_session_unsupported: false, + agent_session_operation_conflict: true, + agent_session_operation_expired: true, + agent_session_operation_invalid: true, + agent_session_operation_unknown: true, + agent_session_item_revision_stale: true, + agent_session_already_resolved: true, + agent_session_identity_required: false, + agent_session_journal_unreadable: true, + agent_session_owner_restart_failed: true +} + +/** Sentences a refusal notice shows too, so a chat says them one way. */ +export const TERMINAL_AGENT_HOLDS_CHAT = 'This chat is still open in a terminal agent.' +export const QUIT_TERMINAL_AGENT = 'Quit that agent to continue the chat here.' +export const START_NEW_CHAT = 'Start a new chat to continue.' + +/** Person-facing provider text is quoted, but bounded so the sentence stays one. */ +const MAX_QUOTED_DETAIL_CHARS = 512 +const BYTES_PER_MB = 1024 * 1024 + +type Sentence = ( + context: AgentSessionFailureWordsContext, + fact: AgentSessionFailureFact, + surface: AgentSessionFailureSurface +) => string + +function quotingPersonDetail(lead: string, detail: ProviderDiagnostic | undefined): string { + const quoted = + detail?.audience === 'person' + ? detail.text + .slice(0, MAX_QUOTED_DETAIL_CHARS) + .trim() + .replace(/[.\s]+$/, '') + : '' + return quoted ? `${lead}: ${quoted}.` : `${lead}.` +} + +/** What to do once the start can work, for a sentence that ends in it. */ +function retryStep({ command }: AgentSessionFailureWordsContext): string { + return command === 'clear' ? 'run /clear again' : 'send your message again' +} + +/** The next step after a start or restart that failed: the command, or the message, again. */ +function startRetry({ command }: AgentSessionFailureWordsContext): string { + return command === 'clear' ? ' Run /clear again.' : ' Send your message to try again.' +} + +function couldNot(verb: string): Sentence { + return (context, fact) => { + const { agentName } = context + const failed = `${agentName ?? 'The agent'} couldn't ${verb}.` + // Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. + if (fact.refusal?.details?.reason === 'claimConflicted') { + return `${failed} ${TERMINAL_AGENT_HOLDS_CHAT} ${QUIT_TERMINAL_AGENT}` + } + const code = fact.refusal?.code + return code && !START_REFUSAL_RESUMABLE[code] + ? `${failed} ${START_NEW_CHAT}` + : `${failed}${startRetry(context)}` + } +} + +function megabytes(bytes: number): string { + return `${Math.round((bytes / BYTES_PER_MB) * 10) / 10} MB` +} + +const NOT_SENT = 'so the message was not sent.' + +const ATTACHMENT_SENTENCES = { + empty: () => `An image on this message is empty, ${NOT_SENT}`, + tooLarge: (_, { limit }) => + limit + ? `An image on this message is larger than ${megabytes(limit)}, ${NOT_SENT}` + : `An image on this message is too large, ${NOT_SENT}`, + tooMany: ({ agentName }, { limit }) => + limit + ? `${agentName ?? 'The agent'} accepts at most ${limit} images in one message, so this message was not sent.` + : 'This message has too many images, so it was not sent.', + totalTooLarge: (_, { limit }) => + limit + ? `The images on this message add up to more than ${megabytes(limit)}, ${NOT_SENT}` + : `The images on this message are too large together, ${NOT_SENT}`, + unsupportedType: ({ agentName }) => + `${agentName ?? 'The agent'} accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent.`, + notAFile: () => `An image on this message isn't a file, ${NOT_SENT}`, + noSource: () => `An image on this message has no file to send, ${NOT_SENT}` +} satisfies Record< + AgentSessionAttachmentProblemReason, + (context: AgentSessionFailureWordsContext, problem: AgentSessionAttachmentProblem) => string +> + +const FAILURE_SENTENCES = { + providerStartFailed: (context) => + `${context.agentName ?? 'The agent'} stopped before it finished starting.${startRetry(context)}`, + startFailed: couldNot('start'), + notSignedIn: (context) => + `${context.agentName ?? 'The agent'} is not signed in for the selected account. Sign in, then ${retryStep(context)}.`, + historyTooLarge: () => + `This conversation's history is too large to restore here. ${START_NEW_CHAT}`, + managedAccountEnvOverride: () => + 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.', + accountSwitchInProgress: () => + 'A Claude account switch is in progress. Try again after it finishes.', + managedAccountUnsupported: (context) => + `While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings, then ${retryStep(context)}.`, + providerExited: ({ agentName }, _, surface) => + surface === 'row' + ? `${agentName ?? 'The agent'} stopped while this response was in progress. You can continue in this conversation.` + : `${agentName ?? 'The agent'} stopped before this message was sent.`, + restartFailed: couldNot('restart'), + providerRejected: (_, fact) => + quotingPersonDetail('The provider did not accept this message', fact.detail), + attachmentInvalid: (context, fact) => + fact.attachment + ? ATTACHMENT_SENTENCES[fact.attachment.reason](context, fact.attachment) + : "An attachment on this message can't be sent to the agent.", + attachmentUnreadable: () => `An attachment on this message couldn't be read, ${NOT_SENT}`, + emptyMessage: () => 'This message is empty, so it was not sent.', + queueFull: () => 'Too many messages were waiting for the agent, so this one was not sent.', + writeFailed: () => "Orca couldn't hand this message to the agent, so it was not sent.", + cancelled: () => 'This message was withdrawn before the agent started it.', + chatClosed: () => 'The chat closed before this message was sent.', + hostRestarted: () => 'Orca restarted before this message was sent.', + notDelivered: () => 'This message was not delivered. Send it again to continue.', + compactionFailed: (_, fact) => quotingPersonDetail('Compaction failed', fact.detail), + compactionUnconfirmed: () => 'Compaction completion is unconfirmed.', + cancelUnconfirmed: () => 'Cancellation was not confirmed.', + answerUnconfirmed: () => 'Your answer was recorded but the agent did not confirm it.', + hostFault: () => "Orca ran into a problem, so this didn't go through. Try again.", + hostStopped: ({ agentName }) => + `${agentName ?? 'The agent'} never finished starting, so Orca stopped it.`, + providerRetrying: ({ agentName }, { retry }) => + retry?.error === 'rate_limit' || retry?.status === 429 + ? `${agentName ?? 'The agent'} is rate-limited and retrying.` + : `${agentName ?? 'The agent'} hit a temporary problem and is retrying.` +} satisfies Record + +/** The sentence a person reads for this fact on this surface; never a marker. */ +export function agentSessionFailureSentence( + fact: AgentSessionFailureFact, + surface: AgentSessionFailureSurface, + context: AgentSessionFailureWordsContext = {} +): string { + const sentence: Sentence = FAILURE_SENTENCES[fact.kind] + return sentence(context, fact, surface) +} + +/** The markers released clients hide, for the rejections that had one before rows carried a fact. + * A write failure is the bare marker: its error belongs in the log. */ +const LEGACY_REJECTION_MARKERS: Partial< + Record string | undefined> +> = { + cancelled: () => DISPATCH_REJECTED_CANCELLED, + writeFailed: () => DISPATCH_REJECTED_WRITE_FAILED, + queueFull: ({ provider }) => + provider === 'codex' + ? DISPATCH_REJECTED_CODEX_QUEUE_FULL + : provider === 'claude' + ? DISPATCH_REJECTED_QUEUE_FULL + : undefined +} + +/** The words a status row reporting this fact records. */ +export function agentSessionFailureWords( + fact: AgentSessionFailureFact, + context: AgentSessionFailureWordsContext & { surface: 'row' } +): AgentSessionFailureRowWords +/** The words a message rejected for this fact records. */ +export function agentSessionFailureWords( + fact: SubmissionRejectionFact, + context: AgentSessionFailureWordsContext & { surface: 'rejection' } +): AgentJournalDispatchRejection +export function agentSessionFailureWords( + fact: AgentSessionFailureFact, + context: AgentSessionFailureWordsContext & { surface: AgentSessionFailureSurface } +): AgentSessionFailureRowWords | AgentJournalDispatchRejection { + if (context.surface === 'row') { + return { text: branded(agentSessionFailureSentence(fact, 'row', context)), failure: fact } + } + // The rejection overload admits only these; a caller that got past the types is Orca's bug. + if (!isSubmissionRejectionFact(fact)) { + throw new Error(`agent session failure kind ${fact.kind} cannot reject a message`) + } + const words = + LEGACY_REJECTION_MARKERS[fact.kind]?.(context) ?? + agentSessionFailureSentence(fact, 'rejection', context) + return { reason: branded(words), rejection: fact } +} + +function branded(words: string): AgentSessionFailureSentence { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only `agentSessionFailureWords` calls this, with words from the table or a legacy marker for its fact. + return words as AgentSessionFailureSentence +} diff --git a/src/shared/agent-session-failure.test.ts b/src/shared/agent-session-failure.test.ts new file mode 100644 index 00000000000..4dc12f9bfe8 --- /dev/null +++ b/src/shared/agent-session-failure.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_PROVIDER_DIAGNOSTIC_CHARS, + providerDiagnostic, + providerDiagnosticOf, + readAgentSessionFailureFact, + withProviderDiagnostic +} from './agent-session-failure' + +describe('provider diagnostics', () => { + it('bounds the text and records nothing for an empty one', () => { + expect(providerDiagnostic(' ', 'log')).toBeUndefined() + expect( + providerDiagnostic('x'.repeat(MAX_PROVIDER_DIAGNOSTIC_CHARS + 50), 'log')?.text + ).toHaveLength(MAX_PROVIDER_DIAGNOSTIC_CHARS) + }) + + it('is read from the error that carries it, or from what it wraps', () => { + const diagnostic = { text: 'bad request', audience: 'person' as const } + const carried = withProviderDiagnostic( + new Error('codex turn/start failed: bad request'), + diagnostic + ) + expect(providerDiagnosticOf(carried)).toEqual(diagnostic) + expect(providerDiagnosticOf(new Error('wrapped', { cause: carried }))).toEqual(diagnostic) + expect(providerDiagnosticOf(new AggregateError([new Error('other'), carried], 'both'))).toEqual( + diagnostic + ) + }) + + it('ends on an aggregate error that contains itself', () => { + const loop = new AggregateError([], 'loop') + loop.errors.push(loop) + expect(providerDiagnosticOf(loop)).toBeUndefined() + }) + + it('is never inferred from an error that did not carry one', () => { + // Orca's own wording, even when it quotes something that looks like a provider message. + expect( + providerDiagnosticOf(new Error('claude stream-json exited (code 1): not signed in')) + ).toBe(undefined) + expect(providerDiagnosticOf('a string')).toBeUndefined() + }) +}) + +describe('reading a failure fact', () => { + it('keeps what this build can place', () => { + expect( + readAgentSessionFailureFact({ + kind: 'restartFailed', + refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } }, + detail: { text: 'x', audience: 'person' } + }) + ).toEqual({ + kind: 'restartFailed', + refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } }, + detail: { text: 'x', audience: 'person' } + }) + }) + + it('keeps what a provider said it is retrying, and only that', () => { + expect( + readAgentSessionFailureFact({ + kind: 'providerRetrying', + retry: { error: 'rate_limit', status: 429, attempt: 3 } + }) + ).toEqual({ kind: 'providerRetrying', retry: { error: 'rate_limit', status: 429 } }) + expect( + readAgentSessionFailureFact({ kind: 'providerRetrying', retry: { error: '', status: 'x' } }) + ).toEqual({ kind: 'providerRetrying' }) + }) + + it('reads a row an unreleased build wrote with a cause as a refusal with no details', () => { + expect( + readAgentSessionFailureFact({ + kind: 'restartFailed', + refusal: { code: 'agent_session_conflict', cause: 'claimConflicted' } + }) + ).toEqual({ kind: 'restartFailed', refusal: { code: 'agent_session_conflict' } }) + }) + + it('drops what a newer host wrote that this build cannot place', () => { + expect(readAgentSessionFailureFact({ kind: 'futureKind' })).toBeUndefined() + expect(readAgentSessionFailureFact(undefined)).toBeUndefined() + expect( + readAgentSessionFailureFact({ + kind: 'restartFailed', + refusal: { code: 'agent_session_conflict', details: { reason: 'futureReason' } }, + detail: { text: 'x', audience: 'future' } + }) + ).toEqual({ kind: 'restartFailed', refusal: { code: 'agent_session_conflict' } }) + }) +}) diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts new file mode 100644 index 00000000000..ed8b677b359 --- /dev/null +++ b/src/shared/agent-session-failure.ts @@ -0,0 +1,262 @@ +// What went wrong in a chat, typed where the host decides it, beside the sentence a person reads. +// +// The host writes both at once: `text`/`reason` stays a complete sentence because released clients +// print it as it is, and this fact is what newer clients choose their copy and action from. A +// provider's own words ride separately as `detail`, set only where Orca composed them from a +// value the provider wrote — never recovered from a string afterwards, since by then nothing can +// tell a provider's sentence from Orca's. + +import { + readAgentSessionRefusalReference, + type AgentSessionRefusalReference +} from './agent-session-wire-refusals' + +/** One per situation with its own honest next step; a new one needs copy before it compiles. */ +export const AGENT_SESSION_FAILURE_KINDS = [ + /** Only an observed exit: the provider stopped while starting. */ + 'providerStartFailed', + /** A start that did not land, with no one to blame: Orca's fault, a failed spawn, a close. */ + 'startFailed', + 'notSignedIn', + 'historyTooLarge', + 'managedAccountEnvOverride', + 'accountSwitchInProgress', + 'managedAccountUnsupported', + 'providerExited', + 'restartFailed', + 'providerRejected', + 'attachmentInvalid', + 'attachmentUnreadable', + 'emptyMessage', + 'queueFull', + 'writeFailed', + 'cancelled', + 'chatClosed', + 'hostRestarted', + 'notDelivered', + 'compactionFailed', + 'compactionUnconfirmed', + 'cancelUnconfirmed', + 'answerUnconfirmed', + 'hostFault', + /** Orca stopped an agent whose start never finished. */ + 'hostStopped', + /** The provider is retrying a request its API refused; not a failure yet. */ + 'providerRetrying' +] as const +export type AgentSessionFailureKind = (typeof AGENT_SESSION_FAILURE_KINDS)[number] + +export function isAgentSessionFailureKind(value: unknown): value is AgentSessionFailureKind { + return typeof value === 'string' && AGENT_SESSION_FAILURE_KINDS.some((kind) => kind === value) +} + +/** Kinds only a status row reports: none is ever why a message was not sent. */ +const STATUS_ROW_ONLY_FAILURE_KINDS = [ + 'compactionFailed', + 'compactionUnconfirmed', + 'cancelUnconfirmed', + 'answerUnconfirmed', + 'providerRetrying' +] as const satisfies readonly AgentSessionFailureKind[] + +/** Why a message was not sent. A new failure kind is one of these until listed above. */ +export type SubmissionRejectionKind = Exclude< + AgentSessionFailureKind, + (typeof STATUS_ROW_ONLY_FAILURE_KINDS)[number] +> + +export function isSubmissionRejectionKind(value: unknown): value is SubmissionRejectionKind { + return ( + isAgentSessionFailureKind(value) && + !STATUS_ROW_ONLY_FAILURE_KINDS.some((statusOnly) => statusOnly === value) + ) +} + +/** `person`: written by the provider for whoever reads the chat, shown inline. `log`: a stderr + * tail or exit status, shown only behind Details. */ +export type ProviderDiagnosticAudience = 'person' | 'log' + +export type ProviderDiagnostic = { + text: string + audience: ProviderDiagnosticAudience +} + +/** Stderr can be a whole dump; the row keeps enough to act on. The same cap as the exit reason a + * lease record keeps, so a diagnostic never outgrows what the record may store. */ +export const MAX_PROVIDER_DIAGNOSTIC_CHARS = 512 + +/** Which of Orca's checks an image failed. A new one needs a sentence before it compiles. */ +export const AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS = [ + 'empty', + 'tooLarge', + 'tooMany', + 'totalTooLarge', + 'unsupportedType', + 'notAFile', + 'noSource' +] as const +export type AgentSessionAttachmentProblemReason = + (typeof AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS)[number] + +export type AgentSessionAttachmentProblem = { + reason: AgentSessionAttachmentProblemReason + /** The limit it broke: bytes for `tooLarge` and `totalTooLarge`, a count for `tooMany`. */ + limit?: number +} + +/** What the provider said it is retrying, in its own fields. */ +export type AgentSessionProviderRetry = { + /** The provider's error type, e.g. `rate_limit` or `overloaded`. */ + error?: string + /** The HTTP status the request failed with. */ + status?: number +} + +export type AgentSessionFailureFact = { + kind: AgentSessionFailureKind + /** Provider-authored only; absent whenever Orca wrote the words. */ + detail?: ProviderDiagnostic + /** On `restartFailed` and `startFailed`: the refusal that kept the agent from starting. */ + refusal?: AgentSessionRefusalReference + /** On `attachmentInvalid`: which check the attachment failed. */ + attachment?: AgentSessionAttachmentProblem + /** On `providerRetrying`: why the provider is retrying. */ + retry?: AgentSessionProviderRetry +} + +/** A fact as a row stores it: its kind may be one a newer host added, so only + * `readAgentSessionFailureFact` or the rejection classifier may place it. */ +export type UnreadAgentSessionFailureFact = { kind: string } + +/** A fact a rejected message may carry. */ +export type SubmissionRejectionFact = AgentSessionFailureFact & { kind: SubmissionRejectionKind } + +export function isSubmissionRejectionFact( + fact: AgentSessionFailureFact +): fact is SubmissionRejectionFact { + return isSubmissionRejectionKind(fact.kind) +} + +/** Null for empty text, so a writer never records a detail with nothing in it. */ +export function providerDiagnostic( + text: string, + audience: ProviderDiagnosticAudience +): ProviderDiagnostic | undefined { + const bounded = text.trim().slice(0, MAX_PROVIDER_DIAGNOSTIC_CHARS).trim() + return bounded ? { text: bounded, audience } : undefined +} + +export function agentSessionFailureFact( + kind: TKind, + extra: { + detail?: ProviderDiagnostic + refusal?: AgentSessionRefusalReference + attachment?: AgentSessionAttachmentProblem + retry?: AgentSessionProviderRetry + } = {} +): AgentSessionFailureFact & { kind: TKind } { + // Re-bounded here, so no writer can store more than the cap however it built the detail. + const detail = extra.detail + ? providerDiagnostic(extra.detail.text, extra.detail.audience) + : undefined + return { + kind, + ...(detail ? { detail } : {}), + ...(extra.refusal ? { refusal: extra.refusal } : {}), + ...(extra.attachment ? { attachment: extra.attachment } : {}), + ...(extra.retry ? { retry: extra.retry } : {}) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function isProviderDiagnostic(value: unknown): value is ProviderDiagnostic { + return ( + isRecord(value) && + typeof value.text === 'string' && + (value.audience === 'person' || value.audience === 'log') + ) +} + +function readAttachmentProblem(value: unknown): AgentSessionAttachmentProblem | undefined { + if (!isRecord(value)) { + return undefined + } + const reason = AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS.find((known) => known === value.reason) + if (!reason) { + return undefined + } + const limit = value.limit + return typeof limit === 'number' && Number.isFinite(limit) && limit > 0 + ? { reason, limit } + : { reason } +} + +/** A retry as a reader meets it; undefined when it names neither field. */ +export function readProviderRetry(value: unknown): AgentSessionProviderRetry | undefined { + if (!isRecord(value)) { + return undefined + } + const error = + typeof value.error === 'string' && value.error.trim() ? value.error.trim() : undefined + const status = + typeof value.status === 'number' && Number.isInteger(value.status) && value.status > 0 + ? value.status + : undefined + return error || status + ? { ...(error ? { error } : {}), ...(status ? { status } : {}) } + : undefined +} + +/** A fact as a reader meets it. Undefined for anything this build cannot place, including a kind a + * newer host added, so the reader falls back to what it does for a row with no fact. */ +export function readAgentSessionFailureFact(value: unknown): AgentSessionFailureFact | undefined { + if (!isRecord(value) || !isAgentSessionFailureKind(value.kind)) { + return undefined + } + const refusal = readAgentSessionRefusalReference(value.refusal) + const attachment = readAttachmentProblem(value.attachment) + const retry = readProviderRetry(value.retry) + return agentSessionFailureFact(value.kind, { + ...(isProviderDiagnostic(value.detail) ? { detail: value.detail } : {}), + ...(refusal ? { refusal } : {}), + ...(attachment ? { attachment } : {}), + ...(retry ? { retry } : {}) + }) +} + +/** The provider-authored diagnostic an error carries, set only where it was composed. Follows the + * `cause` chain, since wrappers such as the acquisition errors keep the original as their cause. */ +export function providerDiagnosticOf(error: unknown): ProviderDiagnostic | undefined { + return providerDiagnosticWithin(error, 0) +} + +// One depth bound across `cause` and aggregated errors, so an error that contains itself ends. +function providerDiagnosticWithin(error: unknown, start: number): ProviderDiagnostic | undefined { + let current: unknown = error + for (let depth = start; depth < 6 && current instanceof Error; depth += 1) { + if ('providerDiagnostic' in current && isProviderDiagnostic(current.providerDiagnostic)) { + return current.providerDiagnostic + } + if (current instanceof AggregateError) { + for (const inner of current.errors) { + const found = providerDiagnosticWithin(inner, depth + 1) + if (found) { + return found + } + } + } + current = current.cause + } + return undefined +} + +/** Attaches the provider's words to the error Orca built around them. */ +export function withProviderDiagnostic( + error: TError, + diagnostic: ProviderDiagnostic | undefined +): TError { + return diagnostic ? Object.assign(error, { providerDiagnostic: diagnostic }) : error +} diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index ec66caaa059..594d6a04b11 100644 --- a/src/shared/agent-session-journal-schemas.test.ts +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -332,6 +332,57 @@ describe('optional notice metadata', () => { }) }) +describe('typed failure facts', () => { + it('admits a status row and a submission with a fact, and the same rows without one', () => { + const failure = { + kind: 'providerStartFailed', + detail: { text: 'exit status 1', audience: 'log' } + } + expect(isAdmissibleAgentJournalItemBody({ kind: 'status', text: 'Stopped.', failure })).toBe( + true + ) + expect(isAdmissibleAgentJournalItemBody({ kind: 'status', text: 'Stopped.' })).toBe(true) + const submission = { + clientMessageId: 'cm-1', + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'rejected', + providerItemId: null, + reason: 'Not sent.', + submittedAt: 1, + resolvedAt: 2 + } + expect(isAdmissibleAgentJournalSubmission(submission)).toBe(true) + expect(isAdmissibleAgentJournalSubmission({ ...submission, rejection: failure })).toBe(true) + }) + + it("admits a refusal's details, and a row an earlier build wrote with its cause", () => { + const refused = (refusal: Record) => + isAdmissibleAgentJournalItemBody({ + kind: 'status', + text: "Codex couldn't restart.", + failure: { kind: 'restartFailed', refusal } + }) + expect( + refused({ + code: 'agent_session_conflict', + details: { reason: 'claimConflicted', futureFact: 1 } + }) + ).toBe(true) + expect(refused({ code: 'agent_session_conflict', cause: 'claimConflicted' })).toBe(true) + }) + + it('keeps a kind or audience a newer host writes admissible', () => { + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'status', + text: 'Stopped.', + failure: { kind: 'futureKind', detail: { text: 'x', audience: 'future' } } + }) + ).toBe(true) + }) +}) + describe('optional tool annotations', () => { const body = { kind: 'tool-call', name: 'shell', input: null, state: 'completed' } it('admits old rows and rows with optional annotations without a new kind', () => { diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 438352b1ea3..d193db01846 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -18,6 +18,7 @@ import { AgentSessionContextUsageSchema } from './agent-session-context-usage-sc import type { AgentJournalItemBody, AgentJournalMessageItem, + AgentJournalResolution, AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' @@ -199,6 +200,14 @@ const ThreadGoalState = z.union([ z.object({ state: z.string() }).refine((value) => !['set', 'cleared'].includes(value.state)) ]) +/** Open like `state`: a kind, audience or refusal detail a newer host writes must not turn the row + * malformed; the fact reader is where an unplaceable one is dropped. */ +const FailureFact = z.object({ + kind: z.string().min(1), + detail: z.object({ text: z.string(), audience: z.string().min(1) }).optional(), + refusal: z.object({ code: z.string().min(1), details: z.looseObject({}).optional() }).optional() +}) + export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ MessageBody, z.object({ @@ -251,7 +260,8 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ }) .optional(), providerFrame: ProviderFrame.optional(), - threadGoal: ThreadGoalState.optional() + threadGoal: ThreadGoalState.optional(), + failure: FailureFact.optional() }), z.object({ kind: z.literal('turn'), @@ -307,9 +317,14 @@ export const AgentJournalSubmissionSchema = z.object({ resolvedAt: z.number().nullable(), recovered: z.literal(true).optional(), handoverRecorded: z.literal(true).optional(), - handedOverAt: z.number().optional() + handedOverAt: z.number().optional(), + rejection: FailureFact.optional() }) +export function isAgentJournalResolution(value: unknown): value is AgentJournalResolution { + return Resolution.safeParse(value).success +} + export function isAdmissibleAgentJournalItemBody(value: unknown): value is AgentJournalItemBody { return AgentJournalItemBodySchema.safeParse(value).success } diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index f68361fb13c..a17561e6b81 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -7,6 +7,8 @@ // rewritten in place, so a host that cannot read a row refuses to write the // journal rather than skipping or compacting past it. +import type { UnreadAgentSessionFailureFact } from './agent-session-failure' +import type { AgentSessionFailureRowWords } from './agent-session-failure-words' import type { AgentType } from './agent-status-types' import type { AgentSessionQuestionAnswer } from './agent-session-question-answer' import type { AgentJournalTurnOutcome } from './agent-turn-outcome' @@ -257,9 +259,8 @@ export type AgentJournalThreadGoalState = | { state: 'set'; goal: AgentJournalThreadGoal } | { state: 'cleared' } -export type AgentJournalStatusItem = { +type AgentJournalStatusItemFields = { kind: 'status' - text: string /** Optional display hints; unknown values retain the ordinary text fallback. */ presentation?: string tone?: string @@ -278,6 +279,19 @@ export type AgentJournalStatusItem = { threadGoal?: AgentJournalThreadGoalState } +/** A status row that reports no failure; its text is its writer's own. */ +export type AgentJournalPlainStatusItem = AgentJournalStatusItemFields & { + text: string + failure?: undefined +} + +export type AgentJournalStatusItem = + | AgentJournalPlainStatusItem + | (AgentJournalStatusItemFields & + /** A row that reports a failure: what failed, typed, beside the sentence older clients print, + * both from `agentSessionFailureWords`. Absent on rows from older hosts. */ + AgentSessionFailureRowWords) + /** The durable record of one root turn. `running` exposes cancellation while * the provider can still accept it; the item is revised to a terminal state, * never tombstoned, so the endpoints survive. Timestamps are the execution @@ -367,8 +381,11 @@ export type AgentJournalSubmission = { dispatchState: AgentJournalDispatchState /** Provider item identity adopted on accept; null otherwise. */ providerItemId: string | null - /** Terminal reason on `rejected`. */ + /** Terminal reason on `rejected`: a sentence a person can read, or one of the legacy markers + * older clients already recognise. On `unknown`, the doubt marker. */ reason: string | null + /** On `rejected`, why, typed; absent on rows from older hosts. */ + rejection?: UnreadAgentSessionFailureFact submittedAt: number resolvedAt: number | null /** Set when crash reconciliation resolved the dispatch, not the provider. A live diff --git a/src/shared/agent-session-lease-adjudication.test.ts b/src/shared/agent-session-lease-adjudication.test.ts index 2216782b5e5..602a263e91b 100644 --- a/src/shared/agent-session-lease-adjudication.test.ts +++ b/src/shared/agent-session-lease-adjudication.test.ts @@ -91,7 +91,11 @@ describe('acquisition compare-and-swap', () => { handoffOperationId: null, probe: MATCHED }) - ).toEqual({ decision: 'refused', code: 'agent_session_checkpoint_stale' }) + ).toEqual({ + decision: 'refused', + code: 'agent_session_checkpoint_stale', + details: { reason: 'fenceStale' } + }) expect(acquire(held, MATCHED)).toEqual({ decision: 'granted', nextFence: 8 }) }) @@ -108,7 +112,11 @@ describe('acquisition compare-and-swap', () => { handoffOperationId: null, probe: MATCHED }) - ).toEqual({ decision: 'refused', code: 'agent_session_checkpoint_stale' }) + ).toEqual({ + decision: 'refused', + code: 'agent_session_checkpoint_stale', + details: { reason: 'fenceStale' } + }) }) it('never grants a second owner on expiry alone', () => { @@ -116,11 +124,13 @@ describe('acquisition compare-and-swap', () => { const expired = lease({ leaseDeadlineAt: 1, lastRenewedAt: 1 }) expect(acquire(expired, INDETERMINATE)).toEqual({ decision: 'refused', - code: 'agent_session_ownership_unknown' + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' } }) expect(acquire(expired, MATCHED)).toEqual({ decision: 'refused', - code: 'agent_session_conflict' + code: 'agent_session_conflict', + details: { reason: 'ownerAlive' } }) expect(acquire(expired, { outcome: 'pid-absent' })).toEqual({ decision: 'granted', @@ -131,7 +141,8 @@ describe('acquisition compare-and-swap', () => { it('refuses while unreconciled even with proof the owner is dead', () => { expect(acquire(lease({ unreconciled: true }), { outcome: 'pid-absent' })).toEqual({ decision: 'refused', - code: 'execution_owner_reconciling' + code: 'execution_owner_reconciling', + details: { reason: 'hostReconciling' } }) }) @@ -139,14 +150,16 @@ describe('acquisition compare-and-swap', () => { // Restart adjudication and recovery resolution are what retire it, once its owner is gone. expect(acquire(lease({ claimStatus: 'conflicted' }), { outcome: 'exit-observed' })).toEqual({ decision: 'refused', - code: 'agent_session_conflict' + code: 'agent_session_conflict', + details: { reason: 'claimConflicted' } }) }) it('refuses acquisition in the recovering stage', () => { expect(acquire(lease({ handoffStage: 'recovering' }), { outcome: 'pid-absent' })).toEqual({ decision: 'refused', - code: 'agent_session_ownership_unknown' + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' } }) }) @@ -159,7 +172,8 @@ describe('acquisition compare-and-swap', () => { }) expect(acquire(mid, { outcome: 'reservation-unused' }, 'op-2')).toEqual({ decision: 'refused', - code: 'agent_session_operation_conflict' + code: 'agent_session_operation_conflict', + details: { reason: 'handoffInFlight' } }) expect(acquire(mid, { outcome: 'reservation-unused' }, 'op-1')).toEqual({ decision: 'retry-reservation', @@ -171,7 +185,8 @@ describe('acquisition compare-and-swap', () => { const reserved = lease({ ownerProcess: null, claimStatus: 'reserved', handoffStage: null }) expect(acquire(reserved, INDETERMINATE)).toEqual({ decision: 'refused', - code: 'agent_session_ownership_unknown' + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' } }) expect(acquire(reserved, { outcome: 'reservation-unused' })).toEqual({ decision: 'granted', diff --git a/src/shared/agent-session-lease-adjudication.ts b/src/shared/agent-session-lease-adjudication.ts index ecdb1cecc3a..bbe409a37fc 100644 --- a/src/shared/agent-session-lease-adjudication.ts +++ b/src/shared/agent-session-lease-adjudication.ts @@ -15,7 +15,10 @@ import type { AgentSessionHandoffStage, AgentSessionLease } from './agent-session-record' -import type { AgentSessionOwnerVerdict } from './agent-session-wire-refusals' +import type { + AgentSessionOwnerVerdict, + AgentSessionRefusalDetailsByCode +} from './agent-session-wire-refusals' export type AgentSessionIdentityMatchField = 'process-start-time' | 'spawn-token' @@ -44,7 +47,13 @@ export type AgentSessionAcquisitionDecision = | { decision: 'granted'; nextFence: number } /** The same acquisition operation re-entering its own reservation; no new fence, no new spawn. */ | { decision: 'retry-reservation'; fence: number } - | { decision: 'refused'; code: AgentSessionLeaseRefusalCode } + | { + [C in AgentSessionLeaseRefusalCode]: { + decision: 'refused' + code: C + details: AgentSessionRefusalDetailsByCode[C] + } + }[AgentSessionLeaseRefusalCode] export type AgentSessionRestartAdjudication = /** Nothing is outstanding — no owner, no reservation. Clear any latched stage; the fence stays. */ @@ -131,23 +140,43 @@ export function evaluateAgentSessionAcquisition(args: { }): AgentSessionAcquisitionDecision { const { lease, expectedFence, handoffOperationId, probe } = args if (lease.unreconciled) { - return { decision: 'refused', code: 'execution_owner_reconciling' } + return { + decision: 'refused', + code: 'execution_owner_reconciling', + details: { reason: 'hostReconciling' } + } } if (!isAgentSessionFenceCurrent(lease, expectedFence)) { - return { decision: 'refused', code: 'agent_session_checkpoint_stale' } + return { + decision: 'refused', + code: 'agent_session_checkpoint_stale', + details: { reason: 'fenceStale' } + } } if (lease.claimStatus === 'conflicted') { // Why: the user's own terminal agent; restart adjudication and recovery retire it once gone. - return { decision: 'refused', code: 'agent_session_conflict' } + return { + decision: 'refused', + code: 'agent_session_conflict', + details: { reason: 'claimConflicted' } + } } if (lease.handoffStage === 'recovering') { // Why: no stage expires into an owner; recovery resolution concludes about it first. - return { decision: 'refused', code: 'agent_session_ownership_unknown' } + return { + decision: 'refused', + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' } + } } if (lease.handoffStage !== null && lease.handoffOperationId !== null) { if (handoffOperationId !== lease.handoffOperationId) { // Why: the retry key is operation id + fence + stage; a different id is a different intent. - return { decision: 'refused', code: 'agent_session_operation_conflict' } + return { + decision: 'refused', + code: 'agent_session_operation_conflict', + details: { reason: 'handoffInFlight' } + } } if ( lease.ownerProcess === null && @@ -162,19 +191,24 @@ export function evaluateAgentSessionAcquisition(args: { if (!isProvenDeadProbe(probe)) { // Why: a lapsed deadline means Orca stopped hearing from the owner, not that the child // stopped editing files and spending tokens. - return { - decision: 'refused', - code: isProvenAliveProbe(probe) - ? 'agent_session_conflict' - : 'agent_session_ownership_unknown' - } + return isProvenAliveProbe(probe) + ? { decision: 'refused', code: 'agent_session_conflict', details: { reason: 'ownerAlive' } } + : { + decision: 'refused', + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' } + } } return { decision: 'granted', nextFence: nextAgentSessionFence(lease) } } if (lease.claimStatus === 'reserved' && probe.outcome !== 'reservation-unused') { // Why: a reservation with no proven process is not a free lease — the crash may have lost // the race with the spawn rather than beaten it. - return { decision: 'refused', code: 'agent_session_ownership_unknown' } + return { + decision: 'refused', + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' } + } } return { decision: 'granted', nextFence: nextAgentSessionFence(lease) } } diff --git a/src/shared/agent-session-mutation-envelope.test.ts b/src/shared/agent-session-mutation-envelope.test.ts index 306450f8a63..028de74935a 100644 --- a/src/shared/agent-session-mutation-envelope.test.ts +++ b/src/shared/agent-session-mutation-envelope.test.ts @@ -163,11 +163,18 @@ describe('admitAgentSessionMutation', () => { it('surfaces a ledger refusal verbatim', () => { const admission = admitAgentSessionMutation({ ...base, - ledger: { decision: 'refused', code: 'agent_session_operation_expired' } + ledger: { + decision: 'refused', + code: 'agent_session_operation_expired', + details: { reason: 'operationExpired' } + } }) expect(admission).toMatchObject({ decision: 'refused', - refusal: { code: 'agent_session_operation_expired' } + refusal: { + code: 'agent_session_operation_expired', + details: { reason: 'operationExpired' } + } }) }) }) diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts index dd409d406d1..770c184b562 100644 --- a/src/shared/agent-session-mutation-envelope.ts +++ b/src/shared/agent-session-mutation-envelope.ts @@ -15,6 +15,7 @@ import { agentSessionLeaseAdmitsWriter } from './agent-session-lease-adjudicatio import type { AgentSessionLease } from './agent-session-record' import { terminalOwnerRefusalMessage } from './agent-session-legacy-handoff-lease' import type { AgentSessionMutationEnvelope, AgentSessionWireRefusal } from './agent-session-wire' +import { refuse } from './agent-session-wire-refusals' /** * Stable digest over the fields that define what this call DOES. Keys are @@ -64,11 +65,11 @@ export function agentSessionFingerprintConflict( ): AgentSessionWireRefusal | null { return envelope.payloadFingerprint === hostFingerprint ? null - : { - code: 'agent_session_operation_conflict', - message: - 'The payload does not match the fingerprint the client declared for this operation.' - } + : refuse( + 'agent_session_operation_conflict', + { reason: 'fingerprintMismatch' }, + 'The payload does not match the fingerprint the client declared for this operation.' + ) } export type AgentSessionMutationAdmission = @@ -104,10 +105,11 @@ export function admitAgentSessionMutation(input: { if (ledger.decision === 'refused') { return { decision: 'refused', - refusal: { - code: ledger.code, - message: `Operation ${envelope.clientOperationId} was refused: ${ledger.code}.` - } + refusal: refuse( + ledger.code, + ledger.details, + `Operation ${envelope.clientOperationId} was refused: ${ledger.code}.` + ) } } if (ledger.decision === 'replay') { @@ -130,24 +132,31 @@ function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireR return null } if (lease.unreconciled) { - return { - code: 'execution_owner_reconciling', - message: 'This host has not yet adjudicated the session lease.' - } + return refuse( + 'execution_owner_reconciling', + { reason: 'hostReconciling' }, + 'This host has not yet adjudicated the session lease.' + ) } if (lease.handoffStage !== null) { - return { - code: 'agent_session_conflict', - message: - lease.claimStatus === 'conflicted' - ? terminalOwnerRefusalMessage(lease) - : lease.handoffStage === 'new-owner-proving' - ? 'The chat is still starting.' - : "Orca has not yet confirmed that this chat's previous agent process stopped. Reopen the chat to check again." + if (lease.claimStatus === 'conflicted') { + return refuse( + 'agent_session_conflict', + { reason: 'claimConflicted' }, + terminalOwnerRefusalMessage(lease) + ) } + return lease.handoffStage === 'new-owner-proving' + ? refuse('agent_session_conflict', { reason: 'chatStarting' }, 'The chat is still starting.') + : refuse( + 'agent_session_conflict', + { reason: 'ownerUnproven' }, + "Orca has not yet confirmed that this chat's previous agent process stopped. Reopen the chat to check again." + ) } - return { - code: 'agent_session_ownership_unknown', - message: 'The session has no live owner to accept writes.' - } + return refuse( + 'agent_session_ownership_unknown', + { reason: 'noLiveOwner' }, + 'The session has no live owner to accept writes.' + ) } diff --git a/src/shared/agent-session-operation-ledger.test.ts b/src/shared/agent-session-operation-ledger.test.ts index 0710eade461..ef10fac8f2b 100644 --- a/src/shared/agent-session-operation-ledger.test.ts +++ b/src/shared/agent-session-operation-ledger.test.ts @@ -64,7 +64,8 @@ describe('operation admission', () => { admit(rows) expect(evaluate(rows, { fingerprint: 'fp-2' })).toEqual({ decision: 'refused', - code: 'agent_session_operation_conflict' + code: 'agent_session_operation_conflict', + details: { reason: 'operationIdReused' } }) }) @@ -78,14 +79,19 @@ describe('operation admission', () => { const rows = new Map() expect(evaluate(rows, { operationId: 'not-an-operation-id' })).toEqual({ decision: 'refused', - code: 'agent_session_operation_invalid' + code: 'agent_session_operation_invalid', + details: { reason: 'operationIdInvalid' } }) // Why: a future-dated id would look new again after its own tombstone is collected. expect( evaluate(rows, { operationId: operationId(NOW + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + 1) }) - ).toEqual({ decision: 'refused', code: 'agent_session_operation_invalid' }) + ).toEqual({ + decision: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'operationIdInvalid' } + }) expect( evaluate(rows, { operationId: operationId(NOW + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS) }) .decision @@ -97,7 +103,8 @@ describe('operation admission', () => { const stale = operationId(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 1) expect(evaluate(rows, { operationId: stale })).toEqual({ decision: 'refused', - code: 'agent_session_operation_expired' + code: 'agent_session_operation_expired', + details: { reason: 'operationExpired' } }) expect( evaluate(rows, { operationId: operationId(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) }) @@ -110,12 +117,14 @@ describe('operation admission', () => { admit(rows, { operationId: operationId(NOW, 'b'.repeat(32)) }) expect(evaluate(rows, { perClientLimit: 1 })).toEqual({ decision: 'refused', - code: 'agent_session_operation_capacity' + code: 'agent_session_operation_capacity', + details: { reason: 'operationCapacity' } }) // A different caller is still refused once the global cap is reached. expect(evaluate(rows, { callerKey: 'client-2', globalLimit: 1 })).toEqual({ decision: 'refused', - code: 'agent_session_operation_capacity' + code: 'agent_session_operation_capacity', + details: { reason: 'operationCapacity' } }) expect(evaluate(rows, { callerKey: 'client-2', perClientLimit: 1 }).decision).toBe('admit') }) diff --git a/src/shared/agent-session-operation-ledger.ts b/src/shared/agent-session-operation-ledger.ts index 18d0f18754b..90caec22bb7 100644 --- a/src/shared/agent-session-operation-ledger.ts +++ b/src/shared/agent-session-operation-ledger.ts @@ -1,3 +1,7 @@ +import type { + AgentSessionAnyRefusalDetails, + AgentSessionRefusalDetailsByCode +} from './agent-session-refusal-details' import { isAgentSessionRewindResult, type AgentSessionRewindReason, @@ -55,7 +59,15 @@ export type AgentSessionOperationOutcome = */ launch?: unknown } - | { status: 'failed'; code: string; message?: string; rewindReason?: AgentSessionRewindReason } + | { + status: 'failed' + code: string + message?: string + rewindReason?: AgentSessionRewindReason + /** Beside the code, so a replay says what the first answer did. Read back against the code, + * since the code is a string here; a row written before details carries none. */ + details?: AgentSessionAnyRefusalDetails + } /** The effect may or may not have happened; replay this answer instead of spawning again. */ | { status: 'unknown' } @@ -78,7 +90,13 @@ export type AgentSessionOperationRefusalCode = export type AgentSessionOperationDecision = | { decision: 'replay'; row: AgentSessionOperationRow } | { decision: 'admit'; row: AgentSessionOperationRow } - | { decision: 'refused'; code: AgentSessionOperationRefusalCode } + | { + [C in AgentSessionOperationRefusalCode]: { + decision: 'refused' + code: C + details: AgentSessionRefusalDetailsByCode[C] + } + }[AgentSessionOperationRefusalCode] /** NUL cannot occur in a caller key or operation id, so no pair can forge another pair's key. */ const OPERATION_KEY_SEPARATOR = '\u0000' @@ -228,19 +246,31 @@ export function evaluateAgentSessionOperation(args: { operationTimestamp > now + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS ) { // Why: a future-dated id could look new again after its tombstone is collected. - return { decision: 'refused', code: 'agent_session_operation_invalid' } + return { + decision: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'operationIdInvalid' } + } } const key = agentSessionOperationKey(callerKey, operationId) const existing = rows.get(key) if (existing) { return existing.fingerprint === fingerprint ? { decision: 'replay', row: existing } - : { decision: 'refused', code: 'agent_session_operation_conflict' } + : { + decision: 'refused', + code: 'agent_session_operation_conflict', + details: { reason: 'operationIdReused' } + } } if (now - operationTimestamp > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) { // Why: once a tombstone could have expired, an unseen replay must never be reinterpreted as // permission to start another fresh agent. - return { decision: 'refused', code: 'agent_session_operation_expired' } + return { + decision: 'refused', + code: 'agent_session_operation_expired', + details: { reason: 'operationExpired' } + } } const perClientLimit = args.perClientLimit ?? AGENT_SESSION_DURABLE_OPERATION_PER_CLIENT_LIMIT const globalLimit = args.globalLimit ?? AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT @@ -253,7 +283,11 @@ export function evaluateAgentSessionOperation(args: { if (callerCount >= perClientLimit || rows.size >= globalLimit) { // Why: tombstones cannot be evicted early without making an old replay capable of spawning // again; reject new ids until retained rows age out. - return { decision: 'refused', code: 'agent_session_operation_capacity' } + return { + decision: 'refused', + code: 'agent_session_operation_capacity', + details: { reason: 'operationCapacity' } + } } return { decision: 'admit', diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts new file mode 100644 index 00000000000..6080a772ee2 --- /dev/null +++ b/src/shared/agent-session-refusal-details.ts @@ -0,0 +1,271 @@ +// What a refusal says beyond its code: one reason list and one set of facts per code. +// +// A code alone cannot say what to do: `agent_session_operation_invalid` covers a cleared +// conversation (open the current one) and a busy command (wait). Each reason here is one situation +// with its own honest words and next step; a reason two codes share is listed under each. There is +// no catch-all: a site that cannot name its situation sends the code with no reason, the same as an +// older host, and the reader falls back to what it does for the code. + +import type { AgentJournalResolution } from './agent-session-journal-types' +import { isAgentJournalResolution } from './agent-session-journal-schemas' +import { AGENT_SESSION_REWIND_REASONS, type AgentSessionRewindReason } from './agent-session-rewind' +import type { + AgentSessionOwnerVerdict, + AgentSessionWireRefusalCode +} from './agent-session-wire-refusals' + +export const AGENT_SESSION_REFUSAL_REASONS = { + agent_session_operation_invalid: [ + // The request + 'requestMalformed', + 'operationIdInvalid', + 'messageIdReused', + 'operationRefusedEarlier', + 'journalWriteFailed', + // The conversation's state + 'conversationCleared', + /** A /clear that never committed; its replacement conversation may not exist. */ + 'clearUnconfirmed', + 'conversationCommandUnconfirmed', + 'conversationCommandInFlight', + 'handoffInFlight', + 'turnActive', + 'promptPending', + 'backgroundTasksRunning', + 'messagesUnsettled', + 'rewindRefused', + 'rewindUnconfirmed', + // A prompt card or an option + 'promptGone', + 'optionRejected', + // The provider + 'providerStarting', + 'goalsUnsupported', + 'providerRejected', + 'providerStartFailed', + 'notSignedIn', + 'historyTooLarge', + /** The launch's own Anthropic sign-in variables would override the managed Claude account. */ + 'managedAccountEnvOverride', + 'accountSwitchInProgress', + /** A Claude account is added in WSL and no Windows one is selected, which a chat can't run under. */ + 'managedAccountUnsupported', + /** The agent started, then Orca could not open the chat's conversation for it. */ + 'attachFailed' + ], + agent_session_ownership_unknown: [ + 'sessionNotAttached', + 'noLiveOwner', + /** Orca cannot prove an agent process it recorded or started has exited. */ + 'ownerUnproven', + 'claimConflicted', + 'recordMissing', + 'replaySuperseded', + 'leaseMoved', + 'spawnIdentityMismatch', + 'notResumable', + 'noProviderChild', + 'conversationHeldElsewhere' + ], + agent_session_conflict: [ + 'chatStarting', + 'ownerUnproven', + 'claimConflicted', + 'ownerAlive', + 'identityMismatch', + 'sessionExists', + 'conversationHeldElsewhere', + 'tabIdTaken' + ], + execution_owner_reconciling: ['hostReconciling', 'recordUnreadable'], + agent_session_checkpoint_stale: ['fenceStale', 'leaseMoved', 'recordMissing'], + agent_session_identity_required: ['recordMissing', 'transcriptNotFound', 'transcriptUnreadable'], + agent_session_operation_conflict: ['fingerprintMismatch', 'operationIdReused', 'handoffInFlight'], + agent_session_operation_expired: ['operationExpired'], + agent_session_operation_capacity: ['operationCapacity'], + agent_session_operation_unknown: [ + 'outcomeUnknown', + 'resultLost', + 'rewindUnconfirmed', + 'tabUnconfirmed' + ], + agent_session_item_revision_stale: ['promptMoved'], + agent_session_already_resolved: ['promptAlreadyResolved'], + agent_session_journal_unreadable: [ + /** SQLite reports the chat's journal damaged or not a database; no retry reads past it. */ + 'journalCorrupt', + /** Any other failed open, which can clear. */ + 'journalUnavailable' + ], + structured_agent_session_unsupported: [ + 'clientCapabilityMissing', + 'hostDisabled', + 'hostUnsupported' + ], + // Sent with no reason when starting the agent for an operation throws; older clients read it too. + agent_session_owner_restart_failed: [] +} as const satisfies Record + +export type AgentSessionRefusalReason = + (typeof AGENT_SESSION_REFUSAL_REASONS)[C][number] + +/** A reason under any code, for a reader that keys words on the name alone. */ +export type AgentSessionAnyRefusalReason = AgentSessionRefusalReason + +type RewindFacts = { + /** Which rewind check refused. */ + rewindReason?: AgentSessionRewindReason +} + +type PromptFacts = { + /** The revision the host actually holds. */ + currentRevision?: number + /** The winning answer and who gave it. */ + resolution?: AgentJournalResolution +} + +type NoFacts = Record + +/** The facts a code carries beside its reason. */ +type AgentSessionRefusalFactsByCode = { + agent_session_operation_invalid: RewindFacts + agent_session_operation_unknown: RewindFacts + agent_session_checkpoint_stale: { + /** So the client can retry without another round trip. */ + currentFence?: number + } + agent_session_item_revision_stale: PromptFacts + agent_session_already_resolved: PromptFacts + agent_session_ownership_unknown: NoFacts + agent_session_conflict: NoFacts + execution_owner_reconciling: NoFacts + agent_session_identity_required: NoFacts + agent_session_operation_conflict: NoFacts + agent_session_operation_expired: NoFacts + agent_session_operation_capacity: NoFacts + agent_session_journal_unreadable: NoFacts + structured_agent_session_unsupported: NoFacts + agent_session_owner_restart_failed: NoFacts +} + +/** A durably failed create says whether anything still runs for it, whichever code it failed + * with: `exited` proves nothing does, so a new operation cannot collide with this one. */ +type AgentSessionRefusalCommonFacts = { ownerVerdict?: AgentSessionOwnerVerdict } + +export type AgentSessionRefusalFacts = + AgentSessionRefusalFactsByCode[C] & AgentSessionRefusalCommonFacts + +/** What an emitter must name: a reason its code lists, and that code's facts. */ +export type AgentSessionRefusalDetailsByCode = { + [C in AgentSessionWireRefusalCode]: { + reason: AgentSessionRefusalReason + } & AgentSessionRefusalFacts +} + +/** Details as the wire and a durable record carry them. The reason is absent where the host could + * not name the situation; the whole object is absent from older hosts. */ +export type AgentSessionRefusalDetails = { + reason?: AgentSessionRefusalReason +} & AgentSessionRefusalFacts + +/** Details under whichever code, for a store that keeps them beside a code it reads as a string. */ +export type AgentSessionAnyRefusalDetails = AgentSessionRefusalDetails + +/** The loose top-level fields released clients read; the host writes them only from details. */ +export type AgentSessionLegacyRefusalFields = { + currentFence?: number + currentRevision?: number + resolution?: AgentJournalResolution + ownerVerdict?: AgentSessionOwnerVerdict + rewindReason?: AgentSessionRewindReason +} + +const FACT_KEYS_BY_CODE: Partial< + Record +> = { + agent_session_operation_invalid: ['rewindReason'], + agent_session_operation_unknown: ['rewindReason'], + agent_session_checkpoint_stale: ['currentFence'], + agent_session_item_revision_stale: ['currentRevision', 'resolution'], + agent_session_already_resolved: ['currentRevision', 'resolution'] +} + +const OWNER_VERDICTS: readonly AgentSessionOwnerVerdict[] = ['live', 'unverifiable', 'exited'] + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readFact( + key: keyof AgentSessionLegacyRefusalFields, + value: unknown +): AgentSessionLegacyRefusalFields { + switch (key) { + case 'currentFence': + return Number.isSafeInteger(value) && typeof value === 'number' ? { currentFence: value } : {} + case 'currentRevision': + return Number.isSafeInteger(value) && typeof value === 'number' + ? { currentRevision: value } + : {} + case 'resolution': + return isAgentJournalResolution(value) ? { resolution: value } : {} + case 'ownerVerdict': { + const ownerVerdict = OWNER_VERDICTS.find((verdict) => verdict === value) + return ownerVerdict ? { ownerVerdict } : {} + } + case 'rewindReason': { + const rewindReason = AGENT_SESSION_REWIND_REASONS.find((reason) => reason === value) + return rewindReason ? { rewindReason } : {} + } + } +} + +export function isAgentSessionRefusalReason( + code: C, + value: unknown +): value is AgentSessionRefusalReason { + const reasons: readonly string[] = AGENT_SESSION_REFUSAL_REASONS[code] + return typeof value === 'string' && reasons.includes(value) +} + +/** + * Details as a reader meets them: from the wire, a ledger row or a stored failure, possibly + * written by another build. Keeps only a reason and facts this code lists, so a reason a newer + * host added reads as none; undefined when nothing is left. + */ +export function readAgentSessionRefusalDetails( + code: C, + value: unknown +): AgentSessionRefusalDetails | undefined { + if (!isRecord(value)) { + return undefined + } + const keys = [...(FACT_KEYS_BY_CODE[code] ?? []), 'ownerVerdict' as const] + const facts = keys.reduce( + (kept, key) => ({ ...kept, ...readFact(key, value[key]) }), + {} + ) + const read = { + ...(isAgentSessionRefusalReason(code, value.reason) ? { reason: value.reason } : {}), + ...facts + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reason was checked against `code`'s list and only the facts `code` lists (plus the verdict every code may carry) were kept. + return Object.keys(read).length > 0 ? (read as AgentSessionRefusalDetails) : undefined +} + +/** The loose fields released clients read, taken from details; the one place they are written. */ +export function agentSessionLegacyRefusalFields( + details: AgentSessionLegacyRefusalFields | undefined +): AgentSessionLegacyRefusalFields { + if (!details) { + return {} + } + const { currentFence, currentRevision, resolution, ownerVerdict, rewindReason } = details + return { + ...(rewindReason !== undefined ? { rewindReason } : {}), + ...(currentFence !== undefined ? { currentFence } : {}), + ...(resolution !== undefined ? { resolution } : {}), + ...(currentRevision !== undefined ? { currentRevision } : {}), + ...(ownerVerdict !== undefined ? { ownerVerdict } : {}) + } +} diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 5e2d55764b2..73652adbf1a 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -4,17 +4,27 @@ import { type AgentSessionWireRefusalCode } from './agent-session-wire-refusals' import { + agentSessionReadHistoryRefusalParts, agentSessionRefusalNotice, - agentSessionRpcErrorFailure, + agentSessionRefusalReasonWords, agentSessionWriteFailureNotice, - agentSessionWriteKindForMethod, agentSessionWriteNoticeEnglish, - agentSessionWriteNoticeParts, - parseAgentSessionWriteFailure, + agentSessionWriteNoticeParts +} from './agent-session-refusal-notice' +import { + AGENT_SESSION_WRITE_NOTICE_COPY, + type AgentSessionWriteNoticeSentence +} from './agent-session-write-notice-copy' +import { AGENT_SESSION_REFUSAL_REASONS } from './agent-session-refusal-details' +import { agentSessionFailureSentence } from './agent-session-failure-words' +import { + agentSessionRefusalFailure, + agentSessionRpcErrorFailure, + agentSessionWriteKindForMethod, type AgentSessionWriteFailure, type AgentSessionWriteKind, - type AgentSessionWriteNoticeSentence -} from './agent-session-refusal-notice' + type AgentSessionWriteRefusal +} from './agent-session-write-failure' import { DISPATCH_REJECTED_NOT_DELIVERED, DISPATCH_REJECTED_QUEUE_FULL, @@ -23,6 +33,7 @@ import { import { structuredAgentSessionRejectionParts } from './structured-agent-session-send-disposition' const WRITES: AgentSessionWriteKind[] = [ + 'read-history', 'send', 'composer-send', 'stop', @@ -48,7 +59,7 @@ const CAUSES: Partial = { + 'read-history': 'notDoneReadHistory', send: 'notDoneSend', 'composer-send': 'notDoneSend', stop: 'notDoneStop', @@ -97,6 +109,7 @@ function causeSaysNotDone( const code = codeOf(failure) return ( code === 'structured_agent_session_unsupported' || + (write === 'read-history' && code === 'agent_session_journal_unreadable') || (write === 'answer' && (code === 'agent_session_item_revision_stale' || code === 'agent_session_already_resolved')) ) @@ -183,7 +196,8 @@ describe('the notice for every failure and write', () => { // - item_revision_stale / already_resolved: "Item has moved on." (prompt-state) // - owner_restart_failed: " couldn't restart: .", where the cause is the resume's // own refusal message, including the ledger's (dead-generation-settlement, agent-start) - // - journal_unreadable: no emitter on this host; an older or newer one may send it. + // - journal_unreadable: "The conversation could not be opened: " (send preparation, before + // it named a reason) it('is never empty and never shows the host message', () => { for (const { failure, write, parts, cell } of cells) { expect(parts.length, cell).toBeGreaterThan(0) @@ -303,30 +317,208 @@ describe('agentSessionRefusalNotice', () => { }) }) -describe('parseAgentSessionWriteFailure', () => { - it('reads back what was saved, and nothing but the code', () => { - expect( - parseAgentSessionWriteFailure( - JSON.parse(JSON.stringify({ kind: 'refused', code: 'agent_session_conflict' })) +// Every (code x reason) a host can name, as a write keeps it. +const REASONED: AgentSessionWriteRefusal[] = AGENT_SESSION_WIRE_REFUSAL_CODES.flatMap((code) => + AGENT_SESSION_REFUSAL_REASONS[code].map((reason) => + agentSessionRefusalFailure({ code, details: { reason } }) + ) +) + +function reasonOf(failure: AgentSessionWriteRefusal): string { + return `${failure.code}/${failure.details?.reason}` +} + +describe('the notice for every reason a host names', () => { + const cells = REASONED.flatMap((failure) => + WRITES.map((write) => ({ + failure, + write, + parts: agentSessionWriteNoticeParts(failure, write), + words: agentSessionRefusalReasonWords(failure), + cell: `${reasonOf(failure)} x ${write}` + })) + ) + + it('has words for every reason', () => { + for (const { words, cell } of cells) { + expect(words, cell).toBeDefined() + } + }) + + it('keeps the code words where the table says the reason means nothing more', () => { + for (const { failure, write, parts, words, cell } of cells) { + if (words && 'words' in words) { + expect(parts, cell).toEqual( + agentSessionWriteNoticeParts({ ...failure, details: undefined }, write) + ) + } + } + }) + + // A retry is the control that sent the write; only an open that can clear says to try again. + it('names a step exactly where the person has one to take', () => { + for (const { words, cell } of cells) { + if (words && 'cause' in words) { + const retryNow = words.action === 'retry' && words.step === 'tryAgain' + expect(words.step !== undefined && !retryNow, cell).toBe( + words.action === 'wait' || words.action === 'actFirst' || words.action === 'goElsewhere' + ) + expect(retryNow, cell).toBe(words.cause === 'historyUnavailable') + } + } + }) + + it('says the write did not happen, once, and never shows the host message', () => { + for (const { failure, write, parts, cell } of cells) { + const english = agentSessionWriteNoticeEnglish(parts) + expect(english.length, cell).toBeGreaterThan(0) + expect(agentSessionRefusalNotice({ ...failure, message: HOST_TEXT }, write), cell).toBe( + english ) - ).toEqual({ kind: 'refused', code: 'agent_session_conflict' }) - expect(parseAgentSessionWriteFailure({ kind: 'failed' })).toEqual({ kind: 'failed' }) + if (failure.code === 'agent_session_operation_unknown') { + expect(parts, cell).toEqual(['outcomeUnknown']) + continue + } + const notDone = parts.filter((part) => typeof part === 'string' && part.startsWith('notDone')) + const answeredAway = write === 'answer' && parts.includes('questionChanged') + const unsupported = failure.code === 'structured_agent_session_unsupported' + const saysNotDone = + write === 'read-history' && failure.code === 'agent_session_journal_unreadable' + expect(notDone, cell).toEqual( + answeredAway || unsupported || saysNotDone ? [] : [NOT_DONE[write]] + ) + } + }) + + it("uses the failure's own sentence for a start that failed, and only for a message", () => { + const failure = agentSessionRefusalFailure({ + code: 'agent_session_operation_invalid', + details: { reason: 'notSignedIn' } + }) + expect(agentSessionWriteNoticeParts(failure, 'send')).toEqual([ + 'notDoneSend', + { text: agentSessionFailureSentence({ kind: 'notSignedIn' }, 'rejection') } + ]) + // Its next step is to send the message again, which a Stop cannot do. + expect(agentSessionWriteNoticeParts(failure, 'stop')).toEqual(['notDoneStop']) + }) + + it.each([ + [ + 'agent_session_operation_invalid', + 'conversationCleared', + 'send', + 'This conversation has been cleared. Your message was not sent. Open the current conversation to continue.' + ], + [ + 'agent_session_operation_invalid', + 'clearUnconfirmed', + 'send', + "The last /clear didn't finish. Your message was not sent. Start a new chat to continue." + ], + [ + 'agent_session_operation_invalid', + 'turnActive', + 'command', + "The agent is still responding. The command didn't run. Wait for the agent to finish responding, or stop it." + ], + [ + 'agent_session_conflict', + 'claimConflicted', + 'composer-send', + 'This chat is still open in a terminal agent. Your message was not sent. Quit that agent to continue the chat here.' + ], + [ + 'agent_session_conflict', + 'chatStarting', + 'stop', + "The agent is still starting. The agent wasn't stopped. Wait for the agent to finish starting." + ], + [ + 'agent_session_operation_invalid', + 'promptGone', + 'answer', + 'This question was already answered or has changed.' + ], + [ + 'agent_session_checkpoint_stale', + 'fenceStale', + 'composer-send', + 'Your message was not sent. Send it again.' + ] + ] as const)('%s / %s on %s', (code, reason, write, expected) => { expect( - parseAgentSessionWriteFailure({ - kind: 'refused', - code: 'agent_session_conflict', - message: HOST_TEXT - }) - ).toEqual({ kind: 'refused', code: 'agent_session_conflict' }) + agentSessionRefusalNotice({ code, message: HOST_TEXT, details: { reason } }, write) + ).toBe(expected) }) +}) +describe('a refusal from a host that names no reason this build knows', () => { it.each([ - null, - 'The agent was restarting.', - { kind: 'refused' }, - { kind: 'refused', code: 'agent_session_from_the_future' }, - { kind: 'something-else' } - ])('drops %j instead of guessing', (value) => { - expect(parseAgentSessionWriteFailure(value)).toBeUndefined() + ['an older host', undefined], + ['a reason a newer host added', { reason: 'fromTheFuture' }], + ['facts with no reason', { ownerVerdict: 'unverifiable' }] + ])("reads as the code's row: %s", (_label, details) => { + const refusal = JSON.parse( + JSON.stringify({ code: 'agent_session_operation_invalid', message: HOST_TEXT, details }) + ) + expect(agentSessionRefusalNotice(refusal, 'command')).toBe("The command didn't run.") + }) +}) + +it('keeps a sentence the failure rows share word for word', () => { + expect(agentSessionFailureSentence({ kind: 'historyTooLarge' }, 'row')).toContain( + AGENT_SESSION_WRITE_NOTICE_COPY.startNewChat + ) +}) + +// A failed journal open says what happened to the history, not what was asked of it. +describe('a chat whose history the host could not open', () => { + const notice = (details: unknown, write: AgentSessionWriteKind): string => + agentSessionRefusalNotice( + JSON.parse( + JSON.stringify({ code: 'agent_session_journal_unreadable', message: HOST_TEXT, details }) + ), + write + ) + + it.each([ + ['journalCorrupt', 'read-history', 'Unable to load this chat.'], + ['journalCorrupt', 'send', 'Unable to load this chat. Your message was not sent.'], + [ + 'journalUnavailable', + 'read-history', + "Orca couldn't open this chat's history right now. Try again." + ], + [ + 'journalUnavailable', + 'send', + "Orca couldn't open this chat's history right now. Your message was not sent. Try again." + ] + ] as const)('%s on %s', (reason, write, expected) => { + expect(notice({ reason }, write)).toBe(expected) + }) + + // Damage can't be told apart from an open that can clear, so it never says to start over. + it.each([ + ['an older host', undefined], + ['a reason an unreleased build wrote', { reason: 'journalUnreadable' }] + ])('promises nothing for a refusal that names no reason: %s', (_label, details) => { + expect(notice(details, 'read-history')).toBe("Orca couldn't read this chat's saved history.") + expect(notice(details, 'send')).toBe( + "Orca couldn't read this chat's saved history. Your message was not sent." + ) + expect( + agentSessionReadHistoryRefusalParts('agent_session_journal_unreadable', details) + ).toEqual(['historyUnreadable']) + }) + + it('says only that the history did not load for any other read refusal', () => { + expect(agentSessionReadHistoryRefusalParts('agent_session_ownership_unknown')).toEqual([ + 'notDoneReadHistory' + ]) + expect(agentSessionReadHistoryRefusalParts('agent_session_from_the_future')).toEqual([ + 'notDoneReadHistory' + ]) }) }) diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 4b3db2be0a6..818031b8191 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -1,127 +1,36 @@ // The one way a chat surface puts a write that did not happen into words. // // A refusal's `message` is never shown. Every code has at least one host emitter whose message is -// written for a log or carries a marker (the census is pinned in the test), so the code and the -// write pick the copy. A cause is named only where every emitter of the code means it. No notice -// says how to try again: the control that sent the write does that, except on the phone, whose -// message goes back to the composer. Surfaces keep the fact and choose the words when they show -// it, so nothing saved carries copy. +// written for a log or carries a marker (the census is pinned in the test), so the refusal's +// reason, when the host names one, and otherwise its code, pick the copy with the write. A code's +// own row names a cause only where every emitter of the code means it; it is also the words for a +// host too old to send a reason. A notice says how to get past a refusal only where the person has +// a step to take; retrying is the control that sent the write, except on the phone, whose message +// goes back to the composer, and a history that couldn't open right now says to try again. +// Surfaces keep the fact and choose the words when they show it, so nothing saved carries copy. +import type { AgentSessionFailureKind } from './agent-session-failure' +import { agentSessionFailureSentence } from './agent-session-failure-words' +import type { AgentSessionRefusalReason } from './agent-session-refusal-details' import { - isAgentSessionWireRefusalCode, - type AgentSessionWireRefusal, - type AgentSessionWireRefusalCode + AGENT_SESSION_WRITE_NOTICE_COPY, + type AgentSessionWriteNoticePart, + type AgentSessionWriteNoticeSentence +} from './agent-session-write-notice-copy' +import type { + AgentSessionWireRefusal, + AgentSessionWireRefusalCode } from './agent-session-wire-refusals' - -/** What the person was doing, which decides what the notice says did not happen. `send` keeps - * the message behind a Retry control; `composer-send` puts it back in the composer, as the phone - * does. */ -export type AgentSessionWriteKind = - | 'send' - | 'composer-send' - | 'stop' - | 'stop-task' - | 'stop-tasks' - | 'answer' - | 'option' - | 'command' - | 'goal' - -/** The kind of write an `agentSession.*` call stands for. */ -export function agentSessionWriteKindForMethod( - fingerprintMethod: string, - fields: Record -): AgentSessionWriteKind { - if (fingerprintMethod === 'agentSession.send') { - return 'send' - } - if (fingerprintMethod === 'agentSession.cancel') { - // A background-task stop never asked the agent to stop. - if (fields.scope !== 'background-tasks') { - return 'stop' - } - return typeof fields.taskId === 'string' ? 'stop-task' : 'stop-tasks' - } - if (fingerprintMethod.startsWith('agentSession.respondTo')) { - return 'answer' - } - if (fingerprintMethod === 'agentSession.setOption') { - return 'option' - } - if (fingerprintMethod === 'agentSession.threadGoal') { - return 'goal' - } - return 'command' -} - -/** Why a write did not happen, or that nothing proves it did not. */ -export type AgentSessionWriteFailure = - | { kind: 'refused'; code: AgentSessionWireRefusalCode } - /** The request failed without a refusal before the host ran it, so nothing is known about why. */ - | { kind: 'failed' } - /** The request failed where the host may already have run it (a timeout, a lost connection, an - * error inside the method). */ - | { kind: 'unconfirmed' } - -export function agentSessionRefusalFailure( - refusal: Pick -): AgentSessionWriteFailure { - return { kind: 'refused', code: refusal.code } -} - -/** A request that threw, from the RPC error code the host answered with (undefined when none came - * back). Only a host that turned it away before running the method proves the write did not - * happen. */ -export function agentSessionRpcErrorFailure(code: string | undefined): AgentSessionWriteFailure { - if (code === 'method_not_found' || code === 'method_not_supported') { - return { kind: 'refused', code: 'structured_agent_session_unsupported' } - } - return code === 'invalid_argument' || code === 'unauthorized' - ? { kind: 'refused', code: 'agent_session_operation_invalid' } - : { kind: 'unconfirmed' } -} - -/** A saved failure, or undefined when it is not one this build wrote. */ -export function parseAgentSessionWriteFailure( - value: unknown -): AgentSessionWriteFailure | undefined { - if (typeof value !== 'object' || value === null || !('kind' in value)) { - return undefined - } - if (value.kind === 'failed') { - return { kind: 'failed' } - } - return value.kind === 'refused' && 'code' in value && isAgentSessionWireRefusalCode(value.code) - ? { kind: 'refused', code: value.code } - : undefined -} - -/** Every sentence a notice is made of. Desktop translates each whole sentence with this as its - * fallback; mobile shows it as is. */ -export const AGENT_SESSION_WRITE_NOTICE_COPY = { - notDoneSend: 'Your message was not sent.', - tryAgainComposerSend: 'Send it again.', - notDoneStop: "The agent wasn't stopped.", - notDoneStopTask: "The background task wasn't stopped.", - notDoneStopTasks: "The background tasks weren't stopped.", - notDoneAnswer: 'Your answer was not sent.', - notDoneOption: "The setting wasn't changed.", - notDoneCommand: "The command didn't run.", - notDoneGoal: "The goal wasn't changed.", - restartFailed: "The agent couldn't restart.", - capacity: 'Orca has received too many requests in the last day.', - outcomeUnknown: "Orca couldn't confirm what happened. Check the chat.", - questionChanged: 'This question was already answered or has changed.', - historyUnreadable: "Orca couldn't read this chat's saved history.", - unsupported: "The Orca running this chat doesn't support this. Update Orca, then try again.", - unreachable: "Orca couldn't reach the agent." -} as const - -export type AgentSessionWriteNoticeSentence = keyof typeof AGENT_SESSION_WRITE_NOTICE_COPY -/** A notice as whole sentences, each translated on its own; `text` is a provider's own words. */ -export type AgentSessionWriteNoticePart = AgentSessionWriteNoticeSentence | { text: string } +import { + agentSessionRefusalFailure, + parseAgentSessionWriteFailure, + type AgentSessionWriteFailure, + type AgentSessionWriteKind, + type AgentSessionWriteRefusal +} from './agent-session-write-failure' const NOT_DONE: Record = { + 'read-history': 'notDoneReadHistory', send: 'notDoneSend', 'composer-send': 'notDoneSend', stop: 'notDoneStop', @@ -142,6 +51,222 @@ export function agentSessionWriteNotDoneParts( return write === 'composer-send' ? ['notDoneSend', 'tryAgainComposerSend'] : [NOT_DONE[write]] } +/** What the person can do about a refusal with this reason. */ +export type AgentSessionRefusalAction = + /** Use the control that sent the write again. */ + | 'retry' + /** Wait for what the notice names; its step says what. */ + | 'wait' + /** Take the step the notice names first. */ + | 'actFirst' + /** Continue somewhere else: the current conversation, or a new chat. */ + | 'goElsewhere' + | 'checkChat' + | 'updateOrca' + /** What the write was for is over: the question it answered moved on. */ + | 'nothingLeft' + /** Orca's own state or fault; nothing the person does gets past it. */ + | 'hostFinding' + +export type AgentSessionRefusalReasonWords = + /** The code's own words are the honest ones for this reason. */ + | { words: 'code'; action: AgentSessionRefusalAction } + /** What stopped the write, and the step past it where the person has one to take. */ + | { + cause: AgentSessionWriteNoticeSentence + step?: AgentSessionWriteNoticeSentence + action: AgentSessionRefusalAction + } + /** A start that failed: the sentence that failure has everywhere, whose next step is a send. */ + | { fact: AgentSessionFailureKind; action: AgentSessionRefusalAction } + +type AgentSessionRefusalCauseWords = Extract + +function codeWords(action: AgentSessionRefusalAction): AgentSessionRefusalReasonWords { + return { words: 'code', action } +} + +function causeWords( + cause: AgentSessionWriteNoticeSentence, + action: AgentSessionRefusalAction, + step?: AgentSessionWriteNoticeSentence +): AgentSessionRefusalReasonWords { + return step ? { cause, step, action } : { cause, action } +} + +const AGENT_STARTING = causeWords('agentStarting', 'wait', 'waitForStart') +const OWNER_UNPROVEN = causeWords('ownerUnproven', 'actFirst', 'reopenChat') +// Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. +const TERMINAL_CLAIM = causeWords('terminalAgentHoldsChat', 'actFirst', 'quitTerminalAgent') + +// Every reason of every code, so a reason the host adds does not compile until it has words. +// Reasons only a create, attach, hold or adopted import meets never reach a chat write; they keep +// their code's words. +const REASON_WORDS = { + agent_session_operation_invalid: { + requestMalformed: codeWords('hostFinding'), + operationIdInvalid: codeWords('hostFinding'), + messageIdReused: codeWords('hostFinding'), + // Settled under that id, so the control's retry goes out under a new one. + operationRefusedEarlier: codeWords('retry'), + journalWriteFailed: causeWords('recordFailed', 'retry'), + conversationCleared: causeWords( + 'conversationCleared', + 'goElsewhere', + 'openCurrentConversation' + ), + // Nothing settles an unfinished /clear yet, so only a new chat continues. + clearUnconfirmed: causeWords('clearUnfinished', 'goElsewhere', 'startNewChat'), + // A /clear or /compact whose outcome the host never settled; only the host resolves it. + conversationCommandUnconfirmed: codeWords('hostFinding'), + conversationCommandInFlight: causeWords('commandRunning', 'wait', 'waitForCommand'), + // The chat's agent process is being replaced, which a start or restart does. + handoffInFlight: AGENT_STARTING, + turnActive: causeWords('turnActive', 'wait', 'waitForTurn'), + promptPending: causeWords('promptPending', 'actFirst', 'answerFirst'), + backgroundTasksRunning: causeWords('backgroundTasksRunning', 'wait', 'waitForBackgroundTasks'), + messagesUnsettled: causeWords('messagesUnsettled', 'actFirst', 'settleEarlierMessage'), + // No chat surface sends a rewind; a replayed one says only that it did not happen. + rewindRefused: codeWords('hostFinding'), + rewindUnconfirmed: codeWords('hostFinding'), + promptGone: causeWords('questionChanged', 'nothingLeft'), + optionRejected: causeWords('optionRejected', 'retry'), + providerStarting: AGENT_STARTING, + goalsUnsupported: causeWords('goalsUnsupported', 'hostFinding'), + providerRejected: causeWords('agentRefused', 'retry'), + providerStartFailed: { fact: 'providerStartFailed', action: 'retry' }, + notSignedIn: { fact: 'notSignedIn', action: 'actFirst' }, + historyTooLarge: { fact: 'historyTooLarge', action: 'goElsewhere' }, + managedAccountEnvOverride: { fact: 'managedAccountEnvOverride', action: 'actFirst' }, + accountSwitchInProgress: { fact: 'accountSwitchInProgress', action: 'wait' }, + managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' }, + attachFailed: codeWords('retry') + }, + agent_session_ownership_unknown: { + sessionNotAttached: codeWords('retry'), + noLiveOwner: codeWords('retry'), + ownerUnproven: OWNER_UNPROVEN, + claimConflicted: TERMINAL_CLAIM, + recordMissing: codeWords('retry'), + replaySuperseded: codeWords('retry'), + leaseMoved: codeWords('retry'), + spawnIdentityMismatch: codeWords('hostFinding'), + notResumable: codeWords('retry'), + noProviderChild: codeWords('retry'), + conversationHeldElsewhere: codeWords('retry') + }, + agent_session_conflict: { + chatStarting: AGENT_STARTING, + ownerUnproven: OWNER_UNPROVEN, + claimConflicted: TERMINAL_CLAIM, + ownerAlive: codeWords('retry'), + identityMismatch: codeWords('hostFinding'), + sessionExists: codeWords('retry'), + conversationHeldElsewhere: codeWords('retry'), + tabIdTaken: codeWords('retry') + }, + execution_owner_reconciling: { + hostReconciling: causeWords('hostReconciling', 'wait', 'waitMoment'), + recordUnreadable: causeWords('recordUnreadable', 'hostFinding') + }, + agent_session_checkpoint_stale: { + fenceStale: codeWords('retry'), + leaseMoved: codeWords('retry'), + recordMissing: codeWords('retry') + }, + agent_session_identity_required: { + recordMissing: causeWords('chatNotFound', 'goElsewhere', 'startNewChat'), + transcriptNotFound: codeWords('retry'), + transcriptUnreadable: codeWords('hostFinding') + }, + agent_session_operation_conflict: { + fingerprintMismatch: codeWords('hostFinding'), + operationIdReused: codeWords('hostFinding'), + handoffInFlight: codeWords('retry') + }, + agent_session_operation_expired: { operationExpired: codeWords('retry') }, + agent_session_operation_capacity: { operationCapacity: codeWords('wait') }, + agent_session_operation_unknown: { + outcomeUnknown: codeWords('checkChat'), + resultLost: codeWords('checkChat'), + rewindUnconfirmed: codeWords('checkChat'), + tabUnconfirmed: codeWords('checkChat') + }, + agent_session_item_revision_stale: { promptMoved: codeWords('nothingLeft') }, + agent_session_already_resolved: { promptAlreadyResolved: codeWords('nothingLeft') }, + agent_session_journal_unreadable: { + // No retry reads past damage, and the words name no step: it only can't load. + journalCorrupt: causeWords('historyUnusable', 'hostFinding'), + // Says its step despite 'retry': released clients and the phone often show no Retry here. + journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain') + }, + // Thrown, so a client meets these only as an RPC error; the code's words stand. + structured_agent_session_unsupported: { + clientCapabilityMissing: codeWords('updateOrca'), + hostDisabled: codeWords('hostFinding'), + hostUnsupported: codeWords('updateOrca') + }, + agent_session_owner_restart_failed: {} +} satisfies { + [C in AgentSessionWireRefusalCode]: Record< + AgentSessionRefusalReason, + AgentSessionRefusalReasonWords + > +} + +/** The words and next step a reason gets; undefined when the refusal names none. */ +export function agentSessionRefusalReasonWords( + failure: AgentSessionWriteRefusal +): AgentSessionRefusalReasonWords | undefined { + const reason = failure.details?.reason + const byReason: Partial> | undefined = + REASON_WORDS[failure.code] + return reason === undefined ? undefined : byReason?.[reason] +} + +// Each already says the history was not read. +const HISTORY_CAUSES: ReadonlySet = new Set([ + 'historyUnusable', + 'historyUnavailable', + 'historyUnreadable' +]) + +/** A cause, and that the request did not happen unless the cause already says so. */ +function causeParts( + cause: AgentSessionWriteNoticeSentence, + write: AgentSessionWriteKind +): AgentSessionWriteNoticeSentence[] { + const saysNotDone = + (write === 'read-history' && HISTORY_CAUSES.has(cause)) || + (cause === 'questionChanged' && write === 'answer') + return saysNotDone ? [cause] : [cause, NOT_DONE[write]] +} + +function causeWordsParts( + words: AgentSessionRefusalCauseWords, + write: AgentSessionWriteKind +): AgentSessionWriteNoticeSentence[] { + const said = causeParts(words.cause, write) + return words.step ? [...said, words.step] : said +} + +/** The notice a named reason has of its own; undefined leaves the code's words. */ +function reasonParts( + failure: AgentSessionWriteRefusal, + write: AgentSessionWriteKind +): AgentSessionWriteNoticePart[] | undefined { + const words = agentSessionRefusalReasonWords(failure) + if (!words || 'words' in words) { + return undefined + } + if ('fact' in words) { + return write === 'send' || write === 'composer-send' + ? [NOT_DONE[write], { text: agentSessionFailureSentence({ kind: words.fact }, 'rejection') }] + : undefined + } + return causeWordsParts(words, write) +} + export function agentSessionWriteNoticeParts( failure: AgentSessionWriteFailure, write: AgentSessionWriteKind @@ -153,6 +278,10 @@ export function agentSessionWriteNoticeParts( if (failure.kind === 'unconfirmed') { return ['outcomeUnknown'] } + const byReason = reasonParts(failure, write) + if (byReason) { + return byReason + } switch (failure.code) { // The cause is in the chat's own status row. Some restarts can be retried and some need a new // chat, and the code does not say which. @@ -182,9 +311,10 @@ export function agentSessionWriteNoticeParts( // A Stop names the prompt it was pressed under, so it can be refused this way too. case 'agent_session_item_revision_stale': case 'agent_session_already_resolved': - return write === 'answer' ? ['questionChanged'] : ['questionChanged', notDone] + return causeParts('questionChanged', write) + // A host that names no reason raised it for damage and for an open that can clear alike. case 'agent_session_journal_unreadable': - return ['historyUnreadable', notDone] + return causeParts('historyUnreadable', write) case 'structured_agent_session_unsupported': return ['unsupported'] } @@ -203,7 +333,7 @@ export function agentSessionWriteNoticeEnglish( /** English, for a surface without translations. Takes the refusal as the wire gives it; its * message is not read. */ export function agentSessionRefusalNotice( - refusal: Pick, + refusal: Pick, write: AgentSessionWriteKind ): string { return agentSessionWriteNoticeEnglish( @@ -215,3 +345,15 @@ export function agentSessionRefusalNotice( export function agentSessionWriteFailureNotice(write: AgentSessionWriteKind): string { return agentSessionWriteNoticeEnglish(agentSessionWriteNoticeParts({ kind: 'failed' }, write)) } + +/** The notice for a refused read of a chat's history, from the refusal's code and any details a + * host sent; a code this build does not know says only that the history did not load. */ +export function agentSessionReadHistoryRefusalParts( + code: string, + details?: unknown +): AgentSessionWriteNoticePart[] { + const failure = parseAgentSessionWriteFailure({ kind: 'refused', code, details }) + return failure + ? agentSessionWriteNoticeParts(failure, 'read-history') + : agentSessionWriteNotDoneParts('read-history') +} diff --git a/src/shared/agent-session-refusal-retry.test.ts b/src/shared/agent-session-refusal-retry.test.ts new file mode 100644 index 00000000000..30a9a177f8e --- /dev/null +++ b/src/shared/agent-session-refusal-retry.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionOwnerVerdict } from './agent-session-wire' +import { agentSessionOwnerVerdictAllowsFreshOperationId } from './agent-session-refusal-retry' +import { parseAgentSessionWriteFailure } from './agent-session-write-failure' +import { + admitStructuredAgentSessionOutboxEntry, + createStructuredAgentSessionOutboxEntry, + requeueStructuredAgentSessionSendRefusal +} from './structured-agent-session-outbox' +import { disposeStructuredAgentSessionSendResult } from './structured-agent-session-send-disposition' + +const entry = createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'message-1', + sessionId: 'session-1', + text: 'hello', + attachments: [], + queuedAt: 1 +}) + +/** A refused send as a reloaded entry holds it: through the saved shape and back. */ +function storedOwnershipRefusal(ownerVerdict: AgentSessionOwnerVerdict | undefined) { + const failure = parseAgentSessionWriteFailure( + JSON.parse( + JSON.stringify({ + kind: 'refused', + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven', ...(ownerVerdict ? { ownerVerdict } : {}) } + }) + ) + ) + if (failure?.kind !== 'refused') { + throw new Error('the saved refusal did not read back') + } + return failure +} + +function retried(ownerVerdict: AgentSessionOwnerVerdict | undefined) { + return requeueStructuredAgentSessionSendRefusal( + entry, + storedOwnershipRefusal(ownerVerdict), + () => 'message-2' + ) +} + +describe("the owner's verdict decides a retry's operation id only as a floor", () => { + it('lets a retry use a new id once the stored verdict proves nothing runs', () => { + expect(agentSessionOwnerVerdictAllowsFreshOperationId('exited')).toBe(true) + // A new id only: nothing recorded the message, so it stays queued at the head. + expect(retried('exited')).toMatchObject({ clientMessageId: 'message-2', state: 'queued' }) + }) + + it('keeps later sends behind the message after an exited refusal', () => { + const later = createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'message-3', + sessionId: 'session-1', + text: 'after', + attachments: [], + queuedAt: 2 + }) + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry, later], + entry, + blockedClientMessageId: null, + result: { + ok: false, + refusal: { + code: 'agent_session_ownership_unknown', + message: 'x', + details: { reason: 'ownerUnproven', ownerVerdict: 'exited' } + } + }, + createOperationId: () => 'message-2' + }) + expect(disposition.blockedClientMessageId).toBe('message-2') + expect( + admitStructuredAgentSessionOutboxEntry( + disposition.entries, + disposition.blockedClientMessageId + ) + ).toMatchObject({ state: 'blocked', entry: { clientMessageId: 'message-2' } }) + }) + + it.each(['unverifiable', 'live', undefined])( + 'keeps the id for a stored %s verdict, whose operation may still land', + (stored) => { + expect(agentSessionOwnerVerdictAllowsFreshOperationId(stored)).toBe(false) + expect(retried(stored)).toMatchObject({ clientMessageId: 'message-1', state: 'queued' }) + } + ) + + it.each(['unverifiable', 'live', undefined])( + 'moves off a stored %s verdict only when the current lease proves the owner exited', + (stored) => { + expect(agentSessionOwnerVerdictAllowsFreshOperationId(stored, 'exited')).toBe(true) + expect(agentSessionOwnerVerdictAllowsFreshOperationId(stored, 'unverifiable')).toBe(false) + expect(agentSessionOwnerVerdictAllowsFreshOperationId(stored, 'live')).toBe(false) + } + ) + + it('never lowers a stored exited', () => { + expect(agentSessionOwnerVerdictAllowsFreshOperationId('exited', 'live')).toBe(true) + expect(agentSessionOwnerVerdictAllowsFreshOperationId('exited', 'unverifiable')).toBe(true) + }) +}) diff --git a/src/shared/agent-session-refusal-retry.ts b/src/shared/agent-session-refusal-retry.ts index 9777cfd42d1..093d3549729 100644 --- a/src/shared/agent-session-refusal-retry.ts +++ b/src/shared/agent-session-refusal-retry.ts @@ -1,4 +1,4 @@ -import type { AgentSessionWireRefusalCode } from './agent-session-wire' +import type { AgentSessionOwnerVerdict, AgentSessionWireRefusalCode } from './agent-session-wire' export type AgentSessionRefusalOperationState = 'settled-rejected' | 'pending-admission' | 'unknown' @@ -28,3 +28,16 @@ export function agentSessionRefusalOperationState( return 'pending-admission' } } + +/** + * Whether a verdict lets a retry use a new operation id. A stored `exited` is final: nothing runs + * the refused operation, so a new id cannot collide with it. Any other stored verdict is a floor: + * the first operation may still land, so only a verdict re-derived from the current lease can prove + * `exited`, and nothing lowers a stored `exited`. + */ +export function agentSessionOwnerVerdictAllowsFreshOperationId( + stored: AgentSessionOwnerVerdict | undefined, + current?: AgentSessionOwnerVerdict +): boolean { + return stored === 'exited' || current === 'exited' +} diff --git a/src/shared/agent-session-wire-refusals.test.ts b/src/shared/agent-session-wire-refusals.test.ts new file mode 100644 index 00000000000..af017a617e7 --- /dev/null +++ b/src/shared/agent-session-wire-refusals.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_REFUSAL_REASONS, + readAgentSessionRefusalDetails +} from './agent-session-refusal-details' +import { + AGENT_SESSION_WIRE_REFUSAL_CODES, + agentSessionRefusalError, + agentSessionRefusalReference, + readAgentSessionRefusalReference, + refuse, + refuseUnclassified, + withAgentSessionRefusalFacts, + type AgentSessionWireRefusal +} from './agent-session-wire-refusals' +import type { AgentJournalResolution } from './agent-session-journal-types' + +const RESOLUTION: AgentJournalResolution = { + state: 'resolved', + selectedOptionId: 'allow', + resolvedBy: 'phone-1', + resolvedAt: 1_789_000_000_000 +} + +/** The refusal with its new field removed: exactly what a released client reads. */ +function asReleasedClientSeesIt(refusal: AgentSessionWireRefusal): unknown { + const { details: _details, ...rest } = JSON.parse(JSON.stringify(refusal)) + return rest +} + +describe('a refusal names only a reason its code lists', () => { + it('rejects a reason another code lists, at compile time', () => { + // @ts-expect-error `promptGone` is an operation_invalid reason, not a conflict one. + refuse('agent_session_conflict', { reason: 'promptGone' }, 'm') + // @ts-expect-error the thrown form is held to the same list. + agentSessionRefusalError('agent_session_identity_required', { reason: 'claimConflicted' }) + // @ts-expect-error a fact another code carries is not this code's. + refuse('agent_session_conflict', { reason: 'ownerAlive', currentFence: 3 }, 'm') + // @ts-expect-error the unclassified path cannot carry a reason. + refuseUnclassified('agent_session_conflict', 'm', { reason: 'claimConflicted' }) + expect(refuse('agent_session_conflict', { reason: 'ownerAlive' }, 'm').details).toEqual({ + reason: 'ownerAlive' + }) + }) + + it('has no catch-all reason under any code', () => { + for (const code of AGENT_SESSION_WIRE_REFUSAL_CODES) { + for (const reason of AGENT_SESSION_REFUSAL_REASONS[code]) { + expect([code, reason]).not.toEqual([code, expect.stringMatching(/^(other|unknown)$/i)]) + expect(reason).not.toMatch(/fault|generic|misc/i) + } + } + }) +}) + +describe('the loose fields released clients read', () => { + it('are copied from details by refuse, one field at a time', () => { + expect( + refuse('agent_session_checkpoint_stale', { reason: 'fenceStale', currentFence: 4 }, 'm') + ).toMatchObject({ currentFence: 4 }) + expect( + refuse( + 'agent_session_item_revision_stale', + { reason: 'promptMoved', currentRevision: 2, resolution: RESOLUTION }, + 'm' + ) + ).toMatchObject({ currentRevision: 2, resolution: RESOLUTION }) + expect( + refuse( + 'agent_session_operation_invalid', + { reason: 'rewindRefused', rewindReason: 'busy' }, + 'm' + ) + ).toMatchObject({ rewindReason: 'busy' }) + expect( + refuse( + 'agent_session_ownership_unknown', + { reason: 'ownerUnproven', ownerVerdict: 'exited' }, + 'm' + ) + ).toMatchObject({ ownerVerdict: 'exited' }) + expect( + refuseUnclassified('agent_session_checkpoint_stale', 'm', { currentFence: 9 }) + ).toMatchObject({ currentFence: 9, details: { currentFence: 9 } }) + }) + + it('gain a verdict learned after the refusal, in details and at the top level', () => { + const stamped = withAgentSessionRefusalFacts( + refuse('agent_session_operation_invalid', { reason: 'providerStartFailed' }, 'm'), + { ownerVerdict: 'exited' } + ) + expect(stamped).toEqual({ + code: 'agent_session_operation_invalid', + message: 'm', + details: { reason: 'providerStartFailed', ownerVerdict: 'exited' }, + ownerVerdict: 'exited' + }) + // A refusal an older row replayed, with no reason, still reaches released clients with it. + expect( + withAgentSessionRefusalFacts(refuseUnclassified('agent_session_conflict', 'm'), { + ownerVerdict: 'unverifiable' + }) + ).toEqual({ + code: 'agent_session_conflict', + message: 'm', + details: { ownerVerdict: 'unverifiable' }, + ownerVerdict: 'unverifiable' + }) + }) + + // Each expected value is the literal the host built for the same situation before details. + it.each([ + [ + 'a moved prompt', + refuse( + 'agent_session_item_revision_stale', + { reason: 'promptMoved', currentRevision: 2, resolution: RESOLUTION }, + 'Item i has moved on.' + ), + { + code: 'agent_session_item_revision_stale', + message: 'Item i has moved on.', + currentRevision: 2, + resolution: RESOLUTION + } + ], + [ + 'a resolved prompt', + refuse( + 'agent_session_already_resolved', + { reason: 'promptAlreadyResolved', currentRevision: 2, resolution: RESOLUTION }, + 'Item i was already resolved.' + ), + { + code: 'agent_session_already_resolved', + message: 'Item i was already resolved.', + currentRevision: 2, + resolution: RESOLUTION + } + ], + [ + 'a stale fence', + refuse('agent_session_checkpoint_stale', { reason: 'fenceStale', currentFence: 4 }, 'm'), + { code: 'agent_session_checkpoint_stale', message: 'm', currentFence: 4 } + ], + [ + 'a refused rewind', + refuse( + 'agent_session_operation_invalid', + { reason: 'rewindRefused', rewindReason: 'busy' }, + 'agent_session_rewind:busy' + ), + { + code: 'agent_session_operation_invalid', + message: 'agent_session_rewind:busy', + rewindReason: 'busy' + } + ], + [ + 'a failed create', + withAgentSessionRefusalFacts( + refuse('agent_session_operation_invalid', { reason: 'providerStartFailed' }, 'exited 1'), + { ownerVerdict: 'exited' } + ), + { code: 'agent_session_operation_invalid', message: 'exited 1', ownerVerdict: 'exited' } + ], + [ + 'a refusal with no facts', + refuse('agent_session_conflict', { reason: 'claimConflicted' }, 'm'), + { code: 'agent_session_conflict', message: 'm' } + ] + ] as const)('read by a released client exactly as before for %s', (_label, refusal, before) => { + expect(asReleasedClientSeesIt(refusal)).toEqual(before) + }) +}) + +describe('a stored refusal read back', () => { + it('keeps what the code lists and drops the rest', () => { + expect( + readAgentSessionRefusalDetails('agent_session_checkpoint_stale', { + reason: 'fenceStale', + currentFence: 3, + currentRevision: 8, + cause: 'fenceStale' + }) + ).toEqual({ reason: 'fenceStale', currentFence: 3 }) + // A reason a newer host added reads as none, but the facts beside it still count. + expect( + readAgentSessionRefusalDetails('agent_session_checkpoint_stale', { + reason: 'futureReason', + currentFence: 3 + }) + ).toEqual({ currentFence: 3 }) + expect(readAgentSessionRefusalDetails('agent_session_conflict', { reason: 'x' })).toBe( + undefined + ) + }) + + it('round-trips through its reference, and reads an older shape as naming nothing', () => { + const refusal = refuse( + 'agent_session_ownership_unknown', + { reason: 'noLiveOwner', ownerVerdict: 'live' }, + 'm' + ) + const reference = agentSessionRefusalReference(refusal) + expect(reference).toEqual({ + code: 'agent_session_ownership_unknown', + details: { reason: 'noLiveOwner', ownerVerdict: 'live' } + }) + expect(readAgentSessionRefusalReference(JSON.parse(JSON.stringify(reference)))).toEqual( + reference + ) + expect( + readAgentSessionRefusalReference({ code: 'agent_session_conflict', cause: 'claimConflicted' }) + ).toEqual({ code: 'agent_session_conflict' }) + expect(readAgentSessionRefusalReference({ code: 'agent_session_future' })).toBeUndefined() + }) +}) diff --git a/src/shared/agent-session-wire-refusals.ts b/src/shared/agent-session-wire-refusals.ts index 12cd63d4490..8fd1602377b 100644 --- a/src/shared/agent-session-wire-refusals.ts +++ b/src/shared/agent-session-wire-refusals.ts @@ -1,8 +1,24 @@ // How the host declines an `agentSession.*` mutation: the closed code list, its // narrowing guard, and the refusal body a client reads. -import type { AgentJournalResolution } from './agent-session-journal-types' -import type { AgentSessionRewindReason } from './agent-session-rewind' +import { + agentSessionLegacyRefusalFields, + readAgentSessionRefusalDetails, + type AgentSessionAnyRefusalDetails, + type AgentSessionLegacyRefusalFields, + type AgentSessionRefusalDetails, + type AgentSessionRefusalDetailsByCode, + type AgentSessionRefusalFacts +} from './agent-session-refusal-details' + +export type { + AgentSessionAnyRefusalDetails, + AgentSessionAnyRefusalReason, + AgentSessionLegacyRefusalFields, + AgentSessionRefusalDetails, + AgentSessionRefusalDetailsByCode, + AgentSessionRefusalReason +} from './agent-session-refusal-details' export const AGENT_SESSION_WIRE_REFUSAL_CODES = [ 'structured_agent_session_unsupported', @@ -38,17 +54,140 @@ export function isAgentSessionWireRefusalCode( /** What the host last proved about a session's provider process; see the SSH execution boundary. */ export type AgentSessionOwnerVerdict = 'live' | 'unverifiable' | 'exited' -export type AgentSessionWireRefusal = { - rewindReason?: AgentSessionRewindReason - code: AgentSessionWireRefusalCode +/** One variant per code, so a refusal's details can only be that code's. */ +export type AgentSessionRefusalOf = { + [K in C]: { + code: K + /** For logs and released clients, which print it; newer clients choose words from details. */ + message: string + /** Absent from older hosts. */ + details?: AgentSessionRefusalDetails + } & AgentSessionLegacyRefusalFields +}[C] + +export type AgentSessionWireRefusal = AgentSessionRefusalOf + +/** A refusal named without its prose: what a durable record or an error's data keeps. */ +export type AgentSessionRefusalReference = { + [K in AgentSessionWireRefusalCode]: { code: K; details?: AgentSessionRefusalDetails } +}[AgentSessionWireRefusalCode] + +function buildRefusal( + code: AgentSessionWireRefusalCode, + details: AgentSessionAnyRefusalDetails | undefined, message: string - /** On a stale fence, so the client can retry without another round trip. */ - currentFence?: number - /** On a lost compare-and-set: the winning answer and who gave it. */ - resolution?: AgentJournalResolution - /** On a lost compare-and-set: the revision the host actually holds. */ - currentRevision?: number - /** On a durably failed create: `exited` proves nothing runs for the session, so a new - * operation cannot collide with this one. Absent (older hosts) reads as unverifiable. */ - ownerVerdict?: AgentSessionOwnerVerdict +): AgentSessionWireRefusal { + const kept = details && Object.keys(details).length > 0 ? details : undefined + const refusal = { + code, + message, + ...(kept ? { details: kept } : {}), + ...agentSessionLegacyRefusalFields(kept) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every caller passes details typed for `code` (refuse and refuseUnclassified by their signatures, the reference by its own union). + return refusal as AgentSessionWireRefusal +} + +/** A refusal built at its emitter. The reason must be one `code` lists; the legacy top-level fields + * released clients read are copied from `details` here and nowhere else. */ +export function refuse( + code: C, + details: NoInfer, + message: string +): AgentSessionWireRefusal { + return buildRefusal(code, details, message) +} + +/** + * A refusal whose situation this site cannot name: the code, and whatever facts it does know, with + * no reason, exactly as an older host sends it. The explicit path, so no emitter reaches for a + * catch-all reason instead. + */ +export function refuseUnclassified( + code: C, + message: string, + facts?: NoInfer> +): AgentSessionWireRefusal { + return buildRefusal(code, facts, message) +} + +/** A refusal rebuilt from what a record or a decision kept of it. */ +export function agentSessionRefusalFromReference( + reference: AgentSessionRefusalReference, + message: string +): AgentSessionWireRefusal { + return buildRefusal(reference.code, reference.details, message) +} + +/** The same refusal with more facts: a failed create's verdict, learned after the refusal was. */ +export function withAgentSessionRefusalFacts( + refusal: AgentSessionWireRefusal, + facts: { ownerVerdict: AgentSessionOwnerVerdict } +): AgentSessionWireRefusal { + return buildRefusal(refusal.code, { ...refusal.details, ...facts }, refusal.message) +} + +/** The refusal reduced to what may be stored or put in an error's data. */ +export function agentSessionRefusalReference( + refusal: AgentSessionWireRefusal +): AgentSessionRefusalReference { + return buildReference(refusal.code, refusal.details) +} + +/** A reference read back from a record another build may have written; details are checked + * against the code, and anything else they held (an older build's `cause`) is dropped. */ +export function readAgentSessionRefusalReference( + value: unknown +): AgentSessionRefusalReference | undefined { + if ( + typeof value !== 'object' || + value === null || + !('code' in value) || + !isAgentSessionWireRefusalCode(value.code) + ) { + return undefined + } + return buildReference( + value.code, + 'details' in value ? readAgentSessionRefusalDetails(value.code, value.details) : undefined + ) +} + +function buildReference( + code: AgentSessionWireRefusalCode, + details: AgentSessionAnyRefusalDetails | undefined +): AgentSessionRefusalReference { + const reference = { code, ...(details ? { details } : {}) } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `details` is the refusal's own, or was read against `code`. + return reference as AgentSessionRefusalReference +} + +/** + * A refusal raised rather than returned. Its message is the bare code, exactly as the + * `Error(code)` it replaces: the RPC passthrough, the restart-resume ledger and released clients + * all read a thrown refusal's message as its code. The details ride on `refusal` and reach the + * client only in the RPC error's data. Deliberately no `code` property, so no `'code' in error` + * passthrough meant for another subsystem can claim it. + */ +export class AgentSessionRefusalError extends Error { + readonly refusal: AgentSessionWireRefusal + + constructor(refusal: AgentSessionWireRefusal, options?: { cause?: unknown }) { + super(refusal.code, options) + this.name = 'AgentSessionRefusalError' + this.refusal = refusal + } +} + +/** A store or host refusal thrown under its situation; the error's message stays the code. */ +export function agentSessionRefusalError( + code: C, + details: NoInfer, + message: string = code +): AgentSessionRefusalError { + return new AgentSessionRefusalError(refuse(code, details, message)) +} + +export function isAgentSessionRefusalError(error: unknown): error is AgentSessionRefusalError { + return error instanceof AgentSessionRefusalError } diff --git a/src/shared/agent-session-write-failure.test.ts b/src/shared/agent-session-write-failure.test.ts new file mode 100644 index 00000000000..83d8faea790 --- /dev/null +++ b/src/shared/agent-session-write-failure.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionRefusalFailure, + parseAgentSessionWriteFailure +} from './agent-session-write-failure' + +const HOST_TEXT = 'Expected runtime fence 1; the session is at 3.' + +function saved(value: unknown): unknown { + return JSON.parse(JSON.stringify(value)) +} + +describe('parseAgentSessionWriteFailure', () => { + // The shape saved before refusals carried a reason; it loads as it was and reads as its code. + it('reads back an entry saved with the code alone, unchanged', () => { + expect( + parseAgentSessionWriteFailure(saved({ kind: 'refused', code: 'agent_session_conflict' })) + ).toEqual({ kind: 'refused', code: 'agent_session_conflict' }) + expect(parseAgentSessionWriteFailure({ kind: 'failed' })).toEqual({ kind: 'failed' }) + expect( + parseAgentSessionWriteFailure({ + kind: 'refused', + code: 'agent_session_conflict', + message: HOST_TEXT + }) + ).toEqual({ kind: 'refused', code: 'agent_session_conflict' }) + }) + + it.each([ + [ + 'a reason', + { kind: 'refused', code: 'agent_session_conflict', details: { reason: 'chatStarting' } } + ], + [ + "a rewind's reason", + { + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'rewindRefused', rewindReason: 'busy' } + } + ], + [ + "the owner's verdict", + { + kind: 'refused', + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven', ownerVerdict: 'exited' } + } + ] + ])('keeps %s', (_label, value) => { + expect(parseAgentSessionWriteFailure(saved(value))).toEqual(value) + }) + + // A newer build's reason or verdict is dropped, never guessed; the code's words stand. + it.each([ + [{ reason: 'fromTheFuture' }, undefined], + [{ reason: 'fromTheFuture', ownerVerdict: 'exited' }, { ownerVerdict: 'exited' }], + [{ reason: 'ownerUnproven', ownerVerdict: 'gone' }, { reason: 'ownerUnproven' }], + [{ reason: 'chatStarting' }, undefined] + ])('drops what this build cannot place: %j', (details, kept) => { + expect( + parseAgentSessionWriteFailure({ + kind: 'refused', + code: 'agent_session_ownership_unknown', + details + }) + ).toEqual({ + kind: 'refused', + code: 'agent_session_ownership_unknown', + ...(kept ? { details: kept } : {}) + }) + }) + + it.each([ + null, + 'The agent was restarting.', + { kind: 'refused' }, + { kind: 'refused', code: 'agent_session_from_the_future' }, + { kind: 'something-else' } + ])('drops %j instead of guessing', (value) => { + expect(parseAgentSessionWriteFailure(value)).toBeUndefined() + }) +}) + +describe('agentSessionRefusalFailure', () => { + // What moves with the owner, the question's answer and the host's words are never kept. + it('keeps only the facts that stay true after a reload', () => { + expect( + agentSessionRefusalFailure({ + code: 'agent_session_checkpoint_stale', + details: { reason: 'fenceStale', currentFence: 3, ownerVerdict: 'exited' } + }) + ).toEqual({ + kind: 'refused', + code: 'agent_session_checkpoint_stale', + details: { reason: 'fenceStale' } + }) + // A prompt's revision and winning answer, and a provider's diagnostic, as a host may send them. + const answered = JSON.parse( + JSON.stringify({ + code: 'agent_session_already_resolved', + details: { + reason: 'promptAlreadyResolved', + currentRevision: 2, + resolution: { state: 'answered', selectedOptionId: 'yes', resolvedBy: 'phone' }, + detail: { text: 'stderr', audience: 'log' } + } + }) + ) + expect(agentSessionRefusalFailure(answered)).toEqual({ + kind: 'refused', + code: 'agent_session_already_resolved', + details: { reason: 'promptAlreadyResolved' } + }) + }) + + it('keeps a code from a newer host without reading its details', () => { + const refusal = JSON.parse('{"code":"agent_session_from_the_future","details":{"reason":"x"}}') + expect(agentSessionRefusalFailure(refusal)).toEqual({ + kind: 'refused', + code: 'agent_session_from_the_future' + }) + }) +}) diff --git a/src/shared/agent-session-write-failure.ts b/src/shared/agent-session-write-failure.ts new file mode 100644 index 00000000000..dd5c8f26caa --- /dev/null +++ b/src/shared/agent-session-write-failure.ts @@ -0,0 +1,152 @@ +// A chat write that did not happen, as a fact: what the person was doing, and what the host said. +// Saved with a queued message, so it holds only what stays true after a reload; the words are +// chosen from it when it is shown (`agent-session-refusal-notice.ts`). + +import { + readAgentSessionRefusalDetails, + type AgentSessionRefusalReason +} from './agent-session-refusal-details' +import type { AgentSessionRewindReason } from './agent-session-rewind' +import { + isAgentSessionWireRefusalCode, + type AgentSessionOwnerVerdict, + type AgentSessionWireRefusal, + type AgentSessionWireRefusalCode +} from './agent-session-wire-refusals' + +/** What the person was doing, which decides what the notice says did not happen. `send` keeps + * the message behind a Retry control; `composer-send` puts it back in the composer, as the phone + * does. `read-history` is opening the chat to show its history, which writes nothing. */ +export type AgentSessionWriteKind = + | 'read-history' + | 'send' + | 'composer-send' + | 'stop' + | 'stop-task' + | 'stop-tasks' + | 'answer' + | 'option' + | 'command' + | 'goal' + +/** The kind of write an `agentSession.*` call stands for. */ +export function agentSessionWriteKindForMethod( + fingerprintMethod: string, + fields: Record +): AgentSessionWriteKind { + if (fingerprintMethod === 'agentSession.send') { + return 'send' + } + if (fingerprintMethod === 'agentSession.cancel') { + // A background-task stop never asked the agent to stop. + if (fields.scope !== 'background-tasks') { + return 'stop' + } + return typeof fields.taskId === 'string' ? 'stop-task' : 'stop-tasks' + } + if (fingerprintMethod.startsWith('agentSession.respondTo')) { + return 'answer' + } + if (fingerprintMethod === 'agentSession.setOption') { + return 'option' + } + if (fingerprintMethod === 'agentSession.threadGoal') { + return 'goal' + } + return 'command' +} + +/** What a saved refusal keeps beside its reason: facts about the refused operation that stay true + * after a reload. Never the fence, revision or resolution, which move, nor any provider text. */ +type DurableRefusalFacts = { + agent_session_operation_invalid: { rewindReason?: AgentSessionRewindReason } + agent_session_operation_unknown: { rewindReason?: AgentSessionRewindReason } + /** A snapshot from when it was refused; see `agentSessionOwnerVerdictAllowsFreshOperationId`. */ + agent_session_ownership_unknown: { ownerVerdict?: AgentSessionOwnerVerdict } +} + +const DURABLE_FACT_KEYS: Partial> = { + agent_session_operation_invalid: ['rewindReason'], + agent_session_operation_unknown: ['rewindReason'], + agent_session_ownership_unknown: ['ownerVerdict'] +} satisfies { [C in keyof DurableRefusalFacts]: readonly (keyof DurableRefusalFacts[C])[] } + +export type AgentSessionWriteRefusalDetails = { + reason?: AgentSessionRefusalReason +} & (C extends keyof DurableRefusalFacts ? DurableRefusalFacts[C] : Record) + +/** A refused write: its code, and what the host said about it that outlives the reply. */ +export type AgentSessionWriteRefusal = { + [C in AgentSessionWireRefusalCode]: { + kind: 'refused' + code: C + /** Absent from older hosts and from entries saved before refusals carried a reason. */ + details?: AgentSessionWriteRefusalDetails + } +}[AgentSessionWireRefusalCode] + +/** Why a write did not happen, or that nothing proves it did not. */ +export type AgentSessionWriteFailure = + | AgentSessionWriteRefusal + /** The request failed without a refusal before the host ran it, so nothing is known about why. */ + | { kind: 'failed' } + /** The request failed where the host may already have run it (a timeout, a lost connection, an + * error inside the method). */ + | { kind: 'unconfirmed' } + +/** The refusal as a write keeps it. Details are read against the code, so a reason or verdict + * another build added is dropped and the code's own words stand. */ +function agentSessionWriteRefusal( + code: AgentSessionWireRefusalCode, + details: unknown +): AgentSessionWriteRefusal { + const durable: readonly string[] = ['reason', ...(DURABLE_FACT_KEYS[code] ?? [])] + const kept = Object.fromEntries( + Object.entries(readAgentSessionRefusalDetails(code, details) ?? {}).filter(([key]) => + durable.includes(key) + ) + ) + const refusal = { + kind: 'refused', + code, + ...(Object.keys(kept).length > 0 ? { details: kept } : {}) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `kept` holds only a reason and facts read against `code` and allowed for it above. + return refusal as AgentSessionWriteRefusal +} + +export function agentSessionRefusalFailure( + refusal: Pick +): AgentSessionWriteRefusal { + // A newer host can send a code this client has never heard of; its details mean nothing here. + return isAgentSessionWireRefusalCode(refusal.code) + ? agentSessionWriteRefusal(refusal.code, refusal.details) + : { kind: 'refused', code: refusal.code } +} + +/** A request that threw, from the RPC error code the host answered with (undefined when none came + * back). Only a host that turned it away before running the method proves the write did not + * happen. */ +export function agentSessionRpcErrorFailure(code: string | undefined): AgentSessionWriteFailure { + if (code === 'method_not_found' || code === 'method_not_supported') { + return { kind: 'refused', code: 'structured_agent_session_unsupported' } + } + return code === 'invalid_argument' || code === 'unauthorized' + ? { kind: 'refused', code: 'agent_session_operation_invalid' } + : { kind: 'unconfirmed' } +} + +/** A saved failure, or undefined when it is not one this build wrote. */ +export function parseAgentSessionWriteFailure( + value: unknown +): AgentSessionWriteFailure | undefined { + if (typeof value !== 'object' || value === null || !('kind' in value)) { + return undefined + } + if (value.kind === 'failed') { + return { kind: 'failed' } + } + return value.kind === 'refused' && 'code' in value && isAgentSessionWireRefusalCode(value.code) + ? agentSessionWriteRefusal(value.code, 'details' in value ? value.details : undefined) + : undefined +} diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts new file mode 100644 index 00000000000..d0c36e27a40 --- /dev/null +++ b/src/shared/agent-session-write-notice-copy.ts @@ -0,0 +1,64 @@ +// The sentences a chat notice is made of, each whole so desktop can translate it on its own. + +import { + QUIT_TERMINAL_AGENT, + START_NEW_CHAT, + TERMINAL_AGENT_HOLDS_CHAT +} from './agent-session-failure-words' + +/** Every sentence a notice is made of. Desktop translates each whole sentence with this as its + * fallback; mobile shows it as is. */ +export const AGENT_SESSION_WRITE_NOTICE_COPY = { + notDoneReadHistory: "This chat's history couldn't be loaded.", + notDoneSend: 'Your message was not sent.', + tryAgainComposerSend: 'Send it again.', + notDoneStop: "The agent wasn't stopped.", + notDoneStopTask: "The background task wasn't stopped.", + notDoneStopTasks: "The background tasks weren't stopped.", + notDoneAnswer: 'Your answer was not sent.', + notDoneOption: "The setting wasn't changed.", + notDoneCommand: "The command didn't run.", + notDoneGoal: "The goal wasn't changed.", + restartFailed: "The agent couldn't restart.", + capacity: 'Orca has received too many requests in the last day.', + outcomeUnknown: "Orca couldn't confirm what happened. Check the chat.", + questionChanged: 'This question was already answered or has changed.', + historyUnreadable: "Orca couldn't read this chat's saved history.", + historyUnusable: 'Unable to load this chat.', + historyUnavailable: "Orca couldn't open this chat's history right now.", + unsupported: "The Orca running this chat doesn't support this. Update Orca, then try again.", + unreachable: "Orca couldn't reach the agent.", + recordFailed: "Orca couldn't record it in this chat's history.", + conversationCleared: 'This conversation has been cleared.', + openCurrentConversation: 'Open the current conversation to continue.', + clearUnfinished: "The last /clear didn't finish.", + commandRunning: 'A /compact or /clear is still running.', + waitForCommand: 'Wait for the /compact or /clear to finish.', + agentStarting: 'The agent is still starting.', + waitForStart: 'Wait for the agent to finish starting.', + turnActive: 'The agent is still responding.', + waitForTurn: 'Wait for the agent to finish responding, or stop it.', + promptPending: 'The agent is waiting for an answer to a question or approval.', + answerFirst: 'Answer the question or approval first.', + backgroundTasksRunning: 'Background tasks are still running.', + waitForBackgroundTasks: 'Wait for the background tasks to finish.', + messagesUnsettled: "A message you sent earlier hasn't been confirmed yet.", + settleEarlierMessage: 'Wait for your earlier message to go through, or retry it.', + optionRejected: "The agent didn't accept this setting.", + goalsUnsupported: "This agent doesn't support goals.", + agentRefused: 'The agent turned this down.', + ownerUnproven: "Orca hasn't confirmed that this chat's previous agent stopped.", + reopenChat: 'Reopen the chat to check again.', + terminalAgentHoldsChat: TERMINAL_AGENT_HOLDS_CHAT, + quitTerminalAgent: QUIT_TERMINAL_AGENT, + hostReconciling: 'Orca is still checking on this chat after restarting.', + waitMoment: 'Wait a moment.', + recordUnreadable: "Orca couldn't read this chat's saved state.", + chatNotFound: 'The Orca running this chat has no record of it.', + startNewChat: START_NEW_CHAT, + tryAgain: 'Try again.' +} as const + +export type AgentSessionWriteNoticeSentence = keyof typeof AGENT_SESSION_WRITE_NOTICE_COPY +/** A notice as whole sentences, each translated on its own; `text` is a provider's own words. */ +export type AgentSessionWriteNoticePart = AgentSessionWriteNoticeSentence | { text: string } diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 45c1a3f6ed4..f33158d9a0f 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -10,6 +10,7 @@ import type { AgentSessionBackgroundTask, AgentSessionBackgroundTaskRunState } from './agent-session-background-task-wire' +import type { AgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalMessageSendMode, AgentJournalPosition @@ -54,6 +55,8 @@ export type NativeChatTextBlock = { truncated: boolean } } + /** On a status line that reports a failure: what failed, typed. */ + failure?: AgentSessionFailureFact } /** A tool invocation by the agent. `input` is the (already-serialized) tool diff --git a/src/shared/structured-agent-session-dispatch-rejection.test.ts b/src/shared/structured-agent-session-dispatch-rejection.test.ts new file mode 100644 index 00000000000..be16c8a3fbd --- /dev/null +++ b/src/shared/structured-agent-session-dispatch-rejection.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_FAILURE_KINDS, + agentSessionFailureFact, + isSubmissionRejectionKind +} from './agent-session-failure' +import { agentSessionFailureWords } from './agent-session-failure-words' +import { + classifyDispatchRejection, + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_CODEX_QUEUE_FULL, + DISPATCH_REJECTED_HOST_RESTARTED, + DISPATCH_REJECTED_PROVIDER_CLOSED, + DISPATCH_REJECTED_QUEUE_FULL, + isWriteFailureSubmission +} from './structured-agent-session-dispatch-rejection' + +describe('classifyDispatchRejection', () => { + it.each([ + [DISPATCH_REJECTED_CANCELLED, 'withdrawn', null, 'cancelled'], + [DISPATCH_REJECTED_HOST_RESTARTED, 'undelivered', null, 'hostRestarted'], + [DISPATCH_REJECTED_PROVIDER_CLOSED, 'undelivered', null, 'chatClosed'], + ['not_delivered', 'undelivered', null, 'notDelivered'], + [DISPATCH_REJECTED_QUEUE_FULL, 'transport', 'failure', 'queueFull'], + [DISPATCH_REJECTED_CODEX_QUEUE_FULL, 'transport', 'failure', 'queueFull'], + ['provider_write_failed: broken pipe', 'transport', 'failure', 'writeFailed'], + ['provider_write_failed', 'transport', 'failure', 'writeFailed'] + ] as const)('reads the legacy marker %j', (reason, category, verdict, kind) => { + expect(classifyDispatchRejection({ reason })).toEqual({ category, verdict, kind }) + }) + + it('reads any other legacy reason as a sentence that failed the send', () => { + expect(classifyDispatchRejection({ reason: 'Claude does not support .bmp' })).toEqual({ + category: 'content', + verdict: 'failure' + }) + expect(classifyDispatchRejection({ reason: null })).toEqual({ + category: 'content', + verdict: 'failure' + }) + }) + + it('fails the send for every kind but a withdrawal, a restart, a close, or a lost send', () => { + const noOneFailed = ['cancelled', 'hostRestarted', 'chatClosed', 'notDelivered'] + for (const kind of AGENT_SESSION_FAILURE_KINDS.filter(isSubmissionRejectionKind)) { + const verdict = classifyDispatchRejection({ reason: 'x', rejection: { kind } }).verdict + expect([kind, verdict]).toEqual([kind, noOneFailed.includes(kind) ? null : 'failure']) + } + }) + + it.each([ + ['cancelled', 'withdrawn', null], + ['hostRestarted', 'undelivered', null], + ['chatClosed', 'undelivered', null], + ['notDelivered', 'undelivered', null], + // Orca stopped a start that hung: the agent failed, and the message never reached it. + ['hostStopped', 'undelivered', 'failure'], + ['writeFailed', 'transport', 'failure'], + ['queueFull', 'transport', 'failure'] + ] as const)('reads the written %s rejection as %s, verdict %s', (kind, category, verdict) => { + const written = agentSessionFailureWords(agentSessionFailureFact(kind), { + surface: 'rejection', + provider: 'claude' + }) + expect(classifyDispatchRejection(written)).toEqual({ category, verdict, kind }) + }) + + it('reads a legacy not_delivered row with no fact as a send nobody failed', () => { + expect(classifyDispatchRejection({ reason: 'not_delivered' })).toEqual({ + category: 'undelivered', + verdict: null, + kind: 'notDelivered' + }) + }) + + it('reads the typed fact over the sentence beside it', () => { + expect( + classifyDispatchRejection({ + reason: 'The provider stopped before it finished starting.', + rejection: { kind: 'providerStartFailed' } + }) + ).toEqual({ category: 'startFailed', verdict: 'failure', kind: 'providerStartFailed' }) + expect( + classifyDispatchRejection({ reason: 'Not sent.', rejection: { kind: 'queueFull' } }) + ).toMatchObject({ category: 'transport', verdict: 'failure' }) + }) + + it('writes a sentence, not a marker released clients would print, for a restart or a close', () => { + for (const kind of ['hostRestarted', 'chatClosed'] as const) { + const written = agentSessionFailureWords(agentSessionFailureFact(kind), { + surface: 'rejection' + }) + expect(written.reason).not.toMatch(/^[a-z_]+$/) + expect(classifyDispatchRejection(written)).toEqual({ + category: 'undelivered', + verdict: null, + kind + }) + } + }) + + it('gives a fact it cannot place no verdict, whatever the reason beside it says', () => { + // A newer host's kind, a status-row kind, and a fact with no kind at all. + for (const rejection of [{ kind: 'futureKind' }, { kind: 'providerRetrying' }, {}]) { + for (const reason of [ + DISPATCH_REJECTED_QUEUE_FULL, + DISPATCH_REJECTED_CANCELLED, + 'Refused.' + ]) { + expect(classifyDispatchRejection({ reason, rejection })).toEqual({ + category: 'undelivered', + verdict: null + }) + } + } + }) +}) + +describe('isWriteFailureSubmission', () => { + it('holds for a legacy row in any state that carries the marker', () => { + expect( + isWriteFailureSubmission({ reason: 'provider_write_failed: closed before enqueue' }) + ).toBe(true) + }) + + it('holds for the bare marker a new host writes, as released clients already read it', () => { + const written = agentSessionFailureWords(agentSessionFailureFact('writeFailed'), { + surface: 'rejection' + }) + expect(written.reason).toBe('provider_write_failed') + expect(isWriteFailureSubmission({ reason: written.reason })).toBe(true) + }) + + it('holds for a typed write failure and for nothing else', () => { + expect(isWriteFailureSubmission({ reason: 'x', rejection: { kind: 'writeFailed' } })).toBe(true) + expect(isWriteFailureSubmission({ reason: DISPATCH_REJECTED_QUEUE_FULL })).toBe(false) + expect(isWriteFailureSubmission({ reason: 'x', rejection: { kind: 'queueFull' } })).toBe(false) + expect(isWriteFailureSubmission({ reason: null })).toBe(false) + }) +}) diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 5c4e1d0f0a8..3b9ff4fb025 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -1,23 +1,23 @@ // Why a submission is `rejected`: the message provably did not happen. // -// Two different facts reach this state, and the reason string is what tells them -// apart. A CONTENT rejection is permanent — the provider looked at this payload -// and refused it, and it would refuse the same payload again; its reason is the -// provider's own explanation ("Claude messages support at most 20 images") and is -// meant to be read. A TRANSPORT rejection is transient — the frame was never -// handed to the provider at all, so the same text under a fresh id is a first -// delivery and the next attempt may well succeed; its reason names an internal -// cause and must NOT be put in front of a person. +// The host writes each rejection twice: `reason`, a sentence (or, for the cases older clients +// already recognise, one of the legacy markers below) that released clients print as it is, and +// `rejection`, the typed fact newer clients read — both from `agentSessionFailureWords`. +// `classifyDispatchRejection` is the only place a reader judges either. // -// Both are `rejected` because both make the single claim that state exists to -// make: this message did not reach the provider. Neither is ever re-delivered -// under its own id — `rejected` is terminal in the reducer — so a retry rotates -// the client message id, which is a new message and cannot duplicate. -// -// Shared rather than main-only because both sides need it: the host writes the -// reason, and a client has to know which kind it is holding before it can decide -// whether the string is showable. +// Every rejection makes the one claim that state exists to make: this message did not reach the +// provider. None is ever re-delivered under its own id — `rejected` is terminal in the reducer — so +// a retry rotates the client message id, which is a new message and cannot duplicate. + +import { + isSubmissionRejectionKind, + readAgentSessionFailureFact, + type SubmissionRejectionKind +} from './agent-session-failure' +import type { AgentJournalSubmission } from './agent-session-journal-types' +/** Orca could not hand the message over. Written bare: released clients hide it, and the error + * that once followed it belongs in the log. Rows from older hosts carry `: ` after it. */ export const DISPATCH_REJECTED_WRITE_FAILED = 'provider_write_failed' /** Local admission refused the frame before any transport was involved. Two @@ -29,24 +29,21 @@ export const DISPATCH_REJECTED_CODEX_QUEUE_FULL = 'codex structured dispatch que /** The provider confirmed a queued frame was withdrawn before execution. */ export const DISPATCH_REJECTED_CANCELLED = 'provider_cancelled_before_start' -/** Accepted by a host process that ended before handing it to any provider. */ +/** Legacy marker: accepted by a host process that ended before handing it to any provider. + * Read only; released clients print it raw, so new rows carry a sentence. */ export const DISPATCH_REJECTED_HOST_RESTARTED = 'host_restarted_before_delivery' -/** Accepted, then the provider was closed before the message was handed to it. */ +/** Legacy marker: accepted, then the provider was closed before the message was handed to it. + * Read only, like the one above. */ export const DISPATCH_REJECTED_PROVIDER_CLOSED = 'provider_closed_before_delivery' -/** Crash recovery found the send absent from a provider history it could trust. */ +/** Restart reconciliation found the send absent from a provider history it could trust. Rows + * written before rows carried a fact hold it as their reason; released clients printed it. */ export const DISPATCH_REJECTED_NOT_DELIVERED = 'not_delivered' -export function dispatchWriteFailureReason(error: unknown): string { - const detail = error instanceof Error ? error.message : String(error) - return `${DISPATCH_REJECTED_WRITE_FAILED}: ${detail}` -} - -/** True for the internal transport marker, false for a provider's own words. */ -export function dispatchRejectionWasTransportWriteFailure( - reason: string | null | undefined -): boolean { +/** True for the internal transport marker, false for a provider's own words. Legacy-reason half + * of `isWriteFailureSubmission`; readers go through that or the classifier. */ +function dispatchRejectionWasTransportWriteFailure(reason: string | null | undefined): boolean { return ( reason === DISPATCH_REJECTED_WRITE_FAILED || reason?.startsWith(`${DISPATCH_REJECTED_WRITE_FAILED}: `) === true @@ -54,33 +51,128 @@ export function dispatchRejectionWasTransportWriteFailure( } /** - * True when the reason is ours rather than the provider's, so it must not be - * shown verbatim. A content rejection carries the provider's own explanation and - * is the only kind a person should read. + * What a rejection means for the chat, whatever wrote it: + * - `withdrawn`: the user's Stop took it back; nothing failed. + * - `undelivered`: accepted, then never handed over (Orca restarted, the chat closed, the provider + * never took it or stopped first). + * - `startFailed`: the agent it waited on did not start. + * - `content`: the provider, or Orca's check of the message, refused this payload. + * - `transport`: Orca could not hand it over. */ -export function dispatchRejectionReasonIsInternal(reason: string | null | undefined): boolean { - return ( - dispatchRejectionWasTransportWriteFailure(reason) || - reason === DISPATCH_REJECTED_QUEUE_FULL || - reason === DISPATCH_REJECTED_CODEX_QUEUE_FULL || - reason === DISPATCH_REJECTED_CANCELLED || - reason === DISPATCH_REJECTED_HOST_RESTARTED || - reason === DISPATCH_REJECTED_PROVIDER_CLOSED || - reason === DISPATCH_REJECTED_NOT_DELIVERED - ) +export type DispatchRejectionCategory = + | 'withdrawn' + | 'undelivered' + | 'startFailed' + | 'content' + | 'transport' + +export type DispatchRejectionClassification = { + category: DispatchRejectionCategory + /** `failure` when the chat reads Failed; null only when no one failed the user: a withdrawal, a + * host restart, a chat close, or a send the provider never received after a restart. */ + verdict: 'failure' | null + /** The situation, when the row carried one or a legacy marker names it; absent for a legacy + * sentence, whose words are all a reader has, and for a fact this build cannot place. */ + kind?: SubmissionRejectionKind +} + +const KIND_CATEGORY = { + cancelled: 'withdrawn', + hostRestarted: 'undelivered', + chatClosed: 'undelivered', + notDelivered: 'undelivered', + providerExited: 'undelivered', + hostStopped: 'undelivered', + providerStartFailed: 'startFailed', + startFailed: 'startFailed', + notSignedIn: 'startFailed', + historyTooLarge: 'startFailed', + managedAccountEnvOverride: 'startFailed', + accountSwitchInProgress: 'startFailed', + managedAccountUnsupported: 'startFailed', + restartFailed: 'startFailed', + providerRejected: 'content', + attachmentInvalid: 'content', + attachmentUnreadable: 'content', + emptyMessage: 'content', + queueFull: 'transport', + writeFailed: 'transport', + hostFault: 'transport' +} satisfies Record + +/** Null only where no one failed the user; a new kind does not compile until it is placed here. */ +const KIND_VERDICT = { + cancelled: null, + hostRestarted: null, + chatClosed: null, + notDelivered: null, + providerExited: 'failure', + providerStartFailed: 'failure', + startFailed: 'failure', + notSignedIn: 'failure', + historyTooLarge: 'failure', + managedAccountEnvOverride: 'failure', + accountSwitchInProgress: 'failure', + managedAccountUnsupported: 'failure', + restartFailed: 'failure', + providerRejected: 'failure', + attachmentInvalid: 'failure', + attachmentUnreadable: 'failure', + emptyMessage: 'failure', + queueFull: 'failure', + writeFailed: 'failure', + hostFault: 'failure', + // Orca stopped a start that hung: the agent failed. + hostStopped: 'failure' +} satisfies Record + +/** The legacy markers, by the kind each stands for. */ +const LEGACY_MARKER_KINDS: ReadonlyMap = new Map([ + [DISPATCH_REJECTED_CANCELLED, 'cancelled'], + [DISPATCH_REJECTED_HOST_RESTARTED, 'hostRestarted'], + [DISPATCH_REJECTED_PROVIDER_CLOSED, 'chatClosed'], + [DISPATCH_REJECTED_QUEUE_FULL, 'queueFull'], + [DISPATCH_REJECTED_CODEX_QUEUE_FULL, 'queueFull'], + [DISPATCH_REJECTED_NOT_DELIVERED, 'notDelivered'] +]) + +/** The kind a legacy marker stands for; undefined for any other reason, which is a sentence. */ +function legacyMarkerKind(reason: string | null): SubmissionRejectionKind | undefined { + if (dispatchRejectionWasTransportWriteFailure(reason)) { + return 'writeFailed' + } + return reason === null ? undefined : LEGACY_MARKER_KINDS.get(reason) } /** - * What a rejected send says about the request, for the session's verdict: `failure` when the - * agent or its start refused it, null when nobody failed. A withdrawn send, and one a restart, - * a close or a crash left undelivered, are not verdicts — the chat shows them unsent with Retry. - * An allowlist of the null reasons, because every other reason includes open provider text. + * The one reader of why a submission was rejected. The typed fact decides when the row carries + * one; a fact this build cannot place says only that the message was not sent. A row with no + * fact, from an older host, is read by its legacy marker, and any other reason is a sentence — a + * provider's, or Orca's before rows were typed — which reads as a content failure. */ -export function dispatchRejectionVerdict(reason: string | null | undefined): 'failure' | null { - return reason === DISPATCH_REJECTED_CANCELLED || - reason === DISPATCH_REJECTED_HOST_RESTARTED || - reason === DISPATCH_REJECTED_PROVIDER_CLOSED || - reason === DISPATCH_REJECTED_NOT_DELIVERED - ? null - : 'failure' +export function classifyDispatchRejection( + submission: Pick & { rejection?: unknown } +): DispatchRejectionClassification { + const { rejection } = submission + const factKind = readAgentSessionFailureFact(rejection)?.kind + if (typeof rejection === 'object' && rejection !== null && !isSubmissionRejectionKind(factKind)) { + // Undelivered, not content: all a fact this build can't place proves is the message didn't happen. + return { category: 'undelivered', verdict: null } + } + const kind = isSubmissionRejectionKind(factKind) ? factKind : legacyMarkerKind(submission.reason) + if (!kind) { + return { category: 'content', verdict: 'failure' } + } + return { category: KIND_CATEGORY[kind], verdict: KIND_VERDICT[kind], kind } +} + +/** A submission that says Orca never handed it over, in any dispatch state: journals written + * before this state moved hold it as `unknown` carrying the marker. */ +export function isWriteFailureSubmission( + submission: Pick +): boolean { + return ( + readAgentSessionFailureFact(submission.rejection)?.kind === 'writeFailed' || + dispatchRejectionWasTransportWriteFailure(submission.reason) + ) } diff --git a/src/shared/structured-agent-session-latest-request.test.ts b/src/shared/structured-agent-session-latest-request.test.ts index 780a636064c..7cdb1b2d36a 100644 --- a/src/shared/structured-agent-session-latest-request.test.ts +++ b/src/shared/structured-agent-session-latest-request.test.ts @@ -4,14 +4,22 @@ import type { AgentJournalSubmission, AgentJournalTurnLifecycle } from './agent-session-journal-types' +import { AGENT_SESSION_FAILURE_KINDS, isSubmissionRejectionKind } from './agent-session-failure' import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { + classifyDispatchRejection, DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_CODEX_QUEUE_FULL, DISPATCH_REJECTED_HOST_RESTARTED, DISPATCH_REJECTED_NOT_DELIVERED, - DISPATCH_REJECTED_PROVIDER_CLOSED + DISPATCH_REJECTED_PROVIDER_CLOSED, + DISPATCH_REJECTED_QUEUE_FULL, + DISPATCH_REJECTED_WRITE_FAILED } from './structured-agent-session-dispatch-rejection' -import { latestStructuredAgentSessionRequest } from './structured-agent-session-latest-request' +import { + hasStructuredAgentSessionRequest, + latestStructuredAgentSessionRequest +} from './structured-agent-session-latest-request' import { projectStructuredAgentSessionStatusSummary } from './structured-agent-session-projection' const START_FAILURE = 'Claude is not signed in.' @@ -245,3 +253,61 @@ describe('the latest request and its verdict', () => { }) }) }) + +describe('the sidebar verdict agrees with the rejection classifier', () => { + // A sentence as the reason, so only the typed fact can make a kind read as no verdict. + const typed = AGENT_SESSION_FAILURE_KINDS.filter(isSubmissionRejectionKind).map((kind) => ({ + name: `typed ${kind}`, + submission: sent('m1', { + dispatchState: 'rejected', + reason: 'The message was not sent.', + rejection: { kind } + }) + })) + const legacy = [ + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_QUEUE_FULL, + DISPATCH_REJECTED_CODEX_QUEUE_FULL, + DISPATCH_REJECTED_HOST_RESTARTED, + DISPATCH_REJECTED_PROVIDER_CLOSED, + DISPATCH_REJECTED_NOT_DELIVERED, + DISPATCH_REJECTED_WRITE_FAILED, + `${DISPATCH_REJECTED_WRITE_FAILED}: broken pipe` + ].map((reason) => ({ name: `legacy ${reason}`, submission: rejected('m1', reason) })) + const unknownKind = { + name: 'a kind this build cannot place', + submission: sent('m1', { + dispatchState: 'rejected', + reason: 'A newer host wrote this.', + rejection: { kind: 'someFutureKind' } + }) + } + + it.each([...typed, ...legacy, unknownKind])('$name', ({ submission }) => { + const items = [userEntry('m1', 1)] + const { verdict } = classifyDispatchRejection(submission) + expect(latestStructuredAgentSessionRequest(items, [submission])?.outcome ?? null).toBe(verdict) + expect(hasStructuredAgentSessionRequest(items, [submission])).toBe(verdict === 'failure') + }) + + const failedNobody = new Set([ + 'typed notDelivered', + 'typed cancelled', + 'typed hostRestarted', + 'typed chatClosed', + `legacy ${DISPATCH_REJECTED_NOT_DELIVERED}`, + `legacy ${DISPATCH_REJECTED_CANCELLED}`, + `legacy ${DISPATCH_REJECTED_HOST_RESTARTED}`, + `legacy ${DISPATCH_REJECTED_PROVIDER_CLOSED}` + ]) + const failedNobodyCases = [...typed, ...legacy].filter(({ name }) => failedNobody.has(name)) + it('covers every send that failed nobody', () => { + expect(failedNobodyCases).toHaveLength(failedNobody.size) + }) + it.each(failedNobodyCases)( + 'gives no verdict for a send that failed nobody: $name', + ({ submission }) => { + expect(latestStructuredAgentSessionRequest([userEntry('m1', 1)], [submission])).toBeNull() + } + ) +}) diff --git a/src/shared/structured-agent-session-latest-request.ts b/src/shared/structured-agent-session-latest-request.ts index 9fae8a39960..9679c0c12bd 100644 --- a/src/shared/structured-agent-session-latest-request.ts +++ b/src/shared/structured-agent-session-latest-request.ts @@ -14,7 +14,7 @@ import type { import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { isRootAgentJournalItem } from './agent-session-journal-producer' import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record' -import { dispatchRejectionVerdict } from './structured-agent-session-dispatch-rejection' +import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' import { isUnansweredStructuredAgentSessionDispatch } from './structured-agent-session-unanswered-dispatch' export type StructuredAgentSessionLatestRequest = { @@ -55,7 +55,7 @@ export function latestStructuredAgentSessionRequest( const submission = rejected.get(item.itemId) if ( submission && - dispatchRejectionVerdict(submission.reason) === 'failure' && + classifyDispatchRejection(submission).verdict === 'failure' && !deliveredIntoRunningTurn(items, index, submission) ) { return { @@ -94,7 +94,7 @@ export function hasStructuredAgentSessionRequest( submission.dispatchState === 'accepted' || isUnansweredStructuredAgentSessionDispatch(submission, currentFence) || (submission.dispatchState === 'rejected' && - dispatchRejectionVerdict(submission.reason) === 'failure') + classifyDispatchRejection(submission).verdict === 'failure') ) ) } diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 6c820921162..4211bbb3961 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -1,15 +1,17 @@ +import { readAgentSessionFailureFact, type AgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalMessageItem, AgentJournalSubmission } from './agent-session-journal-types' import { parseAgentSessionWriteFailure, - type AgentSessionWriteFailure -} from './agent-session-refusal-notice' -import { agentSessionRefusalOperationState } from './agent-session-refusal-retry' -import type { - AgentSessionMutationEnvelope, - AgentSessionWireRefusalCode -} from './agent-session-wire' + type AgentSessionWriteFailure, + type AgentSessionWriteRefusal +} from './agent-session-write-failure' +import { + agentSessionOwnerVerdictAllowsFreshOperationId, + agentSessionRefusalOperationState +} from './agent-session-refusal-retry' +import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' -import { DISPATCH_REJECTED_CANCELLED } from './structured-agent-session-dispatch-rejection' +import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' /** `rejected`: the host settled the send as not delivered. The drain never sends it again on its * own and nothing queues behind it; only the user's Retry does. */ @@ -34,11 +36,39 @@ export type StructuredAgentSessionOutboxEntry = { lastFailure?: StructuredAgentSessionAttemptFailure } +/** A host's rejection fact as a message keeps it: never its provider detail, whose person-facing + * words are already in the reason and whose log text is not kept client-side. */ +export type StructuredAgentSessionRejectionFact = Pick< + AgentSessionFailureFact, + 'kind' | 'attachment' +> + /** Kept as the fact, not the words: the Retry row chooses those when it shows the entry. */ export type StructuredAgentSessionAttemptFailure = | AgentSessionWriteFailure /** The host recorded the message and the provider turned it down, with the provider's reason. */ - | { kind: 'rejected'; reason: string | null } + | { kind: 'rejected'; reason: string | null; rejection?: StructuredAgentSessionRejectionFact } + +/** The failure a rejected submission leaves on its message. A fact this build cannot place is + * dropped, leaving the reason. */ +export function structuredAgentSessionRejectedFailure(submission: { + reason: string | null + rejection?: unknown +}): Extract { + const fact = readAgentSessionFailureFact(submission.rejection) + return { + kind: 'rejected', + reason: submission.reason, + ...(fact + ? { + rejection: { + kind: fact.kind, + ...(fact.attachment ? { attachment: fact.attachment } : {}) + } + } + : {}) + } +} function parseStructuredAgentSessionAttemptFailure( value: unknown @@ -51,7 +81,10 @@ function parseStructuredAgentSessionAttemptFailure( 'reason' in value ) { return value.reason === null || typeof value.reason === 'string' - ? { kind: 'rejected', reason: value.reason } + ? structuredAgentSessionRejectedFailure({ + reason: value.reason, + rejection: 'rejection' in value ? value.rejection : undefined + }) : undefined } return parseAgentSessionWriteFailure(value) @@ -119,25 +152,31 @@ export function stageStructuredAgentSessionOutboxEntryForSend( export function requeueStructuredAgentSessionSendRefusal( entry: StructuredAgentSessionOutboxEntry, - code: AgentSessionWireRefusalCode, + refusal: AgentSessionWriteRefusal, createOperationId: () => string, retainOperationId = false ): StructuredAgentSessionOutboxEntry { - const refusalState = agentSessionRefusalOperationState(code) + const refusalSettled = agentSessionRefusalOperationState(refusal.code) === 'settled-rejected' + // An exited owner runs nothing under the old id, so a new one can't collide; the message still + // holds the head, since nothing recorded it. + const ownerExited = + refusal.code === 'agent_session_ownership_unknown' && + agentSessionOwnerVerdictAllowsFreshOperationId(refusal.details?.ownerVerdict) if ( - refusalState !== 'settled-rejected' || + !(refusalSettled || ownerExited) || retainOperationId || entry.state === 'unconfirmed' || entry.retryAfterUnknownSubmittedAt !== null ) { return { ...entry, state: 'queued' } } - // Only here is the refusal proof the message never landed: an earlier attempt under this id, or - // one whose delivery was in doubt, may have, so those stay queued behind the block. + // Only here may the id rotate: an earlier attempt under this id, or one whose delivery was in + // doubt, may have landed, so those stay queued behind the block. Only a settled refusal proves + // the message never landed and so releases the queue. return { ...entry, clientMessageId: createOperationId(), - state: 'rejected', + state: refusalSettled ? 'rejected' : 'queued', lastAttemptAt: null, retryAfterUnknownSubmittedAt: null } @@ -155,7 +194,7 @@ export function reconcileStructuredAgentSessionOutbox( } if ( submission?.dispatchState === 'rejected' && - submission.reason === DISPATCH_REJECTED_CANCELLED + classifyDispatchRejection(submission).category === 'withdrawn' ) { return [] } @@ -173,7 +212,7 @@ export function reconcileStructuredAgentSessionOutbox( { ...entry, state: 'rejected' as const, - lastFailure: { kind: 'rejected' as const, reason: submission.reason } + lastFailure: structuredAgentSessionRejectedFailure(submission) } ] } diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index 6dbe9f90787..f26f409afd4 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization' +import { agentSessionFailureWords } from './agent-session-failure-words' import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import { parsePaneKey } from './stable-pane-id' import { @@ -103,6 +104,26 @@ describe('structured agent session status projection', () => { }) }) + it("forwards a status row's failure fact and drops one this build cannot place", () => { + const words = agentSessionFailureWords( + { + kind: 'providerExited', + detail: { text: 'stderr tail', audience: 'log' } + }, + { surface: 'row' } + ) + expect( + projectStructuredItemToNativeChat(item('exit', 1, { kind: 'status', ...words }))?.blocks[0] + ).toEqual({ type: 'text', ...words }) + const future = item('future', 2, { kind: 'status', text: 'Stopped.' }) + // A newer host's kind reads as no fact, so the row keeps its text and nothing else. + Object.assign(future.body, { failure: { kind: 'futureKind' } }) + expect(projectStructuredItemToNativeChat(future)?.blocks[0]).toEqual({ + type: 'text', + text: 'Stopped.' + }) + }) + it('projects running, attention, and completed lifecycle states', () => { const running = item('running', 1, { kind: 'status', diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 9dcd952d271..f444af0a060 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -11,6 +11,7 @@ import { type AgentJournalTurnOutcome } from './agent-session-journal-types' import { isRootAgentJournalItem } from './agent-session-journal-producer' +import { structuredAgentSessionStatusBlock } from './structured-agent-session-status-block' import { agentJournalItemRowOrigin } from './agent-session-journal-position' import { AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, @@ -122,18 +123,7 @@ function itemBlocks(item: AgentJournalRenderItem): { if (body.kind !== 'status' || body.turnLifecycle) { return null } - return { - role: 'system', - blocks: [ - { - type: 'text', - text: body.text, - ...(body.presentation !== undefined ? { presentation: body.presentation } : {}), - ...(body.tone !== undefined ? { tone: body.tone } : {}), - ...(body.providerFrame ? { providerFrame: body.providerFrame } : {}) - } - ] - } + return { role: 'system', blocks: [structuredAgentSessionStatusBlock(body)] } } function isAgentJournalMessageSendMode(value: string): value is AgentJournalMessageSendMode { diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 909da35c1f3..08fad831af2 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -4,10 +4,10 @@ // which is the whole reason it was split out. import { describe, expect, it } from 'vitest' +import type { AgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalSubmission } from './agent-session-journal-types' import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire' import { - dispatchWriteFailureReason, DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_QUEUE_FULL } from './structured-agent-session-dispatch-rejection' @@ -31,7 +31,10 @@ const entry: StructuredAgentSessionOutboxEntry = createStructuredAgentSessionOut queuedAt: 1 }) -function rejectedWith(reason: string | null): AgentSessionMutationResult { +function rejectedWith( + reason: string | null, + extra: { rejection?: AgentSessionFailureFact; replayed?: boolean } = {} +): AgentSessionMutationResult { const submission: AgentJournalSubmission = { clientMessageId: 'client-1', fence: 1, @@ -40,15 +43,16 @@ function rejectedWith(reason: string | null): AgentSessionMutationResult + } } function notice(reason: string | null): string | undefined { @@ -78,7 +82,7 @@ describe('what a rejection shows the user', () => { }) it('never puts the transport marker on screen', () => { - const shown = notice(dispatchWriteFailureReason(new Error('broken pipe'))) + const shown = notice('provider_write_failed: broken pipe') // `provider_write_failed: broken pipe` names nothing a person can act on. expect(shown).not.toContain('provider_write_failed') expect(shown).not.toContain('broken pipe') @@ -92,13 +96,54 @@ describe('what a rejection shows the user', () => { ) }) - it('names the cause of a start that died before it could take the message', () => { - // The host words this reason for the user: the child's own diagnostic, nothing internal. - const reason = - 'The provider stopped before it finished starting: claude stream-json exited (code 1): claude: not signed in.' + it('shows the sentence a host wrote for the person reading it', () => { + const reason = 'The provider stopped before it finished starting.' expect(notice(reason)).toBe(reason) }) + it('never puts the legacy not_delivered marker on screen', () => { + // Released clients printed it as it was; it is a marker, not a sentence. + expect(notice('not_delivered')).toBe('Your message was not sent.') + }) + + it.each([ + ['the legacy marker alone', undefined], + ['the marker and its typed fact', { kind: 'cancelled' as const }] + ])('fails nothing for a Stop-withdrawn send, first reply or replay: %s', (_label, rejection) => { + for (const replayed of [false, true]) { + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + blockedClientMessageId: null, + result: rejectedWith(DISPATCH_REJECTED_CANCELLED, { rejection, replayed }), + createOperationId: () => 'unused' + }) + expect(disposition).toEqual({ + entries: [], + error: null, + blockedClientMessageId: null, + retryWithFreshClientMessageId: null + }) + } + }) + + it('reads a withdrawal off the typed fact whatever the reason says', () => { + const result = rejectedWith('Withdrawn.', { rejection: { kind: 'cancelled' } }) + if (!result.ok) { + throw new Error('expected rejected submission fixture') + } + expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission])).toEqual([]) + expect( + disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + blockedClientMessageId: null, + result, + createOperationId: () => 'unused' + }).error + ).toBeNull() + }) + it('claims no cause when the rejection names none', () => { expect(notice(null)).toBe('Your message was not sent.') }) @@ -144,6 +189,57 @@ describe('what a refusal shows the user', () => { ).toBe('Your message was not sent.') }) + it("keeps the reason the host named, and says it on the message's Retry row", () => { + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + blockedClientMessageId: null, + result: { + ok: false, + refusal: { + code: 'agent_session_conflict', + message: 'The chat is still starting.', + details: { reason: 'chatStarting' } + } + }, + createOperationId: () => 'unused' + }) + + const lastFailure = disposition.entries[0]?.lastFailure + expect(lastFailure).toEqual({ + kind: 'refused', + code: 'agent_session_conflict', + details: { reason: 'chatStarting' } + }) + expect( + agentSessionWriteNoticeEnglish(structuredAgentSessionAttemptFailureParts(lastFailure!)) + ).toBe( + 'The agent is still starting. Your message was not sent. Wait for the agent to finish starting.' + ) + }) + + it("keeps a rejected message's typed fact without the provider's detail", () => { + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + blockedClientMessageId: null, + result: rejectedWith('An image on this message is empty, so the message was not sent.', { + rejection: { + kind: 'attachmentInvalid', + attachment: { reason: 'empty' }, + detail: { text: 'image block 2: zero bytes', audience: 'log' } + } + }), + createOperationId: () => 'unused' + }) + + expect(disposition.entries[0]?.lastFailure).toEqual({ + kind: 'rejected', + reason: 'An image on this message is empty, so the message was not sent.', + rejection: { kind: 'attachmentInvalid', attachment: { reason: 'empty' } } + }) + }) + it('keeps a failed request as a fact, not a transport error string', () => { const disposition = disposeStructuredAgentSessionSendFailure({ entries: [entry], diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 11ba9703b83..7ffce942b6e 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -10,19 +10,17 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire' import { - agentSessionRefusalFailure, agentSessionWriteNoticeEnglish, agentSessionWriteNoticeParts, - agentSessionWriteNotDoneParts, - type AgentSessionWriteNoticePart + agentSessionWriteNotDoneParts } from './agent-session-refusal-notice' -import { - dispatchRejectionReasonIsInternal, - dispatchRejectionWasTransportWriteFailure -} from './structured-agent-session-dispatch-rejection' +import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy' +import { agentSessionRefusalFailure } from './agent-session-write-failure' +import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' import { classifyStructuredAgentSessionSendFailure, requeueStructuredAgentSessionSendRefusal, + structuredAgentSessionRejectedFailure, type StructuredAgentSessionAttemptFailure, type StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' @@ -136,14 +134,13 @@ export function structuredAgentSessionRejectionParts( if (reason === null) { return ['notDoneSend'] } - if (dispatchRejectionWasTransportWriteFailure(reason)) { + const rejection = classifyDispatchRejection({ reason }) + if (rejection.kind === 'writeFailed') { return ['unreachable', ...agentSessionWriteNotDoneParts(write)] } - // Any other reason we minted is an internal cause with no user-facing meaning; - // only a provider's own explanation is worth reading verbatim. - return dispatchRejectionReasonIsInternal(reason) - ? agentSessionWriteNotDoneParts(write) - : [{ text: reason }] + // A legacy marker is an internal cause with no user-facing meaning; any other reason is a + // sentence written to be read — the provider's, or the host's own. + return rejection.kind ? agentSessionWriteNotDoneParts(write) : [{ text: reason }] } /** What the Retry row says about why its message did not go through. */ @@ -166,16 +163,17 @@ export function disposeStructuredAgentSessionSendResult( const refusedIndex = input.entries.findIndex( (candidate) => candidate.clientMessageId === input.entry.clientMessageId ) + const refusal = agentSessionRefusalFailure(result.refusal) const entries = input.entries.map((candidate) => candidate.clientMessageId === input.entry.clientMessageId ? withLastFailure( requeueStructuredAgentSessionSendRefusal( candidate, - result.refusal.code, + refusal, input.createOperationId, input.entry.lastAttemptAt !== null ), - agentSessionRefusalFailure(result.refusal) + refusal ) : candidate ) @@ -210,12 +208,26 @@ export function disposeStructuredAgentSessionSendResult( retryWithFreshClientMessageId: null } } + // A Stop's withdrawal failed nothing, first reply or replay: the entry leaves as the reconcile + // drops it, with no notice. + if ( + submission.dispatchState === 'rejected' && + classifyDispatchRejection(submission).category === 'withdrawn' + ) { + return { + entries: dropEntry(input), + error: null, + blockedClientMessageId: input.blockedClientMessageId, + retryWithFreshClientMessageId: null + } + } if (submission.dispatchState === 'rejected') { return { - entries: replaceEntryState(input, 'rejected', { - kind: 'rejected', - reason: submission.reason - }), + entries: replaceEntryState( + input, + 'rejected', + structuredAgentSessionRejectedFailure(submission) + ), error: null, blockedClientMessageId: input.blockedClientMessageId, retryWithFreshClientMessageId: input.entry.clientMessageId diff --git a/src/shared/structured-agent-session-status-block.ts b/src/shared/structured-agent-session-status-block.ts new file mode 100644 index 00000000000..58dbbe9131c --- /dev/null +++ b/src/shared/structured-agent-session-status-block.ts @@ -0,0 +1,18 @@ +import { readAgentSessionFailureFact } from './agent-session-failure' +import type { AgentJournalStatusItem } from './agent-session-journal-types' +import type { NativeChatTextBlock } from './native-chat-types' + +/** A status row as the line a chat paints: named fields only, so a host-only key never leaks. */ +export function structuredAgentSessionStatusBlock( + body: AgentJournalStatusItem +): NativeChatTextBlock { + const failure = readAgentSessionFailureFact(body.failure) + return { + type: 'text', + text: body.text, + ...(body.presentation !== undefined ? { presentation: body.presentation } : {}), + ...(body.tone !== undefined ? { tone: body.tone } : {}), + ...(body.providerFrame ? { providerFrame: body.providerFrame } : {}), + ...(failure ? { failure } : {}) + } +}