diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts index a891a8000a9..db718a4380d 100644 --- a/src/main/ai-vault/runtime-session-scanner.test.ts +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -200,6 +200,35 @@ describe('runtime AI Vault session scanner', () => { }) ).rejects.toThrow('Invalid aiVault.prepareSessionResume response') }) + + it('emits the desktop sentinel only for this exact missing RPC', async () => { + mocks.callRuntimeEnvironment.mockResolvedValueOnce({ + ok: false, + error: { code: 'method_not_found', message: 'Unknown method: aiVault.prepareSessionResume' } + }) + + await expect( + prepareRuntimeAiVaultSessionResume('/user-data', 'env-1', { + agent: 'claude', + filePath: '/sessions/a.jsonl', + codexHome: null, + executionHostId: 'runtime:env-1' + }) + ).rejects.toThrow('aiVault.prepareSessionResume: method_not_found') + + mocks.callRuntimeEnvironment.mockResolvedValueOnce({ + ok: false, + error: { code: 'method_not_found', message: 'Unknown method: terminal.send' } + }) + await expect( + prepareRuntimeAiVaultSessionResume('/user-data', 'env-1', { + agent: 'claude', + filePath: '/sessions/a.jsonl', + codexHome: null, + executionHostId: 'runtime:env-1' + }) + ).rejects.toThrow('Unknown method: terminal.send') + }) }) function result( diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index 794a2177c00..e81e29e9160 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -149,6 +149,13 @@ export async function prepareRuntimeAiVaultSessionResume( args ) if (response.ok !== true) { + if ( + response.error.code === 'method_not_found' && + response.error.message === 'Unknown method: aiVault.prepareSessionResume' + ) { + // Why: Electron drops custom error fields; this exact message preserves the host's verdict. + throw new Error('aiVault.prepareSessionResume: method_not_found') + } throw new Error(response.error.message) } const parsed = aiVaultPrepareSessionResumeResultSchema.safeParse(response.result) diff --git a/src/main/ipc/ai-vault-resume.test.ts b/src/main/ipc/ai-vault-resume.test.ts new file mode 100644 index 00000000000..89187a0593b --- /dev/null +++ b/src/main/ipc/ai-vault-resume.test.ts @@ -0,0 +1,44 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { + installOwnership, + PROVIDER_SESSION +} from '../ai-vault/structured-session-ownership.test-support' +import { prepareAiVaultSessionResume } from './ai-vault-resume' + +vi.mock('electron', () => ({ ipcMain: { handle: vi.fn() } })) +afterEach(() => setStructuredAgentSessionHost(null)) + +it('refuses the native owner while preserving forks and other execution hosts', async () => { + installOwnership({ handle: { transport: 'acp', agent: 'omp', nativeId: PROVIDER_SESSION } }) + const local = { + agent: 'omp' as const, + sessionId: PROVIDER_SESSION, + filePath: '/sessions/conversation.jsonl', + codexHome: null, + executionHostId: 'local' as const + } + const prepareSessionResume = vi.fn().mockResolvedValue({ useRealCodexHome: false }) + const prepareRuntimeSessionResume = vi.fn().mockResolvedValue({ useRealCodexHome: false }) + const options = { prepareSessionResume, prepareRuntimeSessionResume } + await expect(prepareAiVaultSessionResume(local, options)).rejects.toThrow( + 'agent_session_conflict' + ) + expect(prepareSessionResume).not.toHaveBeenCalled() + await expect(prepareAiVaultSessionResume({ ...local, fork: true }, options)).resolves.toEqual({ + useRealCodexHome: false + }) + const paired = { ...local, executionHostId: 'runtime:paired' as const } + await expect(prepareAiVaultSessionResume(paired, options)).resolves.toEqual({ + useRealCodexHome: false + }) + expect(prepareRuntimeSessionResume).toHaveBeenCalledWith('paired', paired) + await expect( + prepareAiVaultSessionResume({ ...local, executionHostId: 'ssh:server' }, options) + ).resolves.toEqual({ useRealCodexHome: false }) + const wsl = { ...local, filePath: String.raw`\\wsl.localhost\Ubuntu\home\qa\session.jsonl` } + await expect(prepareAiVaultSessionResume(wsl, options)).resolves.toEqual({ + useRealCodexHome: false + }) + expect(prepareSessionResume).toHaveBeenCalledWith(wsl) +}) diff --git a/src/main/ipc/ai-vault-resume.ts b/src/main/ipc/ai-vault-resume.ts index 37055ab182b..25469b1d244 100644 --- a/src/main/ipc/ai-vault-resume.ts +++ b/src/main/ipc/ai-vault-resume.ts @@ -5,7 +5,9 @@ import type { AiVaultSessionResumePreparation } from '../../shared/ai-vault-resume-preparation' import { isUnresolvedOwnerHostId, parseExecutionHostId } from '../../shared/execution-host' +import { isWslUncPath } from '../../shared/wsl-paths' import { assertLegacyAiVaultResumeAllowed } from '../ai-vault/structured-session-ownership' +import { tryEnsureStructuredAgentSessionHostForResume } from '../runtime/structured-agent-session-host-refusal' export type AiVaultResumeHandlerOptions = { ensureStructuredSessionOwnership?: () => Promise @@ -26,8 +28,6 @@ export async function prepareAiVaultSessionResume( args: AiVaultPrepareSessionResumeArgs, options: AiVaultResumeHandlerOptions ): Promise { - await options.ensureStructuredSessionOwnership?.() - assertLegacyAiVaultResumeAllowed(args) const executionHost = parseExecutionHostId(args.executionHostId) if (executionHost?.kind === 'runtime') { if (!options.prepareRuntimeSessionResume || isUnresolvedOwnerHostId(executionHost.id)) { @@ -39,5 +39,13 @@ export async function prepareAiVaultSessionResume( if (executionHost?.kind === 'ssh') { return { useRealCodexHome: false } } + if (!isWslUncPath(args.filePath)) { + if (options.ensureStructuredSessionOwnership) { + await (args.codexHome === null && !args.fork + ? tryEnsureStructuredAgentSessionHostForResume(options.ensureStructuredSessionOwnership) + : options.ensureStructuredSessionOwnership()) + } + assertLegacyAiVaultResumeAllowed(args) + } return options.prepareSessionResume?.(args) ?? { useRealCodexHome: false } } diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index cda50f28f67..a5e6d6a5890 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -12,13 +12,17 @@ import { conditionalAiVaultListResult } from '../../../ai-vault/session-list-fin import { restampAiVaultListResult } from '../../../ai-vault/session-list-results' import type { AiVaultPrepareSessionResumeArgs } from '../../../../shared/ai-vault-resume-preparation' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { isWslUncPath } from '../../../../shared/wsl-paths' import { describeAiVaultScanError } from '../../../../shared/ai-vault-scan-error-message' import { assertLegacyAiVaultResumeAllowed, projectStructuredAiVaultSessions, searchWithStructuredOwners } from '../../../ai-vault/structured-session-ownership' -import { ensureStructuredAgentSessionHostUnlessRefused } from '../../structured-agent-session-host-refusal' +import { + ensureStructuredAgentSessionHostUnlessRefused, + tryEnsureStructuredAgentSessionHostForResume +} from '../../structured-agent-session-host-refusal' import { clientOpensStructuredChatFromHistory, supportsStructuredAgentSessions @@ -133,10 +137,13 @@ export const AI_VAULT_METHODS = [ // client-provided runtime/SSH stamp escape that host boundary. executionHostId: LOCAL_EXECUTION_HOST_ID } - await ensureStructuredAgentSessionHostUnlessRefused(() => - runtime.ensureStructuredAgentSessionHost() - ) - assertLegacyAiVaultResumeAllowed(args) + if (!isWslUncPath(args.filePath)) { + const ensureHost = () => runtime.ensureStructuredAgentSessionHost() + await (args.codexHome === null && !args.fork + ? tryEnsureStructuredAgentSessionHostForResume(ensureHost) + : ensureStructuredAgentSessionHostUnlessRefused(ensureHost)) + assertLegacyAiVaultResumeAllowed(args) + } return runtime.prepareAiVaultSessionResume(args) } }) diff --git a/src/main/runtime/structured-agent-session-host-refusal.ts b/src/main/runtime/structured-agent-session-host-refusal.ts index 30465c0fb12..765e05093f4 100644 --- a/src/main/runtime/structured-agent-session-host-refusal.ts +++ b/src/main/runtime/structured-agent-session-host-refusal.ts @@ -38,6 +38,25 @@ export async function ensureStructuredAgentSessionHostUnlessRefused( } } +/** Chat-host setup is bookkeeping for a terminal resume; an existing owner is checked afterward. */ +export async function tryEnsureStructuredAgentSessionHostForResume( + ensureHost: () => Promise +): Promise { + try { + await ensureStructuredAgentSessionHostUnlessRefused(ensureHost) + } catch (error) { + const code = isAgentSessionRefusalError(error) + ? error.refusal.code + : error instanceof Error + ? error.message + : null + if (code === 'agent_session_conflict' || code === 'agent_session_ownership_unknown') { + throw error + } + console.warn('[ai-vault] native chat host setup failed during terminal resume:', error) + } +} + /** Set when the journal database would not open; cleared by a later install. The install retries * on the next call, so a refusal that can clear does. */ export function recordStructuredAgentSessionHostInstallRefusal( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts index 6e9c9f95434..f5c86c938fa 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts @@ -1,5 +1,5 @@ import { translate } from '@/i18n/i18n' -import { compactIpcErrorMessage } from '@/lib/ipc-error' +import { isAiVaultOwnershipRefusal } from '@/lib/ai-vault-resume-refusal' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { getAgentForkArgv, isResumableTuiAgent } from '../../../../shared/agent-session-resume' import { getAiVaultAgentProviderSession } from '@/lib/ai-vault-resume-command' @@ -29,8 +29,7 @@ export function aiVaultSessionCliForkWorktreeId( * the fork as a second writer, so that refusal asks for the update instead of naming a code. */ export function describeAiVaultCliForkFailure(message: string): string { // The desktop preparation's refusal arrives inside Electron's IPC wrapper. - const code = compactIpcErrorMessage(message) - return code === 'agent_session_conflict' || code === 'agent_session_ownership_unknown' + return isAiVaultOwnershipRefusal(message) ? translate( 'auto.components.right.sidebar.AiVaultPanel.resumeInNewCliHostTooOld', 'Update Orca on the host that runs this chat to resume it in a new CLI.' diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts index 175a5d0b9e1..a17cfb36890 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ buildAiVaultResumeStartupForWorktree: vi.fn(), prepareAiVaultSessionForFork: vi.fn(), prepareAiVaultSessionForResume: vi.fn(), + prepareAiVaultSessionForTerminalResume: vi.fn(), launchAiVaultSessionInNewTab: vi.fn(), activateAiVaultResumeWorkspace: vi.fn(), toastError: vi.fn(), @@ -25,6 +26,7 @@ vi.mock('@/lib/ai-vault-resume-command', () => ({ vi.mock('@/lib/ai-vault-session-resume-preparation', () => ({ prepareAiVaultSessionForFork: mocks.prepareAiVaultSessionForFork, prepareAiVaultSessionForResume: mocks.prepareAiVaultSessionForResume, + prepareAiVaultSessionForTerminalResume: mocks.prepareAiVaultSessionForTerminalResume, dropDeletedSshResumeCwd: async (session: unknown) => session })) vi.mock('@/lib/launch-ai-vault-session', () => ({ @@ -154,3 +156,21 @@ describe('Resume in New CLI launch', () => { expect(mocks.launchAiVaultSessionInNewTab).not.toHaveBeenCalled() }) }) + +it('refuses the stale OMP row before creating a second terminal writer', async () => { + vi.clearAllMocks() + mocks.prepareAiVaultSessionForTerminalResume.mockRejectedValue( + new Error( + "Error invoking remote method 'aiVault:prepareSessionResume': Error: agent_session_conflict" + ) + ) + renderActions().handleResume( + { ...owned, agent: 'omp', structuredSession: undefined }, + 'worktree-1' + ) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) + expect(mocks.toastError).toHaveBeenCalledWith( + 'This OMP conversation is open in a chat. Refresh Session History to open it.' + ) + expect(mocks.launchAiVaultSessionInNewTab).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index 135a8d2ce21..08421b670b8 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -13,7 +13,8 @@ import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-t import { dropDeletedSshResumeCwd, prepareAiVaultSessionForFork, - prepareAiVaultSessionForResume + prepareAiVaultSessionForResume, + prepareAiVaultSessionForTerminalResume } from '@/lib/ai-vault-session-resume-preparation' import type { Worktree } from '../../../../shared/worktree/types' import { translate } from '@/i18n/i18n' @@ -25,6 +26,7 @@ import type { AgentSessionContinuationRequest } from '@/lib/agent-session-contin import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session' import { adoptsCliConversations } from '../../../../shared/structured-agent-cli-conversation-adoption' import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' +import { describeAiVaultResumeFailure } from '@/lib/ai-vault-resume-refusal' import { activateAiVaultResumeWorkspace, resumeAiVaultSessionInNewChat @@ -86,7 +88,7 @@ export function useAiVaultSessionLaunchActions({ ) ) } catch (error) { - notifyAiVaultSessionPreparationFailure(error) + notifyAiVaultSessionPreparationFailure(error, session.agent) } }, [buildResumeCommand] @@ -150,7 +152,9 @@ export function useAiVaultSessionLaunchActions({ } showQueuedToast() }) - .catch((error: unknown) => notifyAiVaultSessionPreparationFailure(error, describeFailure)) + .catch((error: unknown) => + notifyAiVaultSessionPreparationFailure(error, session.agent, describeFailure) + ) }, [activeWorktree?.id, activeWorktreeId, targetState] ) @@ -161,10 +165,14 @@ export function useAiVaultSessionLaunchActions({ void activateAiVaultStructuredSession(session) return } - launchInTerminal(session, targetWorktreeId, (worktreeId) => - prepareAiVaultSessionForResume(session) - .then(dropDeletedSshResumeCwd) - .then((preparedSession) => buildResumeStartup(preparedSession, worktreeId)) + launchInTerminal( + session, + targetWorktreeId, + (worktreeId) => + prepareAiVaultSessionForTerminalResume(session) + .then(dropDeletedSshResumeCwd) + .then((preparedSession) => buildResumeStartup(preparedSession, worktreeId)), + (message) => describeAiVaultResumeFailure(new Error(message), session.agent) ) }, [buildResumeStartup, launchInTerminal] @@ -277,16 +285,11 @@ export function useAiVaultSessionLaunchActions({ function notifyAiVaultSessionPreparationFailure( error: unknown, + agent: AiVaultAgent, describeFailure: (message: string) => string = (message) => message ): void { - toast.error( - error instanceof Error - ? describeFailure(error.message) - : translate( - 'auto.components.right.sidebar.AiVaultPanel.prepareSessionResumeFailed', - 'Could not prepare this session for resume.' - ) - ) + const described = error instanceof Error ? new Error(describeFailure(error.message)) : error + toast.error(describeAiVaultResumeFailure(described, agent)) } function resolveAiVaultSessionLaunchTargetOrNotify( diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index eb954cbe077..e785bb9de1a 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -18,12 +18,12 @@ import { getAiVaultAgentProviderSession } from '@/lib/ai-vault-resume-command' import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session' -import { aiVaultSessionNeedsResumePreparation } from '@/lib/ai-vault-session-resume-preparation' +import { prepareAiVaultTerminalResumeRequest } from '@/lib/ai-vault-session-resume-preparation' +import { describeAiVaultResumeFailure } from '@/lib/ai-vault-resume-refusal' import { useAppStore } from '@/store' import { resolveDropZone } from './tab-drop-zone' import type { TabDropZone } from './useTabDragSplit' import { translate } from '@/i18n/i18n' -import type { AiVaultPrepareSessionResumeResult } from '../../../../shared/ai-vault-resume-preparation' import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session' type PaneDropTarget = { @@ -222,23 +222,22 @@ export default function AiVaultSessionDropLayer({ ) ) } - const preparation = - payload.sessionFilePath && - payload.sessionExecutionHostId && - payload.codexHome !== undefined && - aiVaultSessionNeedsResumePreparation({ - agent: payload.agent, - codexHome: payload.codexHome, - executionHostId: payload.sessionExecutionHostId - }) - ? window.api.aiVault.prepareSessionResume({ - agent: payload.agent, - sessionId: payload.sessionId, - filePath: payload.sessionFilePath, - executionHostId: payload.sessionExecutionHostId, - codexHome: payload.codexHome - }) - : Promise.resolve({ useRealCodexHome: false }) + if (!payload.sessionFilePath || !payload.sessionExecutionHostId) { + toast.error( + translate( + 'auto.components.tab.group.AiVaultSessionDropLayer.oldPayload', + 'This session was dragged from an older Orca window. Resume it from Session History instead.' + ) + ) + return true + } + const preparation = prepareAiVaultTerminalResumeRequest({ + agent: payload.agent, + sessionId: payload.sessionId, + filePath: payload.sessionFilePath, + executionHostId: payload.sessionExecutionHostId, + codexHome: payload.codexHome ?? null + }) void preparation .then((result) => { const startup = result.useRealCodexHome @@ -282,7 +281,9 @@ export default function AiVaultSessionDropLayer({ void launchResult.runtimeLaunch.then((outcome) => { if (outcome.status === 'failed') { toast.error( - outcome.message || + (outcome.message + ? describeAiVaultResumeFailure(new Error(outcome.message), payload.agent) + : null) || translate( 'auto.lib.launch.agent.in.new.tab.11cce5cc77', 'Could not launch {{value0}} in a new terminal.', @@ -298,14 +299,7 @@ export default function AiVaultSessionDropLayer({ showQueuedToast() }) .catch((error: unknown) => { - toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.right.sidebar.AiVaultPanel.prepareSessionResumeFailed', - 'Could not prepare this session for resume.' - ) - ) + toast.error(describeAiVaultResumeFailure(error, payload.agent)) }) return true }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d3b0d9ad556..db715cb3ff8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1220,6 +1220,10 @@ } } } + }, + "aiVaultResumeRefusal": { + "openInChat": "This {{value0}} conversation is open in a chat. Refresh Session History to open it.", + "ownershipUnknown": "Orca could not confirm who owns this {{value0}} conversation. Refresh Session History and try again." } }, "hooks": { @@ -3660,7 +3664,8 @@ "sessionQueued": "Session queued", "openSupportedWorkspace": "Open a workspace before resuming a session.", "sessionHostMismatchUnsupported": "This session belongs to a different host. Drop it onto a workspace on the same host.", - "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead." + "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead.", + "oldPayload": "This session was dragged from an older Orca window. Resume it from Session History instead." }, "TabGroupDropOverlay": { "paneColumnLabel": "New split" diff --git a/src/renderer/src/lib/ai-vault-resume-refusal.ts b/src/renderer/src/lib/ai-vault-resume-refusal.ts new file mode 100644 index 00000000000..23e3902e593 --- /dev/null +++ b/src/renderer/src/lib/ai-vault-resume-refusal.ts @@ -0,0 +1,34 @@ +import { aiVaultAgentLabel, type AiVaultAgent } from '../../../shared/ai-vault-types' +import { translate } from '@/i18n/i18n' +import { compactIpcErrorMessage } from './ipc-error' + +export function isAiVaultOwnershipRefusal(message: string): boolean { + const code = compactIpcErrorMessage(message) + return code === 'agent_session_conflict' || code === 'agent_session_ownership_unknown' +} + +export function describeAiVaultResumeFailure(error: unknown, agent: AiVaultAgent): string { + const message = error instanceof Error ? error.message : null + const code = message ? compactIpcErrorMessage(message) : null + if (code === 'agent_session_conflict') { + return translate( + 'auto.lib.aiVaultResumeRefusal.openInChat', + 'This {{value0}} conversation is open in a chat. Refresh Session History to open it.', + { value0: aiVaultAgentLabel(agent) } + ) + } + if (code === 'agent_session_ownership_unknown') { + return translate( + 'auto.lib.aiVaultResumeRefusal.ownershipUnknown', + 'Orca could not confirm who owns this {{value0}} conversation. Refresh Session History and try again.', + { value0: aiVaultAgentLabel(agent) } + ) + } + return ( + message ?? + translate( + 'auto.components.right.sidebar.AiVaultPanel.prepareSessionResumeFailed', + 'Could not prepare this session for resume.' + ) + ) +} diff --git a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts index ebabe7c2244..b29f32fba47 100644 --- a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts @@ -3,7 +3,8 @@ import type { AiVaultSession } from '../../../shared/ai-vault-types' import { dropDeletedSshResumeCwd, prepareAiVaultSessionForFork, - prepareAiVaultSessionForResume + prepareAiVaultSessionForResume, + prepareAiVaultSessionForTerminalResume } from './ai-vault-session-resume-preparation' afterEach(() => { @@ -99,6 +100,29 @@ describe('prepareAiVaultSessionForResume', () => { }) }) +describe('ordinary Resume against an older paired host', () => { + it.each([ + new Error( + "Error invoking remote method 'aiVault:prepareSessionResume': Error: aiVault.prepareSessionResume: method_not_found" + ), + Object.assign(new Error('Unknown method: aiVault.prepareSessionResume'), { + code: 'method_not_found' + }) + ])('retains neutral resumes but requires managed Codex home preparation', async (error) => { + stubPreparation(vi.fn().mockRejectedValue(error)) + const remote = session({ agent: 'claude', executionHostId: 'runtime:paired' }) + await expect(prepareAiVaultSessionForTerminalResume(remote)).resolves.toBe(remote) + await expect( + prepareAiVaultSessionForTerminalResume( + session({ + executionHostId: 'runtime:paired', + codexHome: '/tmp/orca/codex-runtime-home/home' + }) + ) + ).rejects.toBe(error) + }) +}) + describe('prepareAiVaultSessionForFork', () => { // A fork of a chat-owned conversation must run under the selected account, as a resume does. it('repins a chat-owned per-account session, asking the host as a fork', async () => { diff --git a/src/renderer/src/lib/ai-vault-session-resume-preparation.ts b/src/renderer/src/lib/ai-vault-session-resume-preparation.ts index 8740d7a7796..04bef7974be 100644 --- a/src/renderer/src/lib/ai-vault-session-resume-preparation.ts +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.ts @@ -1,20 +1,83 @@ import type { AiVaultSession } from '../../../shared/ai-vault-types' +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult +} from '../../../shared/ai-vault-resume-preparation' import { isLegacySharedCodexHome, isPerAccountManagedCodexHome } from '../../../shared/ai-vault-resume-preparation' import { getSshTargetIdForExecutionHost, - LOCAL_EXECUTION_HOST_ID + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId } from '../../../shared/execution-host' +import { readIpcErrorMessage } from './ipc-error' import type { AiVaultResumeCommandSession } from './ai-vault-resume-command' export function prepareAiVaultSessionForResume(session: AiVaultSession): Promise { - return session.structuredSession + return session.structuredSession || !aiVaultSessionNeedsResumePreparation(session) ? Promise.resolve(session) : prepareAiVaultSessionHome(session, false) } +export function prepareAiVaultSessionForTerminalResume( + session: AiVaultSession +): Promise { + return prepareAiVaultSessionHome(session, false, true) +} + +export function prepareAiVaultSessionResumeRequest( + args: AiVaultPrepareSessionResumeArgs +): Promise { + return window.api.aiVault.prepareSessionResume(args) +} + +export async function prepareAiVaultTerminalResumeRequest( + args: AiVaultPrepareSessionResumeArgs +): Promise { + const needsHomePreparation = aiVaultSessionNeedsResumePreparation(args) + try { + // Why: this request checks ownership; old desktop resumes kept the recorded home otherwise. + const result = await prepareAiVaultSessionResumeRequest( + needsHomePreparation ? args : { ...args, codexHome: null } + ) + if ( + typeof result?.useRealCodexHome !== 'boolean' || + (result.substituteCodexHome !== undefined && typeof result.substituteCodexHome !== 'string') + ) { + throw new Error('Invalid aiVault.prepareSessionResume response') + } + return needsHomePreparation ? result : { useRealCodexHome: false } + } catch (error) { + if ( + !args.fork && + parseExecutionHostId(args.executionHostId)?.kind === 'runtime' && + !needsHomePreparation && + isMissingPairedResumePreparation(error) + ) { + return { useRealCodexHome: false } + } + throw error + } +} + +function isMissingPairedResumePreparation(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + const desktopPrepareError = + error.message.startsWith("Error invoking remote method 'aiVault:prepareSessionResume':") || + error.message.startsWith("Error occurred in handler for 'aiVault:prepareSessionResume':") + return ( + (desktopPrepareError && + readIpcErrorMessage(error) === 'aiVault.prepareSessionResume: method_not_found') || + ('code' in error && + error.code === 'method_not_found' && + error.message === 'Unknown method: aiVault.prepareSessionResume') + ) +} + /** Picks the Codex home a fork of `session` runs under, the same way a resume picks it. */ export function prepareAiVaultSessionForFork(session: AiVaultSession): Promise { return prepareAiVaultSessionHome(session, true) @@ -22,19 +85,23 @@ export function prepareAiVaultSessionForFork(session: AiVaultSession): Promise { - if (!aiVaultSessionNeedsResumePreparation(session)) { + if (fork && !aiVaultSessionNeedsResumePreparation(session)) { return session } - const result = await window.api.aiVault.prepareSessionResume({ + const args: AiVaultPrepareSessionResumeArgs = { agent: session.agent, sessionId: session.sessionId, filePath: session.filePath, codexHome: session.codexHome, executionHostId: session.executionHostId, ...(fork ? { fork: true } : {}) - }) + } + const result = await (terminalResume + ? prepareAiVaultTerminalResumeRequest(args) + : prepareAiVaultSessionResumeRequest(args)) if (result.useRealCodexHome) { return { ...session, codexHome: null } }