diff --git a/src/cli/handlers/jira-write.test.ts b/src/cli/handlers/jira-write.test.ts new file mode 100644 index 00000000000..192fae00a05 --- /dev/null +++ b/src/cli/handlers/jira-write.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest' +import { JIRA_AGENT_ACCESS_RUNTIME_CAPABILITY } from '../../shared/jira-agent-capability' +import { RuntimeClientError } from '../runtime-client' +import { JIRA_WRITE_HANDLERS } from './jira-write' + +function clientFailingWrite(failure: Error) { + return vi.fn(async (method: string) => { + if (method === 'status.get') { + return { result: { capabilities: [JIRA_AGENT_ACCESS_RUNTIME_CAPABILITY] } } + } + if (method === 'jira.status') { + return { result: { sites: [{ id: 'site-1' }], selectedSiteId: 'site-1' } } + } + throw failure + }) +} + +async function clearAssigneeAfter(failure: Error): Promise { + const handler = JIRA_WRITE_HANDLERS['jira assignee clear'] + try { + await handler({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler only calls client.call. + client: { call: clientFailingWrite(failure) } as never, + cwd: '/tmp', + flags: new Map([['key', 'ORCA-1']]), + json: false + }) + } catch (error) { + return error + } + throw new Error('expected the write to fail') +} + +// Pins which write failures say "may have been applied" (round-2 S5), so a dropped code is deliberate. +describe('Jira write outcome-unknown codes', () => { + it.each([ + 'runtime_timeout', + 'runtime_connection_failed', + 'runtime_disconnected', + 'runtime_unavailable' + ])('reports %s as an unverified write', async (code) => { + await expect( + clearAssigneeAfter(new RuntimeClientError(code, 'failed.')) + ).resolves.toMatchObject({ code: 'jira_write_unverified' }) + }) + + it.each([ + 'remote_runtime_unavailable', + 'runtime_manually_disconnected', + 'runtime_source_mismatch', + 'invalid_argument' + ])('passes %s through unchanged', async (code) => { + const failure = new RuntimeClientError(code, 'failed.') + await expect(clearAssigneeAfter(failure)).resolves.toBe(failure) + }) + + it('passes a non-client error through unchanged', async () => { + const failure = new Error('runtime_timeout') + await expect(clearAssigneeAfter(failure)).resolves.toBe(failure) + }) +}) diff --git a/src/cli/orchestration-mutation-recovery.test.ts b/src/cli/orchestration-mutation-recovery.test.ts index 11687285326..92de56f5698 100644 --- a/src/cli/orchestration-mutation-recovery.test.ts +++ b/src/cli/orchestration-mutation-recovery.test.ts @@ -314,4 +314,19 @@ describe('orchestration mutation recovery', () => { expect(result.data).not.toHaveProperty('originalCommand') expect((result.data as { recovery: object }).recovery).not.toHaveProperty('retryCommand') }) + + // Pins the outcome-unknown codes (round-2 S5): only these get recovery guidance. + it.each([ + ['runtime_unavailable', true], + ['remote_runtime_unavailable', true], + ['runtime_timeout', true], + ['invalid_runtime_response', true], + ['runtime_manually_disconnected', false], + ['remote_runtime_timeout', false], + ['runtime_rpc_queue_overloaded', false], + ['invalid_argument', false] + ])('treats %s as an unknown mutation outcome: %s', (code, unknown) => { + const error = new RuntimeClientError(code, 'failed', { orchestrationRequestId: 'request-1' }) + expect(orchestrationMutationRecoveryError(error) !== error).toBe(unknown) + }) }) diff --git a/src/renderer/src/components/automations/automation-host-cache-error.test.ts b/src/renderer/src/components/automations/automation-host-cache-error.test.ts new file mode 100644 index 00000000000..00ab40d36a4 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-cache-error.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { RemoteRuntimeClientError } from '../../../../shared/remote-runtime-client-error' +import { classifyAutomationHostQueryError } from './automation-host-cache-error' + +function classify(error: unknown): { code: string; retryable: boolean } { + const { code, retryable } = classifyAutomationHostQueryError(error, { attempt: 1, now: 0 }) + return { code, retryable } +} + +// Pins the RPC-code mapping of host query failures (round-2 S5), so a dropped code is deliberate. +describe('classifyAutomationHostQueryError code mapping', () => { + it.each([ + ['runtime_unavailable', 'authority_unavailable', true], + ['remote_runtime_unavailable', 'authority_unavailable', true], + ['relay_quota_exceeded', 'authority_unavailable', true], + ['timeout', 'timeout', true], + ['runtime_timeout', 'timeout', true], + ['permission_denied', 'permission_denied', false], + ['unauthorized', 'permission_denied', false], + ['forbidden', 'permission_denied', false], + ['capability_unsupported', 'incompatible', false], + ['legacy_read_only', 'incompatible', false], + ['invalid_runtime_response', 'invalid_response', false], + ['invalid_argument', 'invalid_response', false], + ['runtime_manually_disconnected', 'unknown', true], + ['runtime_rpc_queue_overloaded', 'unknown', true], + ['remote_runtime_busy', 'unknown', true] + ])('maps %s to %s (retryable: %s)', (rpcCode, code, retryable) => { + expect(classify(new RemoteRuntimeClientError(rpcCode, 'failed'))).toEqual({ code, retryable }) + }) + + it('reads a code-stripped message token', () => { + expect( + classify(new Error("Error invoking remote method 'x': Error: failed: runtime_timeout")) + ).toEqual({ code: 'timeout', retryable: true }) + }) +}) diff --git a/src/renderer/src/lib/launch-structured-agent-session.test.ts b/src/renderer/src/lib/launch-structured-agent-session.test.ts index 91f8b9839c6..d0244000790 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.test.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.test.ts @@ -524,4 +524,37 @@ describe('structured agent session launch', () => { expect(transportError).not.toBeInstanceOf(StructuredAgentSessionCreateRefusalError) }) + + // Pins the thrown-error half (round-2 S5): a host that flattened the refusal to runtime_error + // still ends it with the token, and client-minted codes never prove nothing was created. + it('reads a definitive token from a flattened runtime_error message', async () => { + mockSupportedCreate(() => { + throw Object.assign(new Error('structured_agent_session_unsupported'), { + code: 'runtime_error' + }) + }) + const error = await launchStructuredAgentSession( + createStructuredAgentSessionLaunchIntent('workspace-flattened-refusal', 'codex') + ).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(StructuredAgentSessionCreateRefusalError) + expect(error).toMatchObject({ code: 'structured_agent_session_unsupported' }) + }) + + it.each([ + 'runtime_manually_disconnected', + 'runtime_rpc_queue_overloaded', + 'runtime_unavailable', + 'remote_runtime_unavailable', + 'method_not_supported' + ])('keeps a thrown %s outcome unknown', async (code) => { + mockSupportedCreate(() => { + throw Object.assign(new Error('failed'), { code }) + }) + const error = await launchStructuredAgentSession( + createStructuredAgentSessionLaunchIntent(`workspace-client-${code}`, 'codex') + ).catch((caught: unknown) => caught) + + expect(error).not.toBeInstanceOf(StructuredAgentSessionCreateRefusalError) + }) }) diff --git a/src/renderer/src/runtime/web-runtime-browser-creation-failure.test.ts b/src/renderer/src/runtime/web-runtime-browser-creation-failure.test.ts new file mode 100644 index 00000000000..df992bad397 --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-browser-creation-failure.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest' +import { RemoteRuntimeClientError } from '../../../shared/remote-runtime-client-error' +import type { WebRuntimeBrowserCreationContext } from './web-runtime-browser-creation-context' +import { prepareWebRuntimeBrowserCreationFailure } from './web-runtime-browser-creation-failure' + +vi.mock('../store', () => ({ useAppStore: { getState: vi.fn(), setState: vi.fn() } })) + +function failedCreateContext(): WebRuntimeBrowserCreationContext { + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the failure path reads only worktreeId and the group fields, which are absent here. + args: { worktreeId: 'repo::/w' } as never, + environmentId: 'env-1', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the prepare step never reads the intent owner. + intentOwner: {} as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the prepare step never calls the environment. + callEnvironment: vi.fn() as never, + shouldFocusOnCreate: false, + shouldSelectWorktree: false, + provisionalPageId: 'provisional-page', + hostSupportsKnownPageId: true, + expectsClientHosting: false, + unsubscribeFocusGuard: vi.fn(), + guardedPageId: 'provisional-page', + createdPageId: null, + createAttempted: true, + staged: null + } +} + +function prepare(error: unknown): { cleanupPageId: string | null; createOutcomeUnknown: boolean } { + const { cleanupPageId, createOutcomeUnknown } = prepareWebRuntimeBrowserCreationFailure( + failedCreateContext(), + error + ) + return { cleanupPageId, createOutcomeUnknown } +} + +// Pins which create failures prove no page exists (round-2 S5), so a dropped code is deliberate. +describe('prepareWebRuntimeBrowserCreationFailure definitive codes', () => { + it.each([ + 'browser_error', + 'capability_unsupported', + 'invalid_argument', + 'invalid_params', + 'method_not_found', + 'runtime_rpc_queue_overloaded', + 'selector_ambiguous', + 'selector_not_found', + 'unauthorized' + ])('skips the provisional-page cleanup after %s', (code) => { + expect(prepare(new RemoteRuntimeClientError(code, 'failed'))).toEqual({ + cleanupPageId: null, + createOutcomeUnknown: false + }) + }) + + it.each([ + 'runtime_manually_disconnected', + 'runtime_unavailable', + 'remote_runtime_unavailable', + 'runtime_timeout', + 'remote_runtime_busy', + 'runtime_error' + ])('cleans up the provisional page after %s', (code) => { + expect(prepare(new RemoteRuntimeClientError(code, 'failed'))).toEqual({ + cleanupPageId: 'provisional-page', + createOutcomeUnknown: false + }) + }) + + it('reads a definitive code from a code-stripped message token', () => { + expect( + prepare(new Error("Error invoking remote method 'x': Error: failed: method_not_found")) + ).toEqual({ + cleanupPageId: null, + createOutcomeUnknown: false + }) + }) +}) diff --git a/src/renderer/src/runtime/web-runtime-session-tab-close-undelivered-codes.test.ts b/src/renderer/src/runtime/web-runtime-session-tab-close-undelivered-codes.test.ts new file mode 100644 index 00000000000..b6ba507f063 --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-session-tab-close-undelivered-codes.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as TrackingDecisions from './web-session-tabs-sync/tracking-decisions' +import { closeWebRuntimeSessionTab } from './web-runtime-session-tab-lifecycle' +import { + resetWebSessionCloseReplaysForTests, + takeDueWebSessionCloseReplays +} from './web-session-close-replay' +import { resetWebSessionFocusIntentForTests } from './web-session-focus-intent' +import { + isWebSessionCloseIntentPending, + resetWebSessionCloseIntentForTests +} from './web-session-close-intent' +import { ENVIRONMENT_ID, WORKTREE_ID } from './web-runtime-session-test-harness' +import { stubRuntimeEnvironmentBridge } from './runtime-environment-bridge-fixture' + +const mocks = vi.hoisted(() => ({ + getState: vi.fn(), + setState: vi.fn(), + subscribe: vi.fn(), + setActiveWorktree: vi.fn(), + createBrowserTab: vi.fn(), + closeEmptyGroup: vi.fn(), + moveUnifiedTabToGroup: vi.fn(), + setRemoteBrowserPageHandle: vi.fn(), + focusBrowserTabInWorktree: vi.fn(), + applyWebSessionTabsSnapshot: vi.fn(), + decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), + acceptReplayedWebSessionTabsSnapshot: vi.fn(), + resolveHostSessionTabIdForWebSessionTab: vi.fn(), + trackTerminalPaneSplit: vi.fn(), + deliverLaunchPromptToAgentTab: vi.fn(), + seedNativeChatLaunchDraftForAgentTab: vi.fn(), + getRuntimeEnvironmentIdForWorktree: vi.fn(), + hasMaterializedWebRuntimeBrowserPage: vi.fn() +})) + +vi.mock('../store', () => ({ + useAppStore: { + getState: mocks.getState, + setState: mocks.setState, + subscribe: mocks.subscribe + } +})) + +vi.mock('./web-session-tabs-sync/tracking-lifecycle', () => ({ + acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration +})) +vi.mock('./web-session-tabs-sync/snapshot-api', () => ({ + applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot +})) +vi.mock('./web-session-tabs-sync/tracking-decisions', async (importOriginal) => ({ + ...(await importOriginal()), + decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot +})) +vi.mock('./web-session-tabs-sync/store-patch', () => ({ + applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { + mocks.setState(buildPatch) + // The production caller invokes the returned settle receipt. + return () => {} + } +})) +vi.mock('./web-session-tabs-sync/tracking-mappings', () => ({ + resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab +})) + +vi.mock('@/lib/feature-education-telemetry', () => ({ + trackTerminalPaneSplit: mocks.trackTerminalPaneSplit +})) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: mocks.getRuntimeEnvironmentIdForWorktree +})) + +vi.mock('@/lib/agent-launch-prompt-delivery', () => ({ + deliverLaunchPromptToAgentTab: mocks.deliverLaunchPromptToAgentTab, + seedNativeChatLaunchDraftForAgentTab: mocks.seedNativeChatLaunchDraftForAgentTab +})) + +vi.mock('./web-runtime-browser-materialization', () => ({ + hasMaterializedWebRuntimeBrowserPage: mocks.hasMaterializedWebRuntimeBrowserPage +})) + +afterEach(() => { + resetWebSessionCloseIntentForTests() + resetWebSessionCloseReplaysForTests() +}) + +const OWNER = { environmentId: ENVIRONMENT_ID } + +async function closeAfter(failure: unknown, rejects = false): Promise { + const runtimeCall = vi.fn() + if (rejects) { + runtimeCall.mockRejectedValueOnce(failure) + } else { + runtimeCall.mockResolvedValueOnce(failure) + } + runtimeCall.mockResolvedValue({ id: 'close', ok: true, result: { closed: true } }) + stubRuntimeEnvironmentBridge({ call: runtimeCall }) + await expect( + closeWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + reason: 'user' + }) + ).resolves.toBe('failed') + const held = isWebSessionCloseIntentPending( + OWNER, + WORKTREE_ID, + 'host-browser-unified', + Date.now() + 60_000 + ) + const replayQueued = + takeDueWebSessionCloseReplays(OWNER, WORKTREE_ID, new Set(['host-browser-unified'])).length > 0 + expect(replayQueued).toBe(held) + return held +} + +function envelope(code: string): unknown { + return { id: 'close', ok: false, error: { code, message: 'failed' } } +} + +// Pins which close failures keep the user's close for replay (round-2 S5), so a dropped code is deliberate. +describe('user close: which failures keep the intent', () => { + beforeEach(() => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + mocks.getState.mockReturnValue({ + settings: { + activeRuntimeEnvironmentId: ENVIRONMENT_ID + }, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + activeTabIdByWorktree: {}, + activeGroupIdByWorktree: {}, + setActiveWorktree: mocks.setActiveWorktree + }) + mocks.resolveHostSessionTabIdForWebSessionTab.mockImplementation( + (_state, args: { tabId: string }) => + args.tabId === 'local-browser-unified' ? 'host-browser-unified' : null + ) + // Store patches must run their updater so snapshot reconciliation is observable. + mocks.setState.mockImplementation((updater: (state: unknown) => unknown) => { + updater({ + state: 'before', + activeWorktreeId: WORKTREE_ID + }) + }) + mocks.applyWebSessionTabsSnapshot.mockReturnValue({ state: 'after' }) + }) + + afterEach(() => { + resetWebSessionFocusIntentForTests() + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it.each([ + 'runtime_manually_disconnected', + 'runtime_unavailable', + 'remote_runtime_unavailable', + 'runtime_timeout', + 'remote_runtime_timeout' + ])('keeps the close when the answer is %s', async (code) => { + expect(await closeAfter(envelope(code))).toBe(true) + }) + + // #30146: the web client mints this as a bare Error; it must never drop the close. + it('keeps the close when the client itself refuses with runtime_manually_disconnected', async () => { + expect(await closeAfter(new Error('runtime_manually_disconnected'), true)).toBe(true) + }) + + it('keeps the close on any thrown transport failure', async () => { + expect(await closeAfter(new Error('socket hang up'), true)).toBe(true) + }) + + it.each([ + 'invalid_argument', + 'runtime_rpc_queue_overloaded', + 'ssh_user_disconnected', + 'conflict' + ])('drops the close when the answer is %s', async (code) => { + expect(await closeAfter(envelope(code))).toBe(false) + }) +}) diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption.test.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption.test.ts new file mode 100644 index 00000000000..a285c53800c --- /dev/null +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { isStableAdoptionFailure } from './web-session-terminal-orphan-recovery-adoption' + +function failure(code: string, message = 'refused'): unknown { + return { id: 'adopt', ok: false, error: { code, message } } +} + +// Pins which adoption failures stop retrying (round-2 S5), so a dropped code is deliberate. +describe('isStableAdoptionFailure', () => { + it.each(['method_not_found', 'capability_unsupported', 'invalid_runtime_response'])( + 'treats %s as stable', + (code) => { + expect(isStableAdoptionFailure(failure(code))).toBe(true) + } + ) + + it.each([ + 'remote_runtime_unavailable', + 'remote_runtime_busy', + 'runtime_rpc_queue_overloaded', + 'runtime_timeout', + 'runtime_unavailable', + 'reconnecting', + 'timeout', + 'runtime_manually_disconnected', + 'terminal_not_found' + ])('keeps retrying after %s', (code) => { + expect(isStableAdoptionFailure(failure(code))).toBe(false) + }) + + it('keeps retrying a code-stripped connection failure even if it names a stable code', () => { + expect( + isStableAdoptionFailure( + new Error('Could not connect to the remote Orca runtime: method_not_found') + ) + ).toBe(false) + }) + + it('reads a stable code from a thrown error message token', () => { + expect(isStableAdoptionFailure(new Error('Adoption failed: capability_unsupported'))).toBe(true) + }) +}) diff --git a/src/renderer/src/startup/startup-web-host-unreachable.test.ts b/src/renderer/src/startup/startup-web-host-unreachable.test.ts new file mode 100644 index 00000000000..0b7377b4de6 --- /dev/null +++ b/src/renderer/src/startup/startup-web-host-unreachable.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RemoteRuntimeClientError } from '../../../shared/remote-runtime-client-error' +import { isWebHostUnreachableAtStartup } from './startup-web-host-unreachable' + +vi.mock('sonner', () => ({ toast: vi.fn() })) + +function stubWindow(webClient: boolean, snapshots: unknown[]): void { + vi.stubGlobal('window', { + __ORCA_WEB_CLIENT__: webClient, + api: { runtimeEnvironments: { getStatusSnapshots: vi.fn(async () => snapshots) } } + }) +} + +// Pins which boot failures read as "host unreachable" (round-2 S5), so a dropped code is deliberate. +describe('isWebHostUnreachableAtStartup', () => { + beforeEach(() => { + stubWindow(true, []) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it.each([ + 'remote_runtime_unavailable', + 'remote_runtime_busy', + 'runtime_rpc_queue_overloaded', + 'runtime_timeout', + 'runtime_unavailable', + 'reconnecting', + 'timeout' + ])('treats %s as unreachable while the transport is down', async (code) => { + await expect( + isWebHostUnreachableAtStartup(new RemoteRuntimeClientError(code, 'failed')) + ).resolves.toBe(true) + }) + + it.each([ + 'Timed out while connecting to the remote Orca runtime.', + 'Remote Orca runtime connection interrupted.', + 'Could not connect to the remote Orca runtime.' + ])('treats the code-less message "%s" as unreachable', async (message) => { + await expect(isWebHostUnreachableAtStartup(new Error(message))).resolves.toBe(true) + }) + + it.each(['runtime_manually_disconnected', 'invalid_runtime_response', 'unauthorized'])( + 'treats %s as a hydration fault', + async (code) => { + await expect( + isWebHostUnreachableAtStartup(new RemoteRuntimeClientError(code, 'failed')) + ).resolves.toBe(false) + } + ) + + it('treats a connection failure on a ready transport as a hydration fault', async () => { + stubWindow(true, [{ transport: 'ready', retired: false }]) + await expect( + isWebHostUnreachableAtStartup(new RemoteRuntimeClientError('runtime_timeout', 'failed')) + ).resolves.toBe(false) + }) + + it('is never true outside the web client', async () => { + stubWindow(false, []) + await expect( + isWebHostUnreachableAtStartup(new RemoteRuntimeClientError('runtime_timeout', 'failed')) + ).resolves.toBe(false) + }) +}) diff --git a/src/renderer/src/store/slices/worktrees/teardown/host-worktree-removal-state.test.ts b/src/renderer/src/store/slices/worktrees/teardown/host-worktree-removal-state.test.ts new file mode 100644 index 00000000000..d5d17019aef --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/teardown/host-worktree-removal-state.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { RemoteRuntimeClientError } from '../../../../../../shared/remote-runtime-client-error' +import { RuntimeRpcCallQueueOverloadError } from '../../../../../../shared/runtime-rpc-call-queue' +import { isWorktreeRemovalReplyLost } from './host-worktree-removal-state' + +// Pins which delete failures wait on the host's listing (round-2 S5), so a dropped code is deliberate. +describe('isWorktreeRemovalReplyLost', () => { + it.each([ + 'remote_runtime_unavailable', + 'remote_runtime_busy', + 'runtime_timeout', + 'runtime_unavailable', + 'reconnecting', + 'timeout' + ])('treats %s as a lost reply', (code) => { + expect(isWorktreeRemovalReplyLost(new RemoteRuntimeClientError(code, 'transport failed'))).toBe( + true + ) + }) + + it('treats a queue overload as never sent', () => { + expect(isWorktreeRemovalReplyLost(new RuntimeRpcCallQueueOverloadError('global'))).toBe(false) + expect( + isWorktreeRemovalReplyLost( + new Error( + "Error invoking remote method 'x': Error: Remote runtime call queue is full; retry after current calls finish." + ) + ) + ).toBe(false) + }) + + it.each([ + 'runtime_manually_disconnected', + 'invalid_runtime_response', + 'unauthorized', + 'worktree_not_found' + ])('does not treat %s as a lost reply', (code) => { + expect(isWorktreeRemovalReplyLost(new RemoteRuntimeClientError(code, 'refused'))).toBe(false) + }) + + it.each([ + "Error invoking remote method 'x': Error: Request timed out.", + "Error invoking remote method 'x': Error: Remote Orca runtime connection interrupted.", + "Error invoking remote method 'x': Error: Could not connect to the remote Orca runtime." + ])('treats the code-stripped message "%s" as a lost reply', (message) => { + expect(isWorktreeRemovalReplyLost(new Error(message))).toBe(true) + }) +}) diff --git a/src/shared/agent-session-definitive-refusal.test.ts b/src/shared/agent-session-definitive-refusal.test.ts index ac4aca0964f..ed3141dbb02 100644 --- a/src/shared/agent-session-definitive-refusal.test.ts +++ b/src/shared/agent-session-definitive-refusal.test.ts @@ -38,4 +38,19 @@ describe('definitive agent-session create refusals', () => { ) expect(isDefinitiveAgentSessionCreateRefusal('structured_agent_session_unsupported')).toBe(true) }) + + // Pins the RPC half (round-2 S5): transport and client-minted codes never allow a fallback. + it.each([ + 'runtime_unavailable', + 'remote_runtime_unavailable', + 'runtime_timeout', + 'runtime_manually_disconnected', + 'runtime_rpc_queue_overloaded', + 'method_not_supported', + 'invalid_argument', + null, + undefined + ])('does not count %s as definitive', (code) => { + expect(isDefinitiveAgentSessionCreateRefusal(code)).toBe(false) + }) }) diff --git a/src/shared/remote-runtime-client-error-classification.test.ts b/src/shared/remote-runtime-client-error-classification.test.ts index ef33c0c376e..c7141e60df9 100644 --- a/src/shared/remote-runtime-client-error-classification.test.ts +++ b/src/shared/remote-runtime-client-error-classification.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' import { + RECOVERABLE_CODES, + RECOVERABLE_MESSAGE_FRAGMENTS, isRecoverableRemoteRuntimeConnectionError, isRuntimeRpcQueueOverloadError, toRemoteRuntimeClientErrorLike @@ -61,4 +63,54 @@ describe('remote runtime client error classification', () => { const error = toRemoteRuntimeClientErrorLike(new Error(message)) expect(isRecoverableRemoteRuntimeConnectionError(error)).toBe(true) }) + + // Pins the list so a dropped or added code is a deliberate, reviewed change (round-2 S5). + it('pins the recoverable codes and message fragments', () => { + expect([...RECOVERABLE_CODES].toSorted()).toEqual([ + 'reconnecting', + 'remote_runtime_busy', + 'remote_runtime_unavailable', + 'runtime_rpc_queue_overloaded', + 'runtime_timeout', + 'runtime_unavailable', + 'timeout' + ]) + expect(RECOVERABLE_MESSAGE_FRAGMENTS).toEqual([ + 'could not connect to the remote orca runtime', + 'remote orca runtime closed the connection', + 'remote orca runtime connection closed', + 'remote orca runtime is not connected', + 'remote runtime call queue is full', + 'remote runtime connection closed', + 'remote runtime connection wait limit reached', + 'remote runtime request limit reached', + 'remote runtime subscription limit reached', + 'remote runtime subscription memory limit reached', + 'remote runtime subscription closed before it started', + 'remote terminal stream is not connected', + 'timed out waiting for the remote orca runtime' + ]) + }) + + it.each([ + 'runtime_manually_disconnected', + 'ssh_user_disconnected', + 'remote_runtime_timeout', + 'runtime_busy' + ])('does not treat client code %s as recoverable', (code) => { + expect(isRecoverableRemoteRuntimeConnectionError({ code, message: 'refused' })).toBe(false) + }) + + it.each(RECOVERABLE_MESSAGE_FRAGMENTS)( + 'recovers a code-stripped message containing %s', + (fragment) => { + expect( + isRecoverableRemoteRuntimeConnectionError( + toRemoteRuntimeClientErrorLike( + new Error(`Error invoking remote method 'x': Error: ${fragment}.`) + ) + ) + ).toBe(true) + } + ) }) diff --git a/src/shared/structured-agent-session-send-evidence.test.ts b/src/shared/structured-agent-session-send-evidence.test.ts index db8a8a7ee68..c4dadb66c77 100644 --- a/src/shared/structured-agent-session-send-evidence.test.ts +++ b/src/shared/structured-agent-session-send-evidence.test.ts @@ -98,6 +98,15 @@ describe('structuredAgentSessionSendEvidence', () => { ]) { expect(structuredAgentSessionSendEvidence(thrown(rpcCode)).kind).toBe('uncertain') } + for (const rpcCode of [ + 'runtime_manually_disconnected', + 'runtime_rpc_queue_overloaded', + 'remote_runtime_busy', + 'forbidden', + 'capability_unsupported' + ]) { + expect(structuredAgentSessionSendEvidence(thrown(rpcCode)).kind).toBe('uncertain') + } const thrownRefusal = { response: { error: { data: { refusal: { code: 'agent_session_journal_unreadable' } } } } } @@ -110,8 +119,14 @@ describe('structuredAgentSessionSendEvidence', () => { ).toBe('uncertain') }) + // Pins the full turned-away list (round-2 S5): every other code, client-minted ones included, is uncertain. it('treats a call the host turned away before running it as never written', () => { - for (const rpcCode of ['method_not_found', 'invalid_argument', 'unauthorized']) { + for (const rpcCode of [ + 'method_not_found', + 'method_not_supported', + 'invalid_argument', + 'unauthorized' + ]) { expect(structuredAgentSessionSendEvidence(thrown(rpcCode)).kind).toBe('not-recorded') } })