mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(session-history): refuse desktop resumes of chat-owned conversations (#28229)
* fix(session-history): check host ownership before desktop resume * fix(session-history): preserve resume behavior across hosts * test: keep only tests that pin user-visible behavior
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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<void>
|
||||
@@ -26,8 +28,6 @@ export async function prepareAiVaultSessionResume(
|
||||
args: AiVaultPrepareSessionResumeArgs,
|
||||
options: AiVaultResumeHandlerOptions
|
||||
): Promise<AiVaultPrepareSessionResumeResult> {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<unknown>
|
||||
): Promise<void> {
|
||||
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(
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<AiVaultPrepareSessionResumeResult>({ 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
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<AiVaultSession> {
|
||||
return session.structuredSession
|
||||
return session.structuredSession || !aiVaultSessionNeedsResumePreparation(session)
|
||||
? Promise.resolve(session)
|
||||
: prepareAiVaultSessionHome(session, false)
|
||||
}
|
||||
|
||||
export function prepareAiVaultSessionForTerminalResume(
|
||||
session: AiVaultSession
|
||||
): Promise<AiVaultSession> {
|
||||
return prepareAiVaultSessionHome(session, false, true)
|
||||
}
|
||||
|
||||
export function prepareAiVaultSessionResumeRequest(
|
||||
args: AiVaultPrepareSessionResumeArgs
|
||||
): Promise<AiVaultPrepareSessionResumeResult> {
|
||||
return window.api.aiVault.prepareSessionResume(args)
|
||||
}
|
||||
|
||||
export async function prepareAiVaultTerminalResumeRequest(
|
||||
args: AiVaultPrepareSessionResumeArgs
|
||||
): Promise<AiVaultPrepareSessionResumeResult> {
|
||||
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<AiVaultSession> {
|
||||
return prepareAiVaultSessionHome(session, true)
|
||||
@@ -22,19 +85,23 @@ export function prepareAiVaultSessionForFork(session: AiVaultSession): Promise<A
|
||||
|
||||
async function prepareAiVaultSessionHome(
|
||||
session: AiVaultSession,
|
||||
fork: boolean
|
||||
fork: boolean,
|
||||
terminalResume = false
|
||||
): Promise<AiVaultSession> {
|
||||
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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user