diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index b60c71ddce6..586c41dfd62 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -1,3 +1,4 @@ +import { AiVaultChatForkSourceSchema } from '../../shared/ai-vault-chat-fork-contract' import { z } from 'zod' import { AI_VAULT_AGENTS, @@ -10,7 +11,7 @@ import { toRuntimeExecutionHostId } from '../../shared/execution-host' import { listEnvironments } from '../../shared/runtime-environment-store' import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' import type { - AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeRequest, AiVaultPrepareSessionResumeResult } from '../../shared/ai-vault-resume-preparation' import type { @@ -34,6 +35,7 @@ export type RuntimeAiVaultScanOptions = { // parent would silently drop it and resume under the wrong account's home. const aiVaultPrepareSessionResumeResultSchema = z.object({ useRealCodexHome: z.boolean(), + cliForkSource: AiVaultChatForkSourceSchema.optional(), substituteCodexHome: z.string().optional(), forkedSession: z .object({ @@ -148,7 +150,7 @@ export async function resolveRuntimeAiVaultSessionTitles( export async function prepareRuntimeAiVaultSessionResume( userDataPath: string, environmentId: string, - args: AiVaultPrepareSessionResumeArgs + args: AiVaultPrepareSessionResumeRequest ): Promise { const response = await callRuntimeEnvironment( userDataPath, diff --git a/src/main/ai-vault/structured-chat-cli-fork.test.ts b/src/main/ai-vault/structured-chat-cli-fork.test.ts new file mode 100644 index 00000000000..d4e0891060b --- /dev/null +++ b/src/main/ai-vault/structured-chat-cli-fork.test.ts @@ -0,0 +1,210 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, expect, it, vi } from 'vitest' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentJournalSnapshot } from '../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { prepareStructuredChatCliFork } from './structured-chat-cli-fork' +import { prepareLegacySharedCodexSessionResume } from '../codex/codex-legacy-session-resume' +import { AiVaultPrepareSessionResumeParams } from '../../shared/rpc-contract/ai-vault-params' + +let root: string | undefined +const request = { + sourceChat: { sessionId: 'session-alpha-1', workspaceId: 'workspace-1' }, + fork: true +} as const +const prepare = vi.fn(async () => ({ useRealCodexHome: false })) +function savedJournal(): AgentJournalSnapshot { + return { + sessionId: 'session-alpha-1', + cursor: { epoch: 'e', sequence: 1 }, + submissions: [], + items: [ + { + itemId: 'legacy:pi:source:user-1', + revision: 0, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Saved prompt' }] } + } + ] + } +} +function install(record: AgentSessionRecord, copy = vi.fn(), journal = savedJournal()) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: preparation reads only these host dependencies. + setStructuredAgentSessionHost({ + journalSnapshot: async () => journal, + deps: { + store: { getRecord: () => record, listRecords: () => [record] }, + adapter: { copyConversation: copy }, + resolveWorkspacePath: async () => root, + resolveLaunchEnv: () => ({}) + } + } as never) +} +afterEach(async () => { + setStructuredAgentSessionHost(null) + prepare.mockClear() + vi.unstubAllEnvs() + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = undefined +}) + +it('locates the current Codex rollout in its recorded home and materializes the selected home', async () => { + root = await mkdtemp(join(tmpdir(), 'chat-cli-fork-')) + vi.stubEnv('ORCA_USER_DATA_PATH', root) + const home = join(root, 'codex-runtime-home', 'home') + const system = join(root, 'system') + const record = agentSessionRecordFixture() + record.provider = 'codex' + record.accountHome = { variable: 'CODEX_HOME', path: home } + record.providerHandleChain = [ + { + ...record.providerHandleChain[0]!, + handle: { transport: 'codex-app-server', agent: 'codex', nativeId: 'obsolete' } + }, + { + ...record.providerHandleChain[0]!, + linkId: 'latest', + handle: { transport: 'codex-app-server', agent: 'codex', nativeId: 'current-conversation' } + } + ] + const relative = join('2026', '10', '15', 'rollout-2026-10-15-current-conversation.jsonl') + const source = join(home, 'sessions', relative) + await mkdir(join(home, 'sessions', '2026', '10', '15'), { recursive: true }) + await writeFile(source, '{"saved":true}\n') + install(record) + const result = await prepareStructuredChatCliFork(request, async (args) => { + expect(args.sessionId).toBe('current-conversation') + expect(args.filePath).toBe(source) + const prepared = await prepareLegacySharedCodexSessionResume(args, { + isHostSystemDefaultRealHomeSelected: () => true, + legacyCodexHomePath: home, + systemCodexHomePath: system + }) + // Production uses its system home; this fixture selects the same materialized home explicitly. + return { ...prepared, useRealCodexHome: false, substituteCodexHome: system } + }) + expect(result.cliForkSource).toMatchObject({ + sessionId: 'current-conversation', + codexHome: system, + filePath: join(system, 'sessions', relative), + cwd: root + }) +}) + +it('copies OpenCode directly from the selected record and preserves its typed account and copy id', async () => { + root = await mkdtemp(join(tmpdir(), 'chat-cli-fork-')) + const record = agentSessionRecordFixture() + record.provider = 'opencode' + record.accountHome = { kind: 'opencode', locator: { kind: 'unmanaged' } } + record.providerHandleChain = [ + { + ...record.providerHandleChain[0]!, + handle: { agent: 'opencode', transport: 'acp', nativeId: 'ses_source' } + } + ] + const copy = vi.fn(async () => ({ sessionId: 'ses_copy' })) + install(record, copy) + const result = await prepareStructuredChatCliFork(request, prepare) + expect(copy).toHaveBeenCalledWith( + expect.objectContaining({ + accountHome: record.accountHome, + providerSessionId: 'ses_source', + identity: expect.objectContaining({ sessionId: record.sessionId }) + }) + ) + expect(result.cliForkSource).toMatchObject({ + agent: 'opencode', + sourceAgent: 'opencode2', + sessionId: 'ses_copy' + }) + expect(prepare).not.toHaveBeenCalled() +}) + +it('uses the Pi file handle and refuses a mismatched saved header or missing handle', async () => { + root = await mkdtemp(join(tmpdir(), 'chat-cli-fork-')) + const filePath = join(root, '2026-10-15_12345678-abcd-1234-abcd-123456789abc.jsonl') + const record = agentSessionRecordFixture() + record.provider = 'pi' + record.providerHandleChain = [ + { + ...record.providerHandleChain[0]!, + handle: { agent: 'pi', transport: 'jsonl-rpc', nativeId: filePath } + } + ] + await writeFile( + filePath, + `${JSON.stringify({ type: 'session', id: '12345678-abcd-1234-abcd-123456789abc', cwd: root })}\n` + ) + const journal = savedJournal() + const savedMessage = journal.items.splice(0) + install(record, vi.fn(), journal) + await expect(prepareStructuredChatCliFork(request, prepare)).rejects.toThrow( + 'no saved conversation' + ) + expect(prepare).not.toHaveBeenCalled() + await writeFile( + filePath, + `${JSON.stringify({ type: 'session', id: '12345678-abcd-1234-abcd-123456789abc', cwd: root })}\n${JSON.stringify({ type: 'message', id: 'user-1', parentId: null, message: { role: 'user', content: [{ type: 'text', text: 'Saved prompt' }], timestamp: 1 } })}\n` + ) + journal.items.push(...savedMessage) + await expect(prepareStructuredChatCliFork(request, prepare)).resolves.toMatchObject({ + cliForkSource: { filePath, sessionId: '12345678-abcd-1234-abcd-123456789abc' } + }) + journal.items[0]!.itemId = agentJournalSubmissionKey('send-1') + for (const dispatchState of ['pending', 'rejected', 'unknown', 'accepted'] as const) { + journal.submissions = [ + { + clientMessageId: 'send-1', + fence: 1, + payloadFingerprint: 'p', + dispatchState, + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null, + handoverRecorded: true + } + ] + const preparation = prepareStructuredChatCliFork(request, prepare) + await (dispatchState === 'accepted' + ? expect(preparation).resolves.toHaveProperty('cliForkSource') + : expect(preparation).rejects.toThrow( + dispatchState === 'unknown' ? 'Could not confirm' : 'no saved conversation' + )) + } + await writeFile(filePath, `${JSON.stringify({ type: 'session', id: 'wrong', cwd: root })}\n`) + await expect(prepareStructuredChatCliFork(request, prepare)).rejects.toThrow('does not match') + record.providerHandleChain = [] + await expect(prepareStructuredChatCliFork(request, prepare)).rejects.toThrow( + 'no saved conversation' + ) +}) + +it('refuses a foreign workspace or WSL record and rejects mixed chat/row requests', async () => { + root = await mkdtemp(join(tmpdir(), 'chat-cli-fork-')) + const record = agentSessionRecordFixture() + install(record) + await expect( + prepareStructuredChatCliFork( + { ...request, sourceChat: { ...request.sourceChat, workspaceId: 'other' } }, + prepare + ) + ).rejects.toThrow('on this host') + record.location.wslDistro = 'Ubuntu' + await expect(prepareStructuredChatCliFork(request, prepare)).rejects.toThrow('on this host') + expect( + AiVaultPrepareSessionResumeParams.safeParse({ + ...request, + agent: 'claude', + filePath: '/foreign' + }).success + ).toBe(false) + expect(prepare).not.toHaveBeenCalled() +}) diff --git a/src/main/ai-vault/structured-chat-cli-fork.ts b/src/main/ai-vault/structured-chat-cli-fork.ts new file mode 100644 index 00000000000..f1282972b4d --- /dev/null +++ b/src/main/ai-vault/structured-chat-cli-fork.ts @@ -0,0 +1,179 @@ +import { stat } from 'node:fs/promises' +import { isAbsolute, join, relative } from 'node:path' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { agentForksConversations, agentForksThroughChat } from '../../shared/agent-resume-argv' +import { isLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' +import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' +import type { + AiVaultPrepareChatForkArgs, + AiVaultPrepareSessionResumeResult, + AiVaultSessionResumePreparation +} from '../../shared/ai-vault-resume-preparation' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { isWslUncPath } from '../../shared/wsl-paths' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { structuredAgentRuntimeRegistration } from '../runtime/structured-agent-runtime-registrations' +import { readPiSessionHeader } from '../pi/rpc-launch-resolution' +import { copyStructuredChatRecordConversation } from './structured-chat-conversation-copy' +import { isAiVaultAgent } from './session-list-result-validation' +import type { AgentJournalSnapshot } from '../../shared/agent-session-journal-types' +import { + agentJournalSubmissionKey, + parseAgentJournalItemKey +} from '../../shared/agent-session-journal-item-key' + +function assertNativeConversationContent(snapshot: AgentJournalSnapshot): void { + const submissions = new Map( + snapshot.submissions.map((submission) => [ + agentJournalSubmissionKey(submission.clientMessageId), + submission + ]) + ) + let uncertain = false + const hasContent = snapshot.items.some(({ itemId, body }) => { + if (body.kind !== 'message' || (body.role !== 'user' && body.role !== 'assistant')) { + return false + } + const identity = parseAgentJournalItemKey(itemId) + const submission = submissions.get(itemId) + if (submission?.dispatchState === 'accepted' || (identity && identity.provider !== 'orca')) { + return true + } + // Write-ahead messages alone prove neither provider content nor failed delivery. + uncertain ||= + !submission || + submission.dispatchState === 'unknown' || + (submission.dispatchState === 'pending' && + (!submission.handoverRecorded || submission.handedOverAt !== undefined)) + return false + }) + if (!hasContent) { + throw new Error( + uncertain + ? 'Could not confirm this conversation was saved. Try again after the agent responds.' + : 'This chat has no saved conversation to copy.' + ) + } +} + +/** Prepares the record's current conversation on its execution host, without a History inventory. */ +export async function prepareStructuredChatCliFork( + args: AiVaultPrepareChatForkArgs, + prepare: AiVaultSessionResumePreparation +): Promise { + const host = getStructuredAgentSessionHost() + const record = host?.deps.store.getRecord(args.sourceChat.sessionId) + const registration = record && structuredAgentRuntimeRegistration(record.provider) + const head = record && agentSessionProviderHandleChainHead(record.providerHandleChain) + if ( + !host || + !record || + !registration || + !head || + record.location.workspaceId !== args.sourceChat.workspaceId || + record.location.executionHostId !== LOCAL_EXECUTION_HOST_ID || + record.location.wslDistro !== null || + // A typed locator has no CODEX_HOME path; forking would silently use the default account. + (record.provider === 'codex' && !isLegacyAgentSessionAccountHome(record.accountHome)) || + !isAiVaultAgent(record.provider) || + !agentForksConversations(record.provider) + ) { + throw new Error('This chat has no saved conversation available on this host.') + } + const agent = record.provider + const cwd = + (await host.deps.resolveWorkspacePath?.(record.location.workspaceId)) || record.launchDirectory + if (!cwd || !isAbsolute(cwd) || isWslUncPath(cwd)) { + throw new Error('The chat workspace is unavailable on this host.') + } + const providerSessionId = registration.sessionHistory.rowSessionId(head) + if (!providerSessionId.trim()) { + throw new Error('This chat has no saved conversation to copy.') + } + assertNativeConversationContent(await host.journalSnapshot(record.sessionId)) + if (agentForksThroughChat(agent)) { + const result = await copyStructuredChatRecordConversation(record, providerSessionId) + const copy = result.forkedSession + if (!copy || (agent === 'omp' && !copy.filePath)) { + throw new Error('The agent did not return a saved conversation copy.') + } + return { + ...result, + cliForkSource: { + agent: copy.agent, + sourceAgent: registration.sessionHistory.rowAgents.at(-1) ?? agent, + sessionId: copy.sessionId, + filePath: copy.filePath ?? '', + codexHome: null, + cwd, + executionHostPlatform: process.platform + } + } + } + const importer = registration.transcriptImport + if (!importer) { + throw new Error('This chat cannot be opened in the CLI.') + } + const launchEnv = { ...process.env, ...(await host.deps.resolveLaunchEnv?.(agent)) } + let filePath: string | null + if (importer.handleNamesTranscript) { + filePath = head.handle.nativeId + if (head.handle.transport !== 'jsonl-rpc' || !isAbsolute(filePath) || isWslUncPath(filePath)) { + throw new Error('The chat has no valid saved session file.') + } + const header = await readPiSessionHeader(filePath) + if (!header || header.id !== providerSessionId) { + throw new Error('The saved conversation does not match this chat.') + } + } else { + filePath = await importer.findTranscript({ + providerSessionId, + accountHome: record.accountHome, + launchEnv, + workspaceCwd: cwd + }) + } + if (!filePath || !isAbsolute(filePath) || isWslUncPath(filePath)) { + throw new Error('This chat has no saved conversation to copy.') + } + const saved = await stat(filePath) + if (!saved.isFile() || saved.size === 0) { + throw new Error('This chat has no saved conversation to copy.') + } + const codexHome = + agent === 'codex' && isLegacyAgentSessionAccountHome(record.accountHome) + ? record.accountHome.path + : null + const result = await prepare({ + agent, + sessionId: providerSessionId, + filePath, + codexHome, + executionHostId: LOCAL_EXECUTION_HOST_ID, + fork: true + }) + const preparedHome = result.useRealCodexHome ? null : (result.substituteCodexHome ?? codexHome) + if (codexHome && (result.useRealCodexHome || result.substituteCodexHome)) { + filePath = join( + preparedHome ?? getSystemCodexHomePath(), + 'sessions', + relative(join(codexHome, 'sessions'), filePath) + ) + const preparedFile = await stat(filePath) + if (!preparedFile.isFile() || preparedFile.size === 0) { + throw new Error('The prepared conversation is unavailable in the selected account.') + } + } + return { + ...result, + cliForkSource: { + agent, + sourceAgent: agent, + sessionId: providerSessionId, + filePath, + cwd, + codexHome: preparedHome, + executionHostPlatform: process.platform + } + } +} diff --git a/src/main/ai-vault/structured-chat-conversation-copy.ts b/src/main/ai-vault/structured-chat-conversation-copy.ts index 275f8f1d691..b2272c7d881 100644 --- a/src/main/ai-vault/structured-chat-conversation-copy.ts +++ b/src/main/ai-vault/structured-chat-conversation-copy.ts @@ -1,3 +1,4 @@ +import type { AgentSessionRecord } from '../../shared/agent-session-record' import { agentForksThroughChat } from '../../shared/agent-resume-argv' import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' import type { @@ -26,6 +27,18 @@ export async function copyStructuredChatConversation( if (!host || !ownership || !record || !copy) { throw new Error('This chat is no longer in Orca, so it cannot be copied to a new CLI.') } + return copyStructuredChatRecordConversation(record, ownership.providerSessionId) +} + +export async function copyStructuredChatRecordConversation( + record: AgentSessionRecord, + providerSessionId: string +): Promise { + const host = getStructuredAgentSessionHost() + const copy = host?.deps.adapter.copyConversation + if (!host || !copy || !isAiVaultAgent(record.provider)) { + throw new Error('This chat cannot be copied to a new CLI.') + } const forkedSession = await copy.call(host.deps.adapter, { identity: { sessionId: record.sessionId, @@ -35,12 +48,17 @@ export async function copyStructuredChatConversation( providerHandle: agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle ?? null }, - providerSessionId: ownership.providerSessionId, + providerSessionId, accountHome: record.accountHome }) if ( - forkedSession.sessionId === ownership.providerSessionId || - findResumeOwnership({ agent: args.agent, sessionId: forkedSession.sessionId, filePath: '' }) + !forkedSession.sessionId.trim() || + forkedSession.sessionId === providerSessionId || + findResumeOwnership({ + agent: record.provider, + sessionId: forkedSession.sessionId, + filePath: '' + }) ) { throw new Error('The agent returned a conversation already owned by a chat instead of a copy.') } @@ -49,7 +67,7 @@ export async function copyStructuredChatConversation( forkedSession: { ...forkedSession, // The chat's own agent, not the row's: an OpenCode 2 row's chat runs `opencode`. - agent: isAiVaultAgent(record.provider) ? record.provider : args.agent + agent: record.provider } } } diff --git a/src/main/ipc/ai-vault-resume.ts b/src/main/ipc/ai-vault-resume.ts index c46eeaecaa2..ba455ecf69a 100644 --- a/src/main/ipc/ai-vault-resume.ts +++ b/src/main/ipc/ai-vault-resume.ts @@ -1,6 +1,8 @@ +import { AiVaultPrepareSessionResumeParams } from '../../shared/rpc-contract/ai-vault-params' +import { prepareStructuredChatCliFork } from '../ai-vault/structured-chat-cli-fork' import { ipcMain } from 'electron' import type { - AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeRequest, AiVaultPrepareSessionResumeResult, AiVaultSessionResumePreparation } from '../../shared/ai-vault-resume-preparation' @@ -15,18 +17,19 @@ export type AiVaultResumeHandlerOptions = { prepareSessionResume?: AiVaultSessionResumePreparation prepareRuntimeSessionResume?: ( environmentId: string, - args: AiVaultPrepareSessionResumeArgs + args: AiVaultPrepareSessionResumeRequest ) => Promise } export function registerAiVaultResumeHandler(options: AiVaultResumeHandlerOptions): void { - ipcMain.handle('aiVault:prepareSessionResume', (_event, args: AiVaultPrepareSessionResumeArgs) => - prepareAiVaultSessionResume(args, options) + ipcMain.handle( + 'aiVault:prepareSessionResume', + (_event, args: AiVaultPrepareSessionResumeRequest) => prepareAiVaultSessionResume(args, options) ) } export async function prepareAiVaultSessionResume( - args: AiVaultPrepareSessionResumeArgs, + args: AiVaultPrepareSessionResumeRequest, options: AiVaultResumeHandlerOptions ): Promise { const executionHost = parseExecutionHostId(args.executionHostId) @@ -36,6 +39,20 @@ export async function prepareAiVaultSessionResume( } return options.prepareRuntimeSessionResume(executionHost.environmentId, args) } + if ('sourceChat' in args) { + const parsed = AiVaultPrepareSessionResumeParams.parse(args) + if (!('sourceChat' in parsed)) { + throw new Error('Invalid chat source.') + } + if (executionHost?.kind === 'ssh') { + throw new Error('This chat cannot be copied on this host.') + } + await options.ensureStructuredSessionOwnership?.() + return prepareStructuredChatCliFork( + parsed, + options.prepareSessionResume ?? (async () => ({ useRealCodexHome: false })) + ) + } // Why: the desktop process must never materialize transcript paths owned by an SSH host. if (executionHost?.kind === 'ssh') { return { useRealCodexHome: false } diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index effaec21648..64fb030faf4 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -1,3 +1,4 @@ +import { prepareStructuredChatCliFork } from '../../../ai-vault/structured-chat-cli-fork' import { AiVaultSearchRequestSchema, AiVaultSearchStatusRequestSchema, @@ -128,6 +129,12 @@ export const AI_VAULT_METHODS = [ permission: 'workspace', params: AiVaultPrepareSessionResumeParams, handler: async (params, { runtime }) => { + if ('sourceChat' in params) { + await runtime.ensureStructuredAgentSessionHost() + return prepareStructuredChatCliFork(params, (args) => + runtime.prepareAiVaultSessionResume(args) + ) + } const args: AiVaultPrepareSessionResumeArgs = { agent: params.agent, ...(params.sessionId ? { sessionId: params.sessionId } : {}), diff --git a/src/preload/api/ai-vault-api.ts b/src/preload/api/ai-vault-api.ts index 7314ca78251..f1933d331db 100644 --- a/src/preload/api/ai-vault-api.ts +++ b/src/preload/api/ai-vault-api.ts @@ -20,7 +20,7 @@ import type { AiVaultSessionTitlesResult } from '../../shared/ai-vault-session-title' import type { - AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeRequest, AiVaultPrepareSessionResumeResult } from '../../shared/ai-vault-resume-preparation' import type { ExecutionHostId, ExecutionHostScope } from '../../shared/execution-host' @@ -52,7 +52,7 @@ export type AiVaultApi = { resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => Promise cancelListSessions: (args: { requestToken: string }) => Promise prepareSessionResume: ( - args: AiVaultPrepareSessionResumeArgs + args: AiVaultPrepareSessionResumeRequest ) => Promise /** Lists the Task subagent transcripts of one session, on demand. */ listSubagentSessions: (args: AiVaultSubagentListArgs) => Promise diff --git a/src/preload/api/ai-vault-bridge.ts b/src/preload/api/ai-vault-bridge.ts index 425b0dfbc53..c28de00481e 100644 --- a/src/preload/api/ai-vault-bridge.ts +++ b/src/preload/api/ai-vault-bridge.ts @@ -12,7 +12,7 @@ import type { AiVaultSubagentListArgs } from '../../shared/ai-vault-types' import type { AiVaultSessionTitlesArgs } from '../../shared/ai-vault-session-title' -import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation' +import type { AiVaultPrepareSessionResumeRequest } from '../../shared/ai-vault-resume-preparation' import type { PreloadApi } from '../api-types' import { AI_VAULT_SCAN_PROGRESS_CHANNEL, @@ -49,7 +49,7 @@ export const aiVaultApi = { ipcRenderer.invoke('aiVault:resolveSessionTitles', args), cancelListSessions: (args: { requestToken: string }): Promise => ipcRenderer.invoke('aiVault:cancelListSessions', args), - prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) => + prepareSessionResume: (args: AiVaultPrepareSessionResumeRequest) => ipcRenderer.invoke('aiVault:prepareSessionResume', args), listSubagentSessions: (args: AiVaultSubagentListArgs) => ipcRenderer.invoke('aiVault:listSubagentSessions', args), diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionSurfaceSwitchMenuItems.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionSurfaceSwitchMenuItems.tsx index 74acfc2e07d..adcc846079c 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionSurfaceSwitchMenuItems.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionSurfaceSwitchMenuItems.tsx @@ -3,6 +3,7 @@ import { DropdownMenuItem } from '@/components/ui/dropdown-menu' import { ContextMenuItem } from '@/components/ui/context-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' +import { LoadingSpinner } from '@/components/LoadingSpinner' import { ResumeInNewCliTooltipText } from './ai-vault-session-cli-fork-copy' /** "Resume in New Native Chat" / "Resume in New CLI", shared by the session-history row menus and @@ -11,12 +12,14 @@ export function AiVaultSessionSurfaceSwitchMenuItems({ menuKind, tooltipSide, onResumeInNewChat, - onResumeInNewCli + onResumeInNewCli, + cliPending = false }: { menuKind: 'dropdown' | 'context' tooltipSide: 'left' | 'right' onResumeInNewChat?: () => void - onResumeInNewCli?: () => void + onResumeInNewCli?: (event: Event) => void + cliPending?: boolean }): React.JSX.Element { const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem return ( @@ -33,8 +36,12 @@ export function AiVaultSessionSurfaceSwitchMenuItems({ {onResumeInNewCli ? ( - - + + {cliPending ? ( + + ) : ( + + )} {translate( 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewCli', 'Resume in New CLI' 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 d31800c40fa..292f405fddb 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 @@ -1,6 +1,5 @@ import { notifyAiVaultSessionPreparationFailure, - notifyAiVaultSessionQueued, resolveAiVaultSessionLaunchTargetOrNotify } from './ai-vault-session-launch-feedback' import { useCallback, useState } from 'react' @@ -16,7 +15,7 @@ import { buildAiVaultCopiedSessionStartupForWorktree, buildAiVaultForkStartupForWorktree } from '@/lib/ai-vault-session-fork-startup' -import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session' +import { launchAiVaultSessionAndReveal } from '@/lib/launch-ai-vault-session-and-reveal' import { useAppStore } from '@/store' import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types' import { @@ -27,7 +26,6 @@ import { } from '@/lib/ai-vault-session-resume-preparation' import type { Worktree } from '../../../../shared/worktree/types' import { translate } from '@/i18n/i18n' -import { agentLabel } from './ai-vault-session-filters' import { describeAiVaultCliForkFailure } from './ai-vault-session-cli-fork' import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume' import { prepareAiVaultSessionContinuation } from './ai-vault-session-continuation' @@ -36,10 +34,7 @@ import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-struct 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 -} from './ai-vault-session-resume-in-chat-launch' +import { resumeAiVaultSessionInNewChat } from './ai-vault-session-resume-in-chat-launch' import { resolveAiVaultTargetWorkspacePath } from './ai-vault-session-launch-target' export function useAiVaultSessionLaunchActions({ @@ -117,37 +112,12 @@ export function useAiVaultSessionLaunchActions({ return } void prepareStartup(targetId.worktreeId) - .then((startup) => { - const launchResult = launchAiVaultSessionInNewTab({ - agent: session.agent, - worktreeId: targetId.worktreeId, - ...startup - }) - if (launchResult.tabId === null) { - void launchResult.runtimeLaunch.then((outcome) => { - if (outcome.status === 'failed') { - toast.error( - describeFailure(outcome.message) || - translate( - 'auto.lib.launch.agent.in.new.tab.11cce5cc77', - 'Could not launch {{value0}} in a new terminal.', - { value0: agentLabel(session.agent) } - ) - ) - return - } - if (useAppStore.getState().activeWorktreeId !== targetId.worktreeId) { - activateAiVaultResumeWorkspace(targetId.worktreeId) - } - notifyAiVaultSessionQueued(session.agent) - }) - return - } - if (useAppStore.getState().activeWorktreeId !== targetId.worktreeId) { - activateAiVaultResumeWorkspace(targetId.worktreeId) - } - notifyAiVaultSessionQueued(session.agent) - }) + .then((startup) => + launchAiVaultSessionAndReveal( + { agent: session.agent, worktreeId: targetId.worktreeId, ...startup }, + describeFailure + ) + ) .catch((error: unknown) => notifyAiVaultSessionPreparationFailure(error, session.agent, describeFailure) ) diff --git a/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx b/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx new file mode 100644 index 00000000000..9af01605541 --- /dev/null +++ b/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react' +import { DropdownMenuSeparator } from '@/components/ui/dropdown-menu' +import { useAppStore } from '@/store' +import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types' +import { AiVaultSessionSurfaceSwitchMenuItems } from '../right-sidebar/AiVaultSessionSurfaceSwitchMenuItems' +import { + resolveTabSessionHistorySubject, + type NativeChatTabSession +} from './tab-session-history-switch' +import { runNativeChatCliFork, useNativeChatCliForkPending } from './native-chat-cli-fork-run' + +export function NativeChatCliForkMenuItems({ + tab, + nativeChat, + leadingSeparator, + onComplete +}: { + tab: { id: string; worktreeId: string } + nativeChat: NativeChatTabSession + leadingSeparator: boolean + onComplete?: () => void +}): React.JSX.Element | null { + const [subject] = useState(() => + resolveTabSessionHistorySubject(useAppStore.getState(), { tab, nativeChat }) + ) + const chat = subject?.kind === 'chat' ? subject : null + const pending = useNativeChatCliForkPending(chat) + const commandOverrides = useAppStore((state) => state.settings?.agentCmdOverrides) + const agent = AI_VAULT_AGENTS.find((candidate) => candidate === nativeChat.agent) + if (!chat || !agent) { + return null + } + return ( + <> + {leadingSeparator ? : null} + { + event.preventDefault() + void runNativeChatCliFork({ subject: chat, tab, agent, commandOverrides, onComplete }) + }} + /> + + ) +} diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 1c5a9df5e4c..0e78ff5c00f 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' import { X, Minimize2, Pin } from 'lucide-react' import { stripLeadingAgentTitleDecoration } from '../../../../shared/agent-title-decoration' @@ -139,7 +139,13 @@ export default function SortableTab({ }) // Why: no transform/transition/opacity so tabs stay anchored during drag, only the insertion bar moves (see TabBar.tsx). - const [menuOpen, setMenuOpen] = useState(false) + const [menuOpening, setMenuOpening] = useState(null) + const menuGeneration = useRef(0) + const setMenuOpen = useCallback((open: boolean) => { + // Every gesture retires the old opening, even when React batches close and reopen. + const generation = ++menuGeneration.current + setMenuOpening(open ? generation : null) + }, []) const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 }) const { isEditing, @@ -163,17 +169,17 @@ export default function SortableTab({ const closeMenu = (): void => setMenuOpen(false) window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu) return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu) - }, []) + }, [setMenuOpen]) // Why: webview clicks miss the renderer pointerdown Radix uses for outside-click, so dismiss on window blur instead. useEffect(() => { - if (!menuOpen) { + if (menuOpening === null) { return } const dismiss = (): void => setMenuOpen(false) window.addEventListener('blur', dismiss) return () => window.removeEventListener('blur', dismiss) - }, [menuOpen]) + }, [menuOpening, setMenuOpen]) // Why: while editing, drop drag listeners so typing can't start a drag; attributes stay spread to keep dnd-kit a11y. const dragListeners = isEditing ? undefined : listeners @@ -368,7 +374,7 @@ export default function SortableTab({ setMenuOpen(true) }} > - {isEditing || menuOpen ? ( + {isEditing || menuOpening !== null ? ( tabRoot ) : ( { + if (menuGeneration.current === menuOpening) { + setMenuOpen(false) + } + }} tab={tab} unifiedTabId={unifiedTabId} groupId={groupId} isActive={isActive} - open={menuOpen} + open={menuOpening !== null} point={menuPoint} tabCount={tabCount} hasTabsToRight={hasTabsToRight} diff --git a/src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx index 779f177a317..8046c5db968 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx @@ -12,7 +12,7 @@ */ import type { ReactElement, ReactNode } from 'react' import { cloneElement, isValidElement, StrictMode, useEffect, useState } from 'react' -import { act, render, cleanup } from '@testing-library/react' +import { act, render, cleanup, fireEvent } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { TabDragItemData } from '../tab-group/useTabDragSplit' @@ -106,6 +106,22 @@ vi.mock('./TerminalTabLeadingIcon', () => ({ } })) +const menuCompletions: (() => void)[] = [] +vi.mock('./SortableTabContextMenu', () => ({ + SortableTabContextMenu: ({ + open, + onSessionSwitchComplete + }: { + open: boolean + onSessionSwitchComplete: () => void + }) => { + if (open) { + menuCompletions.push(onSessionSwitchComplete) + } + return open ?
: null + } +})) + vi.mock('./shell-icons', () => ({ ShellIcon: () => })) vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => , @@ -247,3 +263,22 @@ describe('SortableTab update-depth probe', () => { expect(container.querySelector('[data-tab-rename-input]')).not.toBeNull() }) }) + +it('retires completion ownership for repeated gestures and batched blur/reopen', () => { + const { container } = render() + const slot = container.querySelector('[data-tab-strip-slot]')! + act(() => fireEvent.contextMenu(slot)) + const first = menuCompletions.at(-1)! + act(() => fireEvent.contextMenu(slot)) + act(first) + expect(container.querySelector('[data-testid="open-menu"]')).not.toBeNull() + const second = menuCompletions.at(-1)! + act(() => { + window.dispatchEvent(new Event('blur')) + fireEvent.contextMenu(slot) + }) + act(second) + expect(container.querySelector('[data-testid="open-menu"]')).not.toBeNull() + act(menuCompletions.at(-1)!) + expect(container.querySelector('[data-testid="open-menu"]')).toBeNull() +}) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index d77345a17c6..5bae295d96a 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -101,6 +101,8 @@ type SortableTabContextMenuProps = { hasTabsToLeft: boolean isPinned: boolean onOpenChange: (open: boolean) => void + onSessionSwitchComplete?: () => void + sessionSwitchOpening?: number | null onActivate: (tabId: string) => void onClose: (tabId: string) => void onCloseOthers: (tabId: string) => void @@ -135,6 +137,8 @@ export function SortableTabContextMenu({ hasTabsToLeft, isPinned, onOpenChange, + onSessionSwitchComplete, + sessionSwitchOpening, onActivate, onClose, onCloseOthers, @@ -193,6 +197,8 @@ export function SortableTabContextMenu({ ) : null} { } = { subject: null, move: null, launchActionArgs: [] } return { ...state, + pending: false, + pendingListeners: new Set<() => void>(), handleResumeInNewChat: vi.fn(), handleResumeInNewCli: vi.fn(), resolveSwitch: vi.fn(), + runNativeChatCliFork: vi.fn(), listSessions: vi.fn(), cancelListSessions: vi.fn(async () => {}) } }) +vi.mock('./native-chat-cli-fork-run', () => ({ + runNativeChatCliFork: mocks.runNativeChatCliFork, + useNativeChatCliForkPending: () => + useSyncExternalStore( + (listener) => { + mocks.pendingListeners.add(listener) + return () => mocks.pendingListeners.delete(listener) + }, + () => mocks.pending + ) +})) + vi.mock('../../store', () => ({ useAppStore: Object.assign( (selector: (state: Record) => unknown) => selector({ settings: null }), @@ -103,6 +119,8 @@ function listResult(sessions: AiVaultSession[]) { beforeEach(() => { resetAiVaultForcedRescanThrottleForTest() mocks.subject = null + mocks.pending = false + mocks.runNativeChatCliFork.mockReset() mocks.move = null mocks.launchActionArgs = [] mocks.handleResumeInNewChat.mockReset() @@ -159,26 +177,19 @@ describe('TabSessionSurfaceSwitchMenuItems', () => { expect(screen.queryByRole('separator')).toBeNull() }) - it('hides the move when the Session History gate withholds it', async () => { + it('offers the native chat action immediately while History never answers', async () => { mocks.subject = CHAT_SUBJECT - await renderItems('orca-chat-1') - expect(mocks.listSessions).toHaveBeenCalled() - expect(screen.queryByRole('menuitem')).toBeNull() - }) - - it('runs the Session History "Resume in New CLI" handler on the row, in the tab workspace', async () => { - mocks.subject = CHAT_SUBJECT - mocks.move = { action: 'resume-in-new-cli', worktreeId: 'wt-1' } - await renderItems('orca-chat-1') - - expect(screen.getByRole('separator')).toBeTruthy() - expect(screen.queryByText('Resume in New Native Chat')).toBeNull() + mocks.listSessions.mockReturnValue(new Promise(() => {})) + renderItemsNow('orca-chat-1') + expect(mocks.listSessions).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('menuitem', { name: 'Resume in New CLI' })) - expect(mocks.handleResumeInNewCli).toHaveBeenCalledWith(CHAT_ROW, 'wt-1') - expect(mocks.launchActionArgs.at(-1)).toMatchObject({ - activeWorktree: null, - activeWorktreeId: 'wt-1' - }) + expect(mocks.runNativeChatCliFork).toHaveBeenCalledWith( + expect.objectContaining({ + subject: CHAT_SUBJECT, + tab: { id: 'tab-1', worktreeId: 'wt-1' }, + agent: 'claude' + }) + ) }) it('runs the Session History "Resume in New Native Chat" handler for a CLI tab', async () => { @@ -218,13 +229,13 @@ describe('TabSessionSurfaceSwitchMenuItems', () => { }) it('offers the move once the fresh answer has it', async () => { - mocks.subject = CHAT_SUBJECT - mocks.move = { action: 'resume-in-new-cli', worktreeId: 'wt-1' } - renderItemsNow('orca-chat-1') + mocks.subject = CLI_SUBJECT + mocks.move = { action: 'resume-in-new-chat', worktreeId: 'wt-1' } + renderItemsNow() expect(screen.queryByRole('menuitem')).toBeNull() await act(async () => {}) - expect(screen.getByRole('menuitem', { name: 'Resume in New CLI' })).toBeTruthy() + expect(screen.getByRole('menuitem', { name: 'Resume in New Native Chat' })).toBeTruthy() }) it('ignores a lookup that settles after the menu closed', async () => { @@ -234,9 +245,9 @@ describe('TabSessionSurfaceSwitchMenuItems', () => { settle = resolve }) ) - mocks.subject = CHAT_SUBJECT - mocks.move = { action: 'resume-in-new-cli', worktreeId: 'wt-1' } - await renderItems('orca-chat-1') + mocks.subject = CLI_SUBJECT + mocks.move = { action: 'resume-in-new-chat', worktreeId: 'wt-1' } + await renderItems() cleanup() await act(async () => settle(listResult([CHAT_ROW]))) @@ -245,20 +256,34 @@ describe('TabSessionSurfaceSwitchMenuItems', () => { it('shows nothing when the lookup fails', async () => { mocks.listSessions.mockRejectedValue(new Error('scanner crashed')) - mocks.subject = CHAT_SUBJECT - mocks.move = { action: 'resume-in-new-cli', worktreeId: 'wt-1' } - await renderItems('orca-chat-1') + mocks.subject = CLI_SUBJECT + mocks.move = { action: 'resume-in-new-chat', worktreeId: 'wt-1' } + await renderItems() expect(screen.queryByRole('menuitem')).toBeNull() }) it('cancels its lookup when the menu closes', async () => { - mocks.subject = CHAT_SUBJECT + mocks.subject = CLI_SUBJECT mocks.listSessions.mockReturnValue(new Promise(() => {})) - await renderItems('orca-chat-1') + await renderItems() cleanup() expect(mocks.cancelListSessions).toHaveBeenCalledWith({ requestToken: expect.any(String) }) }) }) + +it('keeps an accepted native action visible with shared disabled spinner feedback', async () => { + mocks.subject = CHAT_SUBJECT + mocks.runNativeChatCliFork.mockImplementation(() => { + mocks.pending = true + mocks.pendingListeners.forEach((listener) => listener()) + }) + await renderItems('orca-chat-1') + fireEvent.click(screen.getByRole('menuitem', { name: 'Resume in New CLI' })) + const item = screen.getByRole('menuitem', { name: 'Resume in New CLI' }) + expect(item.getAttribute('aria-busy')).toBe('true') + expect(item.getAttribute('data-disabled')).not.toBeNull() + expect(item.querySelector('.animate-spin')).not.toBeNull() +}) diff --git a/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.tsx b/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.tsx index 5daf01e0399..fb8ec65af50 100644 --- a/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.tsx +++ b/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.tsx @@ -1,3 +1,4 @@ +import { NativeChatCliForkMenuItems } from './NativeChatCliForkMenuItems' import { useEffect, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { DropdownMenuSeparator } from '@/components/ui/dropdown-menu' @@ -69,7 +70,7 @@ function useTabSessionSwitch( /** The Session History row's "Resume in New Native Chat" / "Resume in New CLI", offered on the tab * that shows that session and hidden wherever the row would not offer it. */ -export function TabSessionSurfaceSwitchMenuItems({ +function HistoryTabSessionSurfaceSwitchMenuItems({ tab, nativeChat, leadingSeparator @@ -119,3 +120,16 @@ export function TabSessionSurfaceSwitchMenuItems({ ) } + +export function TabSessionSurfaceSwitchMenuItems(props: { + onComplete?: () => void + tab: Pick + nativeChat?: NativeChatTabSession + leadingSeparator: boolean +}): React.JSX.Element | null { + return props.nativeChat ? ( + + ) : ( + + ) +} diff --git a/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.test.ts b/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.test.ts new file mode 100644 index 00000000000..48affbb294b --- /dev/null +++ b/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { runNativeChatCliFork } from './native-chat-cli-fork-run' +import type { + NativeChatCliForkPreparation, + NativeChatCliForkSubject +} from '@/lib/native-chat-cli-fork-preparation' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' + +const mocks = vi.hoisted(() => ({ + prepare: vi.fn(), + launch: vi.fn(), + notify: vi.fn(), + activate: vi.fn(), + activeWorktreeId: 'workspace-1', + error: vi.fn(), + open: true, + host: 'runtime:peer', + listeners: new Set<() => void>() +})) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + activeWorktreeId: mocks.activeWorktreeId, + unifiedTabsByWorktree: { + 'workspace-1': mocks.open + ? [{ id: 'tab-1', contentType: 'agent-session', entityId: 'chat-1' }] + : [] + } + }), + subscribe: (listener: () => void) => { + mocks.listeners.add(listener) + return () => mocks.listeners.delete(listener) + } + } +})) +vi.mock('./tab-session-history-switch', () => ({ + resolveTabSessionHistorySubject: () => ({ + kind: 'chat', + request: { executionHostScope: mocks.host } + }) +})) +vi.mock('@/lib/native-chat-cli-fork-preparation', () => ({ + prepareNativeChatCliFork: mocks.prepare +})) +vi.mock('@/lib/ai-vault-session-fork-startup', () => ({ + assertAiVaultCopiedSessionLaunchAllowed: () => {}, + buildAiVaultForkStartupForWorktree: () => ({ command: 'claude --resume source --fork-session' }), + buildAiVaultCopiedSessionStartupForWorktree: () => ({ command: 'omp --resume copy' }) +})) +vi.mock('../right-sidebar/ai-vault-session-resume-in-chat-launch', () => ({ + activateAiVaultResumeWorkspace: mocks.activate +})) +vi.mock('sonner', () => ({ toast: { error: mocks.error } })) +vi.mock('@/lib/launch-ai-vault-session', () => ({ launchAiVaultSessionInNewTab: mocks.launch })) +vi.mock('../right-sidebar/ai-vault-session-launch-feedback', () => ({ + notifyAiVaultSessionQueued: mocks.notify, + notifyAiVaultSessionPreparationFailure: mocks.error +})) + +const subject: NativeChatCliForkSubject = { + kind: 'chat', + sessionId: 'chat-1', + workspaceId: 'workspace-1', + request: { executionHostScope: 'runtime:peer', scopePaths: ['/host/repo'], sessionLimit: 250 } +} +const args = { subject, tab: { id: 'tab-1', worktreeId: 'workspace-1' }, agent: 'claude' } as const +const prepared: NativeChatCliForkPreparation = { + session: { agent: 'claude', sessionId: 'source', cwd: '/host/repo', codexHome: null }, + copied: false +} +beforeEach(() => { + mocks.open = true + mocks.activeWorktreeId = 'workspace-1' + mocks.activate.mockReset() + mocks.prepare.mockReset() + mocks.launch.mockReset().mockReturnValue({ tabId: 'destination' }) + mocks.notify.mockReset() + mocks.error.mockReset() + replaceRuntimeEnvironmentRevisions([{ id: 'peer', createdAt: 1, pairingRevision: 7 }]) +}) +function closeSource() { + mocks.open = false + mocks.listeners.forEach((listener) => listener()) +} + +it('shares pending across menu openings and suppresses dispatch after source retirement', async () => { + let settle = (_value: NativeChatCliForkPreparation) => {} + mocks.prepare.mockReturnValue( + new Promise((resolve) => { + settle = resolve + }) + ) + const first = runNativeChatCliFork(args) + await runNativeChatCliFork(args) + expect(mocks.prepare).toHaveBeenCalledTimes(1) + closeSource() + settle(prepared) + await first + expect(mocks.launch).not.toHaveBeenCalled() + expect(mocks.error).not.toHaveBeenCalled() +}) + +it('retires preparation when the initiating pairing changes', async () => { + let settle = (_value: NativeChatCliForkPreparation) => {} + mocks.prepare.mockReturnValue( + new Promise((resolve) => { + settle = resolve + }) + ) + const running = runNativeChatCliFork(args) + replaceRuntimeEnvironmentRevisions([{ id: 'peer', createdAt: 1, pairingRevision: 8 }]) + settle(prepared) + await running + expect(mocks.launch).not.toHaveBeenCalled() + expect(mocks.error).not.toHaveBeenCalled() +}) + +it('lets the destination finish after dispatch and passes no source resume identity', async () => { + mocks.prepare.mockResolvedValue(prepared) + mocks.launch.mockImplementation(() => { + closeSource() + return { tabId: null, runtimeLaunch: Promise.resolve({ status: 'created' }) } + }) + await runNativeChatCliFork(args) + expect(mocks.launch).toHaveBeenCalledWith({ + agent: 'claude', + worktreeId: 'workspace-1', + command: 'claude --resume source --fork-session' + }) + expect(mocks.notify).toHaveBeenCalledWith('claude') + expect(mocks.error).not.toHaveBeenCalled() +}) + +it('reveals the successful source workspace after another workspace was selected', async () => { + mocks.prepare.mockImplementation(async () => { + mocks.activeWorktreeId = 'workspace-2' + return prepared + }) + await runNativeChatCliFork(args) + expect(mocks.activate).toHaveBeenCalledExactlyOnceWith('workspace-1') + mocks.activate.mockClear() + mocks.notify.mockClear() + mocks.launch.mockReturnValue({ + tabId: null, + runtimeLaunch: Promise.resolve({ status: 'failed', message: 'refused' }) + }) + await runNativeChatCliFork(args) + expect(mocks.activate).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + expect(mocks.error).toHaveBeenCalledTimes(1) +}) diff --git a/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.ts b/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.ts new file mode 100644 index 00000000000..694f5488715 --- /dev/null +++ b/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.ts @@ -0,0 +1,156 @@ +import { useSyncExternalStore } from 'react' +import { useAppStore } from '@/store' +import { readIpcErrorMessage } from '@/lib/ipc-error' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { + prepareNativeChatCliFork, + type NativeChatCliForkSubject +} from '@/lib/native-chat-cli-fork-preparation' +import { + assertAiVaultCopiedSessionLaunchAllowed, + buildAiVaultCopiedSessionStartupForWorktree, + buildAiVaultForkStartupForWorktree +} from '@/lib/ai-vault-session-fork-startup' +import { launchAiVaultSessionAndReveal } from '@/lib/launch-ai-vault-session-and-reveal' +import { notifyAiVaultSessionPreparationFailure } from '../right-sidebar/ai-vault-session-launch-feedback' +import { describeAiVaultCliForkFailure } from '../right-sidebar/ai-vault-session-cli-fork' +import { + captureWebSessionIntentOwner, + matchesWebSessionIntentOwner +} from '@/runtime/web-runtime-session-environment' +import { + onRuntimeEnvironmentsRetired, + onRuntimeEnvironmentRevisionsChanged +} from '@/runtime/runtime-environment-revision' +import type { AiVaultAgent } from '../../../../shared/ai-vault-types' +import { resolveTabSessionHistorySubject } from './tab-session-history-switch' + +const pending = new Map() +const listeners = new Set<() => void>() +const changed = () => listeners.forEach((listener) => listener()) +const keyFor = (subject: NativeChatCliForkSubject) => + `${subject.request.executionHostScope}\0${subject.sessionId}` +const subscribe = (listener: () => void) => { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function useNativeChatCliForkPending(subject: NativeChatCliForkSubject | null): boolean { + return useSyncExternalStore( + subscribe, + () => !!subject && pending.has(keyFor(subject)), + () => false + ) +} + +export async function runNativeChatCliFork(args: { + subject: NativeChatCliForkSubject + tab: { id: string; worktreeId: string } + agent: AiVaultAgent + onComplete?: () => void + commandOverrides?: Partial> +}): Promise { + const { subject, tab } = args + const key = keyFor(subject) + if (pending.has(key)) { + return + } + const host = parseExecutionHostId(subject.request.executionHostScope) + const owner = host?.kind === 'runtime' ? captureWebSessionIntentOwner(host.environmentId) : null + const controller = new AbortController() + const isCurrent = () => { + if (controller.signal.aborted || (owner && !matchesWebSessionIntentOwner(owner))) { + return false + } + const state = useAppStore.getState() + const source = state.unifiedTabsByWorktree[tab.worktreeId]?.find((item) => item.id === tab.id) + const current = + source?.contentType === 'agent-session' && source.entityId === subject.sessionId + ? resolveTabSessionHistorySubject(state, { + tab, + nativeChat: { sessionId: subject.sessionId, agent: args.agent } + }) + : null + return ( + current?.kind === 'chat' && + current.request.executionHostScope === subject.request.executionHostScope + ) + } + if (!isCurrent()) { + return + } + const release = () => { + if (pending.get(key) === controller) { + pending.delete(key) + changed() + } + } + const abandon = () => { + controller.abort() + release() + } + pending.set(key, controller) + changed() + const stopSource = useAppStore.subscribe(() => { + if (!isCurrent()) { + abandon() + } + }) + const retire = (ids: readonly string[]) => { + if (owner && ids.includes(owner.environmentId)) { + abandon() + } + } + const stopRetirement = onRuntimeEnvironmentsRetired(retire) + const stopRevision = onRuntimeEnvironmentRevisionsChanged(retire) + const stop = () => { + stopSource() + stopRetirement() + stopRevision() + } + try { + const prepared = await prepareNativeChatCliFork(subject, { + pairingRevision: owner?.pairingRevision, + isCurrent, + signal: controller.signal + }) + if (!isCurrent()) { + return + } + const target = prepared.session + const startupArgs = { + state: useAppStore.getState(), + worktreeId: subject.workspaceId, + session: target, + commandOverride: args.commandOverrides?.[target.agent] + } + assertAiVaultCopiedSessionLaunchAllowed(startupArgs) + const startup = prepared.copied + ? buildAiVaultCopiedSessionStartupForWorktree(startupArgs, prepared.sourceAgent ?? args.agent) + : buildAiVaultForkStartupForWorktree(startupArgs) + if (!startup) { + throw new Error('This session cannot be opened in the CLI.') + } + // The destination takes over synchronously; closing the source afterward must not cancel it. + if (!isCurrent()) { + return + } + stop() + await launchAiVaultSessionAndReveal( + { agent: target.agent, worktreeId: subject.workspaceId, ...startup }, + describeAiVaultCliForkFailure + ) + } catch (error) { + if (pending.get(key) === controller) { + notifyAiVaultSessionPreparationFailure( + new Error(readIpcErrorMessage(error)), + args.agent, + describeAiVaultCliForkFailure + ) + } + } finally { + stop() + release() + args.onComplete?.() + } +} 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 794718ef4d0..a5054ace413 100644 --- a/src/renderer/src/lib/ai-vault-session-resume-preparation.ts +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.ts @@ -1,6 +1,9 @@ -import type { AiVaultSession } from '../../../shared/ai-vault-types' +import { AiVaultChatForkSourceSchema } from '../../../shared/ai-vault-chat-fork-contract' +import type { AiVaultAgent, AiVaultSession } from '../../../shared/ai-vault-types' import type { AiVaultPrepareSessionResumeArgs, + AiVaultPrepareChatForkArgs, + AiVaultPrepareSessionResumeRequest, AiVaultPrepareSessionResumeResult } from '../../../shared/ai-vault-resume-preparation' import { @@ -86,13 +89,42 @@ function isMissingPairedResumePreparation(error: unknown): boolean { * makes the copy (`agentForksThroughChat`) has the chat's host make it here; the result is that * copy, which a plain resume opens and no chat owns (`copied: true`). */ +type ForkPreparationRequest = ( + args: AiVaultPrepareSessionResumeRequest +) => Promise + +export function prepareAiVaultSessionForFork( + session: AiVaultSession, + request?: ForkPreparationRequest +): Promise<{ session: AiVaultSession; copied: boolean }> +export function prepareAiVaultSessionForFork( + session: AiVaultPrepareChatForkArgs, + request?: ForkPreparationRequest +): Promise<{ session: AiVaultResumeCommandSession; copied: boolean; sourceAgent?: AiVaultAgent }> export async function prepareAiVaultSessionForFork( - session: AiVaultSession -): Promise<{ session: AiVaultSession; copied: boolean }> { - if (!agentForksThroughChat(session.agent)) { - return { session: await prepareAiVaultSessionHome(session, true), copied: false } + session: AiVaultSession | AiVaultPrepareChatForkArgs, + request: ForkPreparationRequest = (args) => window.api.aiVault.prepareSessionResume(args) +): Promise<{ session: AiVaultResumeCommandSession; copied: boolean; sourceAgent?: AiVaultAgent }> { + if ('sourceChat' in session) { + const result = await request(session) + const source = AiVaultChatForkSourceSchema.parse(result.cliForkSource) + return { + session: { + ...source, + executionHostId: + parseExecutionHostId(session.executionHostId)?.id ?? LOCAL_EXECUTION_HOST_ID + }, + sourceAgent: source.sourceAgent, + copied: result.forkedSession !== undefined + } } - const result = await window.api.aiVault.prepareSessionResume({ + if (!agentForksThroughChat(session.agent)) { + return { + session: await prepareAiVaultSessionHome(session, true, false, request), + copied: false + } + } + const result = await request({ agent: session.agent, sessionId: session.sessionId, filePath: session.filePath, @@ -123,7 +155,8 @@ export function aiVaultCliForkHostTooOldMessage(): string { async function prepareAiVaultSessionHome( session: AiVaultSession, fork: boolean, - terminalResume = false + terminalResume = false, + request: ForkPreparationRequest = (args) => window.api.aiVault.prepareSessionResume(args) ): Promise { if (fork && !aiVaultSessionNeedsResumePreparation(session)) { return session @@ -136,9 +169,7 @@ async function prepareAiVaultSessionHome( executionHostId: session.executionHostId, ...(fork ? { fork: true } : {}) } - const result = await (terminalResume - ? prepareAiVaultTerminalResumeRequest(args) - : prepareAiVaultSessionResumeRequest(args)) + const result = await (terminalResume ? prepareAiVaultTerminalResumeRequest(args) : request(args)) if (result.useRealCodexHome) { return { ...session, codexHome: null } } diff --git a/src/renderer/src/lib/launch-ai-vault-session-and-reveal.ts b/src/renderer/src/lib/launch-ai-vault-session-and-reveal.ts new file mode 100644 index 00000000000..306487c4842 --- /dev/null +++ b/src/renderer/src/lib/launch-ai-vault-session-and-reveal.ts @@ -0,0 +1,32 @@ +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { launchAiVaultSessionInNewTab } from './launch-ai-vault-session' +import { activateAiVaultResumeWorkspace } from '@/components/right-sidebar/ai-vault-session-resume-in-chat-launch' +import { notifyAiVaultSessionQueued } from '@/components/right-sidebar/ai-vault-session-launch-feedback' +import { agentLabel } from '@/components/right-sidebar/ai-vault-session-filters' + +export async function launchAiVaultSessionAndReveal( + args: Parameters[0], + describeFailure: (message: string) => string = (message) => message +): Promise { + const launch = launchAiVaultSessionInNewTab(args) + if (launch.tabId === null) { + const outcome = await launch.runtimeLaunch + if (outcome.status === 'failed') { + toast.error( + describeFailure(outcome.message) || + translate( + 'auto.lib.launch.agent.in.new.tab.11cce5cc77', + 'Could not launch {{value0}} in a new terminal.', + { value0: agentLabel(args.agent) } + ) + ) + return + } + } + if (useAppStore.getState().activeWorktreeId !== args.worktreeId) { + activateAiVaultResumeWorkspace(args.worktreeId) + } + notifyAiVaultSessionQueued(args.agent) +} diff --git a/src/renderer/src/lib/native-chat-cli-fork-preparation.test.ts b/src/renderer/src/lib/native-chat-cli-fork-preparation.test.ts new file mode 100644 index 00000000000..3fdc1c44812 --- /dev/null +++ b/src/renderer/src/lib/native-chat-cli-fork-preparation.test.ts @@ -0,0 +1,138 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { + prepareNativeChatCliFork, + type NativeChatCliForkSubject +} from './native-chat-cli-fork-preparation' +import { AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY } from '../../../shared/ai-vault-resume-preparation' + +const { call } = vi.hoisted(() => ({ call: vi.fn() })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: call })) +afterEach(() => { + call.mockReset() + vi.useRealTimers() + vi.unstubAllGlobals() +}) +const subject: NativeChatCliForkSubject = { + kind: 'chat', + sessionId: 'chat-1', + workspaceId: 'workspace-1', + request: { executionHostScope: 'runtime:peer', scopePaths: ['/host/repo'], sessionLimit: 250 } +} +const source = { + agent: 'claude', + sourceAgent: 'claude', + sessionId: 'provider-1', + filePath: '/host/saved.jsonl', + cwd: '/host/repo', + codexHome: null, + executionHostPlatform: 'linux' +} +const options = { pairingRevision: 7, isCurrent: () => true, signal: new AbortController().signal } +const delayed = (value: T, ms: number) => + new Promise((resolve) => setTimeout(() => resolve(value), ms)) + +it('negotiates then prepares on the same paired host without requesting History', async () => { + call + .mockResolvedValueOnce({ capabilities: [AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY] }) + .mockResolvedValueOnce({ useRealCodexHome: false, cliForkSource: source }) + await expect(prepareNativeChatCliFork(subject, options)).resolves.toMatchObject({ + session: { sessionId: 'provider-1', executionHostId: 'runtime:peer' }, + copied: false + }) + expect(call.mock.calls.map((args) => args[1])).toEqual([ + 'status.get', + 'aiVault.prepareSessionResume' + ]) + expect(call.mock.calls[1]).toEqual([ + { kind: 'environment', environmentId: 'peer' }, + 'aiVault.prepareSessionResume', + { + sourceChat: { sessionId: 'chat-1', workspaceId: 'workspace-1' }, + executionHostId: 'runtime:peer', + fork: true + }, + expect.objectContaining({ expectedEnvironmentPairingRevision: 7 }) + ]) +}) + +it('does not retry History after a capable host refuses or returns a malformed source', async () => { + for (const result of [ + () => Promise.reject(new Error('Cannot read saved conversation')), + () => Promise.resolve({ useRealCodexHome: false }) + ]) { + call + .mockResolvedValueOnce({ capabilities: [AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY] }) + .mockImplementationOnce(result) + await expect(prepareNativeChatCliFork(subject, options)).rejects.toThrow() + } + expect(call.mock.calls.some((args) => args[1] === 'aiVault.listSessions')).toBe(false) +}) + +it('asks an old peer for one bounded same-host list and does not scan twice when absent', async () => { + call + .mockResolvedValueOnce({ capabilities: [] }) + .mockResolvedValueOnce({ sessions: [], issues: [], scannedAt: 'now' }) + await expect(prepareNativeChatCliFork(subject, options)).rejects.toThrow('no saved conversation') + expect(call.mock.calls.map((args) => args[1])).toEqual(['status.get', 'aiVault.listSessions']) + expect(call.mock.calls[1]?.[2]).toMatchObject({ + executionHostId: 'runtime:peer', + force: undefined + }) + expect(call.mock.calls[1]?.[3]).toMatchObject({ + expectedEnvironmentPairingRevision: 7, + timeoutMs: 150_000 + }) +}) + +it('does not prepare when the source retires during capability admission', async () => { + let current = true + call.mockImplementationOnce(async () => { + current = false + return { capabilities: [AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY] } + }) + await expect( + prepareNativeChatCliFork(subject, { ...options, isCurrent: () => current }) + ).rejects.toThrow('source chat changed') + expect(call).toHaveBeenCalledTimes(1) +}) + +it.each([true, false])( + 'preserves delayed modern=%s preparation within its phase budgets', + async (modern) => { + vi.useFakeTimers() + call.mockResolvedValueOnce({ + capabilities: modern ? [AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY] : [] + }) + if (!modern) { + const row = { + ...source, + agent: 'codex', + codexHome: '/host/codex-runtime-home/home', + executionHostId: 'runtime:peer', + messageCount: 1, + queuedMessageCount: 0, + structuredSession: { sessionId: 'chat-1', workspaceId: 'workspace-1' } + } + call.mockImplementationOnce(() => delayed({ sessions: [row] }, 45_000)) + } + call.mockImplementationOnce(() => + delayed({ useRealCodexHome: false, cliForkSource: source }, 40_000) + ) + const result = prepareNativeChatCliFork(subject, options) + await vi.advanceTimersByTimeAsync(modern ? 40_000 : 85_000) + await expect(result).resolves.toMatchObject({ session: { sessionId: 'provider-1' } }) + expect(vi.getTimerCount()).toBe(0) + } +) + +it('bounds legacy inventory observation at 150 seconds and revokes its queued signal', async () => { + vi.useFakeTimers() + call + .mockResolvedValueOnce({ capabilities: [] }) + .mockImplementationOnce(() => new Promise(() => {})) + const failed = expect(prepareNativeChatCliFork(subject, options)).rejects.toThrow('timed out') + await vi.advanceTimersByTimeAsync(150_000) + await failed + expect(call.mock.calls[1]?.[3].signal.aborted).toBe(true) + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/src/renderer/src/lib/native-chat-cli-fork-preparation.ts b/src/renderer/src/lib/native-chat-cli-fork-preparation.ts new file mode 100644 index 00000000000..6e21d95c22f --- /dev/null +++ b/src/renderer/src/lib/native-chat-cli-fork-preparation.ts @@ -0,0 +1,115 @@ +import { createBrowserUuid } from './browser-uuid' +import type { AiVaultAgent, AiVaultListResult } from '../../../shared/ai-vault-types' +import { isAiVaultSessionResumableContent } from '../../../shared/ai-vault-types' +import { + AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY, + type AiVaultPrepareSessionResumeRequest, + type AiVaultPrepareSessionResumeResult +} from '../../../shared/ai-vault-resume-preparation' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import { parseExecutionHostId } from '../../../shared/execution-host' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { prepareAiVaultSessionForFork } from './ai-vault-session-resume-preparation' +import { aiVaultSessionListArgs } from '@/components/right-sidebar/ai-vault-session-list-request' +import { + findTabSessionHistoryRow, + type TabSessionHistorySubject +} from '@/components/tab-bar/tab-session-history-switch' +import type { AiVaultResumeCommandSession } from './ai-vault-resume-command' +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' + +export type NativeChatCliForkSubject = Extract +export type NativeChatCliForkPreparation = { + session: AiVaultResumeCommandSession + copied: boolean + sourceAgent?: AiVaultAgent +} + +export async function prepareNativeChatCliFork( + subject: NativeChatCliForkSubject, + options: { pairingRevision?: number; isCurrent: () => boolean; signal: AbortSignal } +): Promise { + const hostId = subject.request.executionHostScope + const host = parseExecutionHostId(hostId) + if (!host || (host.kind !== 'local' && host.kind !== 'runtime')) { + throw new Error('The conversation host is unavailable.') + } + const target = + host.kind === 'runtime' + ? { kind: 'environment' as const, environmentId: host.environmentId } + : { kind: 'local' as const } + const observe = async (timeoutMs: number, run: (signal: AbortSignal) => Promise) => { + const deadline = new AbortController() + const timer = setTimeout( + () => deadline.abort(new Error('Conversation preparation timed out.')), + timeoutMs + ) + const signal = AbortSignal.any([options.signal, deadline.signal]) + try { + signal.throwIfAborted() + if (!options.isCurrent()) { + throw new Error('The source chat changed before preparation.') + } + return await waitForPromiseWithSignal(run(signal), signal) + } finally { + clearTimeout(timer) + } + } + const callOptions = (timeoutMs: number, signal: AbortSignal) => ({ + timeoutMs, + signal, + expectedEnvironmentPairingRevision: options.pairingRevision + }) + const request = (args: AiVaultPrepareSessionResumeRequest) => + observe(90_000, (signal) => + host.kind === 'local' + ? window.api.aiVault.prepareSessionResume(args) + : callRuntimeRpc( + target, + 'aiVault.prepareSessionResume', + args, + callOptions(90_000, signal) + ) + ) + // Local desktop and host ship together; only a paired peer needs negotiation. + const modern = + host.kind === 'local' || + ( + await observe(30_000, (signal) => + callRuntimeRpc(target, 'status.get', undefined, callOptions(30_000, signal)) + ) + ).capabilities?.includes(AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY) === true + if (!options.isCurrent()) { + throw new Error('The source chat changed before preparation.') + } + if (modern) { + return prepareAiVaultSessionForFork( + { + sourceChat: { sessionId: subject.sessionId, workspaceId: subject.workspaceId }, + executionHostId: host.id, + fork: true + }, + request + ) + } + // Old peers alone need one same-host inventory; never force a rescan or retry a modern error. + const listed = await observe(150_000, (signal) => + callRuntimeRpc( + target, + 'aiVault.listSessions', + { + ...aiVaultSessionListArgs(subject.request, { requestToken: createBrowserUuid() }), + executionHostId: host.id + }, + callOptions(150_000, signal) + ) + ) + if (!options.isCurrent()) { + throw new Error('The source chat changed before preparation.') + } + const row = findTabSessionHistoryRow(listed.sessions, subject) + if (!row || row.executionHostId !== host.id || !isAiVaultSessionResumableContent(row)) { + throw new Error('This chat has no saved conversation to copy.') + } + return { ...(await prepareAiVaultSessionForFork(row, request)), sourceAgent: row.agent } +} diff --git a/src/renderer/src/web/preload-api/web-ai-vault-api.ts b/src/renderer/src/web/preload-api/web-ai-vault-api.ts index fe852ecd844..f85f7152d40 100644 --- a/src/renderer/src/web/preload-api/web-ai-vault-api.ts +++ b/src/renderer/src/web/preload-api/web-ai-vault-api.ts @@ -4,7 +4,7 @@ import { } from '../../../../shared/ai-vault-search-client' import type { PreloadApi } from '../../../../preload/api-types' import type { - AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeRequest, AiVaultPrepareSessionResumeResult } from '../../../../shared/ai-vault-resume-preparation' import type { AiVaultDeleteSessionArgs } from '../../../../shared/ai-vault-session-deletion' @@ -82,7 +82,7 @@ export function createWebAiVaultApi(): NonNullable['aiVault' // settles on its own timeout. The renderer's refreshId guard already drops // the late result; this only means web pays for a scan nobody reads. cancelListSessions: () => Promise.resolve(), - prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) => + prepareSessionResume: (args: AiVaultPrepareSessionResumeRequest) => callRuntimeResult('aiVault.prepareSessionResume', args), // Why: no server-side RPC for subagent transcript listing yet, so report an empty (not erroring) result. listSubagentSessions: () => Promise.resolve({ sessions: [], issues: [] }), diff --git a/src/renderer/src/web/preload-api/web-runtime-calls.ts b/src/renderer/src/web/preload-api/web-runtime-calls.ts index 16bc514074a..995bf58b5e6 100644 --- a/src/renderer/src/web/preload-api/web-runtime-calls.ts +++ b/src/renderer/src/web/preload-api/web-runtime-calls.ts @@ -48,17 +48,31 @@ export async function callRuntimeEnvelope( return response as RuntimeRpcResponse } +export function assertEnvironmentPairing(selector: string, expectedRevision?: number): void { + const current = resolveEnvironment(selector) + if ( + expectedRevision !== undefined && + (current.pairingRevision ?? current.createdAt) !== expectedRevision + ) { + throw new Error('The paired Orca server changed while the request was in progress.') + } +} + export async function callEnvironmentEnvelope( selector: string, method: string, params?: unknown, - timeoutMs?: number + timeoutMs?: number, + expectedEnvironmentPairingRevision?: number ): Promise> { const environment = resolveEnvironment(selector) + const assertPairing = () => assertEnvironmentPairing(selector, expectedEnvironmentPairingRevision) + assertPairing() if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { return manuallyDisconnectedResponse(environment) } const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { + assertPairing() if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { return Promise.resolve(manuallyDisconnectedResponse(environment)) } @@ -67,6 +81,7 @@ export async function callEnvironmentEnvelope( if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { return manuallyDisconnectedResponse(environment) } + assertPairing() updateEnvironmentFromResponse(environment, response) return response as RuntimeRpcResponse } diff --git a/src/renderer/src/web/preload-api/web-runtime-environments-api.ts b/src/renderer/src/web/preload-api/web-runtime-environments-api.ts index 68918ea39eb..6901574bfb2 100644 --- a/src/renderer/src/web/preload-api/web-runtime-environments-api.ts +++ b/src/renderer/src/web/preload-api/web-runtime-environments-api.ts @@ -15,7 +15,7 @@ import { } from '../web-runtime-environment' import { translate } from '@/i18n/i18n' import { translateHostAccessLinkError } from '@/lib/remote-pairing-copy' -import { callEnvironmentEnvelope } from './web-runtime-calls' +import { assertEnvironmentPairing, callEnvironmentEnvelope } from './web-runtime-calls' import { closeActiveRuntimeClients, subscribeWebRuntimeStatus, @@ -27,7 +27,10 @@ import { removeActiveRuntimeEnvironment, requireActiveEnvironmentOrNull, resolveEnvironment, - webRuntimeState + webRuntimeState, + runtimeCallQueuePool, + updateEnvironmentFromResponse, + manuallyDisconnectedResponse } from './web-runtime-session' export function createRuntimeEnvironmentsApi(): NonNullable< @@ -200,13 +203,23 @@ export function createRuntimeEnvironmentsApi(): NonNullable< : callEnvironmentEnvelope(selector, 'status.get', undefined, timeoutMs), retryControlConnection: () => Promise.resolve(), prepareBrowserClientHostPlacement: async () => ({ kind: 'server' }), - call: ({ selector, method, params, timeoutMs }) => - callEnvironmentEnvelope(selector, method, params, timeoutMs), + call: ({ selector, method, params, timeoutMs, expectedEnvironmentPairingRevision }) => + callEnvironmentEnvelope( + selector, + method, + params, + timeoutMs, + expectedEnvironmentPairingRevision + ), cancelSubscription: async ({ subscriptionId }) => { pendingSubscriptions.get(subscriptionId)?.abort() }, - subscribe: async ({ selector, method, params, timeoutMs, subscriptionId }, callbacks) => { + subscribe: async ( + { selector, method, params, timeoutMs, subscriptionId, expectedEnvironmentPairingRevision }, + callbacks + ) => { const environment = resolveEnvironment(selector) + assertEnvironmentPairing(selector, expectedEnvironmentPairingRevision) const client = getClientForEnvironment(environment) const controller = new AbortController() if (subscriptionId) { @@ -217,10 +230,44 @@ export function createRuntimeEnvironmentsApi(): NonNullable< } let subscription try { - subscription = await client.subscribe(method, params, callbacks, { - timeoutMs, - signal: controller.signal - }) + subscription = await runtimeCallQueuePool.enqueue( + environment.id, + method, + () => { + assertEnvironmentPairing(selector, expectedEnvironmentPairingRevision) + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + throw new Error('runtime_manually_disconnected') + } + return client.subscribe( + method, + params, + { + ...callbacks, + onResponse: (response) => { + try { + assertEnvironmentPairing(selector, expectedEnvironmentPairingRevision) + } catch (error) { + callbacks.onError?.({ + code: 'runtime_environment_changed', + message: String(error) + }) + callbacks.onClose?.() + controller.abort() + return + } + const result = manuallyDisconnectedEnvironmentIds.has(environment.id) + ? manuallyDisconnectedResponse(environment) + : response + updateEnvironmentFromResponse(environment, result) + callbacks.onResponse(result) + } + }, + { timeoutMs, signal: controller.signal } + ) + }, + 0, + controller.signal + ) } finally { if (subscriptionId && pendingSubscriptions.get(subscriptionId) === controller) { pendingSubscriptions.delete(subscriptionId) diff --git a/src/renderer/src/web/web-preload-api-runtime-calls.test.ts b/src/renderer/src/web/web-preload-api-runtime-calls.test.ts index 48d366d1a72..c47608bdca0 100644 --- a/src/renderer/src/web/web-preload-api-runtime-calls.test.ts +++ b/src/renderer/src/web/web-preload-api-runtime-calls.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { WebRuntimeSubscriptionCallbacks } from './web-runtime-subscription-contract' import { installBrowserGlobals, writeStoredRuntimeEnvironment @@ -145,6 +146,74 @@ describe('web preload runtime calls', () => { await expect(Promise.all(accepted)).resolves.toHaveLength(264) }) + it('revokes unsent work and refuses queued or delivered work after pairing replacement', async () => { + let release!: () => void + const blocked = new Promise>((resolve) => { + release = () => + resolve({ id: 'blocked', ok: true, result: null, _meta: { runtimeId: 'runtime-a' } }) + }) + let response = (_value: RuntimeRpcResponse) => {} + const call = vi.fn(() => blocked) + const subscribe = vi.fn( + async (_method: string, _params: unknown, callbacks: WebRuntimeSubscriptionCallbacks) => { + response = callbacks.onResponse + return { unsubscribe: vi.fn(), sendBinary: vi.fn() } + } + ) + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call = call + subscribe = subscribe + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + const { callAbortableRuntimeEnvironment } = + await import('@/runtime/abortable-runtime-environment-call') + const { webRuntimeState } = await import('./preload-api/web-runtime-session') + installWebPreloadApi() + const environment = webRuntimeState.activeEnvironment! + const revision = environment.pairingRevision ?? environment.createdAt + const blockers = Array.from({ length: 8 }, () => + globals.window.api.runtime.call({ method: 'blocker' }) + ) + const prepare = (signal: AbortSignal) => + callAbortableRuntimeEnvironment( + 'web-server-a', + 'aiVault.prepareSessionResume', + {}, + 90_000, + signal, + revision + ) + const source = new AbortController() + const cancelled = expect(prepare(source.signal)).rejects.toThrow('aborted') + source.abort() + await cancelled + const queued = globals.window.api.runtimeEnvironments.call({ + selector: 'web-server-a', + method: 'aiVault.prepareSessionResume', + expectedEnvironmentPairingRevision: revision + }) + const refused = expect(queued).rejects.toThrow('paired Orca server changed') + webRuntimeState.activeEnvironment = { ...environment, pairingRevision: revision + 1 } + release() + await Promise.all(blockers) + await refused + expect(call).toHaveBeenCalledTimes(8) + expect(subscribe).not.toHaveBeenCalled() + webRuntimeState.activeEnvironment = environment + const staleResponse = expect(prepare(new AbortController().signal)).rejects.toThrow( + 'paired Orca server changed' + ) + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(1)) + webRuntimeState.activeEnvironment = { ...environment, pairingRevision: revision + 1 } + response({ id: 'prepare', ok: true, result: {}, _meta: { runtimeId: 'old' } }) + await staleResponse + }) + it('does not spend a selected call timeout while queued and forwards it unchanged', async () => { vi.useFakeTimers() let release!: () => void diff --git a/src/shared/ai-vault-chat-fork-contract.ts b/src/shared/ai-vault-chat-fork-contract.ts new file mode 100644 index 00000000000..a6a230a95cc --- /dev/null +++ b/src/shared/ai-vault-chat-fork-contract.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' +import { AI_VAULT_AGENTS } from './ai-vault-types' + +export const AiVaultChatForkSourceSchema = z.object({ + agent: z.enum(AI_VAULT_AGENTS), + sourceAgent: z.enum(AI_VAULT_AGENTS), + sessionId: z.string().min(1), + filePath: z.string(), + cwd: z.string().min(1).nullable(), + codexHome: z.string().min(1).nullable(), + executionHostPlatform: z.enum([ + 'aix', + 'android', + 'darwin', + 'freebsd', + 'haiku', + 'linux', + 'openbsd', + 'sunos', + 'win32', + 'cygwin', + 'netbsd' + ]) +}) diff --git a/src/shared/ai-vault-resume-preparation.ts b/src/shared/ai-vault-resume-preparation.ts index 2281e624b2d..8a454c44d26 100644 --- a/src/shared/ai-vault-resume-preparation.ts +++ b/src/shared/ai-vault-resume-preparation.ts @@ -15,8 +15,26 @@ export type AiVaultPrepareSessionResumeArgs = Pick< fork?: boolean } +export const AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY = 'ai-vault.chat-fork-source.v1' as const + +export type AiVaultChatForkSource = Pick< + AiVaultSession, + 'agent' | 'sessionId' | 'filePath' | 'cwd' | 'codexHome' +> & { executionHostPlatform: NodeJS.Platform; sourceAgent: AiVaultAgent } + +export type AiVaultPrepareChatForkArgs = { + sourceChat: { sessionId: string; workspaceId: string } + executionHostId?: string + fork: true +} + +export type AiVaultPrepareSessionResumeRequest = + | AiVaultPrepareSessionResumeArgs + | AiVaultPrepareChatForkArgs + export type AiVaultPrepareSessionResumeResult = { useRealCodexHome: boolean + cliForkSource?: AiVaultChatForkSource // Why: cross-account hardlinking lists one rollout under several per-account // homes, so the owning host repins resume to the selected account's home. // Absent (older hosts included) means resume keeps the session's own home. diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index e7bc4cb31e9..0f91cc327b2 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -1,3 +1,4 @@ +import { AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY } from './ai-vault-resume-preparation' import { JIRA_AGENT_ACCESS_RUNTIME_CAPABILITY } from './jira-agent-capability' import { JIRA_STATUS_FILTER_RUNTIME_CAPABILITY } from './jira-quick-filters' import { JIRA_SAVED_FILTER_RUNTIME_CAPABILITY } from './jira-saved-filter-capability' @@ -168,6 +169,7 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ ] as const export const RUNTIME_CAPABILITIES = [ + AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY, jiraDetailCapabilities.JIRA_TRANSITION_FIELDS_RUNTIME_CAPABILITY, JIRA_AGENT_ACCESS_RUNTIME_CAPABILITY, jiraDetailCapabilities.JIRA_CURRENT_SPRINT_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/ai-vault-params.ts b/src/shared/rpc-contract/ai-vault-params.ts index 7655e3b26ba..a832a1bb766 100644 --- a/src/shared/rpc-contract/ai-vault-params.ts +++ b/src/shared/rpc-contract/ai-vault-params.ts @@ -58,14 +58,32 @@ export const AiVaultListSessionsParams = z } }) -export const AiVaultPrepareSessionResumeParams = z.object({ - agent: z.enum(AI_VAULT_AGENTS), - sessionId: z.string().min(1).max(512).optional(), - filePath: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH), - codexHome: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH).nullable(), - executionHostId: z.string().optional(), - fork: z.boolean().optional() -}) +const AiVaultRowResumeParams = z + .object({ + agent: z.enum(AI_VAULT_AGENTS), + sessionId: z.string().min(1).max(512).optional(), + filePath: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH), + codexHome: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH).nullable(), + executionHostId: z.string().optional(), + fork: z.boolean().optional() + }) + .strict() + +export const AiVaultPrepareSessionResumeParams = z.union([ + AiVaultRowResumeParams, + z + .object({ + sourceChat: z + .object({ + sessionId: z.string().min(1).max(512), + workspaceId: z.string().min(1).max(512) + }) + .strict(), + executionHostId: z.string().optional(), + fork: z.literal(true) + }) + .strict() +]) export const AiVaultSessionTitlesParams = z.object({ requests: z