From b623ade25d3a217b4e3ed5242b094ea6a66306e1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:16:20 -0700 Subject: [PATCH 1/7] feat(chat): Resume in New CLI for OpenCode and OMP chats OpenCode's and OMP's CLIs have no copy flag, but their chat protocol copies a conversation (ACP session/fork). For a fork of one of their chats, the chat's host now has a short-lived agent, launched exactly as the chat's own, copy the conversation; the terminal then opens the copy with a plain resume. The chat keeps its own conversation, so the CLI is never a second writer on it. The copy travels on the existing aiVault.prepareSessionResume fork request as an optional forkedSessionId; a host that predates it answers without one, and the client asks for an Orca update instead of resuming the original. --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + src/main/acp/acp-conversation-copy.test.ts | 153 ++++++++++++++++++ src/main/acp/acp-conversation-copy.ts | 72 +++++++++ src/main/acp/acp-session-runtime.ts | 12 +- src/main/acp/acp-structured-connection.ts | 1 + .../acp/acp-structured-session-adapter.ts | 13 +- .../ai-vault/runtime-session-scanner.test.ts | 18 +++ src/main/ai-vault/runtime-session-scanner.ts | 3 +- .../structured-chat-conversation-copy.test.ts | 95 +++++++++++ .../structured-chat-conversation-copy.ts | 40 +++++ ...-session-ownership-client-audience.test.ts | 32 ++-- ...ructured-session-ownership.test-support.ts | 29 ++++ .../ai-vault/structured-session-ownership.ts | 5 +- .../ipc/ai-vault-resume-chat-copy.test.ts | 50 ++++++ src/main/ipc/ai-vault-resume.ts | 6 +- ...structured-agent-session-adapter-router.ts | 8 + .../structured-agent-session-adapter.ts | 7 + src/main/runtime/rpc/methods/ai-vault.test.ts | 37 +++++ src/main/runtime/rpc/methods/ai-vault.ts | 5 +- .../ai-vault-session-cli-fork.test.ts | 4 +- .../ai-vault-session-cli-fork.ts | 16 +- .../ai-vault-session-launch-actions.test.ts | 31 +++- .../ai-vault-session-launch-actions.ts | 19 ++- .../tab-session-history-switch.test.ts | 24 +-- ...i-vault-session-resume-preparation.test.ts | 60 ++++++- .../ai-vault-session-resume-preparation.ts | 43 ++++- src/shared/agent-resume-argv.ts | 18 ++- src/shared/ai-vault-resume-preparation.ts | 3 + src/shared/tui-agent-fork-startup.test.ts | 27 +++- 29 files changed, 763 insertions(+), 69 deletions(-) create mode 100644 src/main/acp/acp-conversation-copy.test.ts create mode 100644 src/main/acp/acp-conversation-copy.ts create mode 100644 src/main/ai-vault/structured-chat-conversation-copy.test.ts create mode 100644 src/main/ai-vault/structured-chat-conversation-copy.ts create mode 100644 src/main/ipc/ai-vault-resume-chat-copy.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index e25444616883..2225b13bbc4e 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -15,6 +15,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/acp/acp-structured-prompt-failure.test.ts', 'src/main/acp/acp-timeline-turn-failures.test.ts', 'src/main/acp/acp-structured-session-adapter.test.ts', + 'src/main/acp/acp-conversation-copy.test.ts', 'src/main/pi/rpc-turn-races.test.ts', 'src/main/acp/acp-structured-child-session-end.test.ts', 'src/main/acp/acp-structured-child-stop.test.ts', diff --git a/src/main/acp/acp-conversation-copy.test.ts b/src/main/acp/acp-conversation-copy.test.ts new file mode 100644 index 000000000000..d2627c382703 --- /dev/null +++ b/src/main/acp/acp-conversation-copy.test.ts @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' +import { + FakeAcpChild, + openAcpAdapterRig, + type AcpAdapterRig, + PROVIDER_SESSION +} from './acp-structured-adapter.test-support' +import { copyAcpConversation } from './acp-conversation-copy' + +const OMP = acpLaunchSpecFor('omp')! +const OPENCODE = acpLaunchSpecFor('opencode')! +const FORKS = { agentCapabilities: { loadSession: true, sessionCapabilities: { fork: {} } } } +const FORKS_INITIALIZED = { protocolVersion: 1, ...FORKS } +const identity = (agent: string): AgentSessionJournalIdentity => ({ + sessionId: 'orca-chat-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent, + providerHandle: null +}) + +const launchOf = (spec: AcpLaunchSpec) => ({ + spec, + command: `/opt/bin/${spec.command}`, + args: ['acp'], + cwd: '/workspace/project', + env: {}, + envToDelete: [], + fullAccess: false, + resume: null +}) + +const rigs: AcpAdapterRig[] = [] +afterEach(async () => { + await Promise.all(rigs.splice(0).map((rig) => rig.adapter.closeAll())) +}) + +async function open(options: Parameters[0]): Promise { + const rig = await openAcpAdapterRig(options) + rigs.push(rig) + return rig +} + +describe('copying a chat conversation for the CLI', () => { + it.each([OMP, OPENCODE])( + 'has $agent copy the conversation under the chat launch and answers the copy', + async (spec) => { + const reads: string[] = [] + const rig = await open({ + spec, + initialize: FORKS, + deps: { + // The unsealed launch a recovery read uses, with the keys the host strips from a child. + readLaunch: async (chat) => { + reads.push(chat.sessionId) + return { ...launchOf(spec), envToDelete: ['ORCA_PANE_KEY', 'ORCA_AGENT_HOOK_PORT'] } + } + }, + script: (agent) => + agent.on('session/fork', (frame) => agent.reply(frame, { sessionId: 'copy-1' })) + }) + + await expect( + rig.adapter.copyConversation({ + identity: identity(spec.agent), + providerSessionId: PROVIDER_SESSION + }) + ).resolves.toBe('copy-1') + + expect(rig.sent('session/fork').map((frame) => frame.params)).toEqual([ + { sessionId: PROVIDER_SESSION, cwd: '/workspace/project', mcpServers: [] } + ]) + // Only the copy is made: the chat's conversation is never loaded, resumed or prompted. + expect(rig.child().agent.frames.map((frame) => frame.method)).toEqual([ + 'initialize', + 'session/fork' + ]) + // The short-lived agent is closed, and runs the chat's launch unsealed, for no chat or pane. + expect(rig.child().closes).toBe(1) + expect(reads).toEqual(['orca-chat-1']) + expect(rig.child().launch.envToDelete).toEqual( + expect.arrayContaining(['ORCA_PANE_KEY', 'ORCA_AGENT_HOOK_PORT']) + ) + } + ) + + it('copies while the chat runs, beside its own agent and without touching it', async () => { + const rig = await open({ + spec: OMP, + initialize: FORKS, + script: (agent) => + agent.on('session/fork', (frame) => agent.reply(frame, { sessionId: 'copy-1' })) + }) + await rig.acquire() + const chat = rig.child() + + await rig.adapter.copyConversation({ + identity: identity('omp'), + providerSessionId: PROVIDER_SESSION + }) + + expect(rig.child()).not.toBe(chat) + expect(chat.closes).toBe(0) + expect(chat.agent.frames.some((frame) => frame.method === 'session/fork')).toBe(false) + }) + + it('asks for an update when the installed agent does not offer the copy', async () => { + const rig = await open({ spec: OMP, initialize: { agentCapabilities: { loadSession: true } } }) + + await expect( + rig.adapter.copyConversation({ identity: identity('omp'), providerSessionId: 'p' }) + ).rejects.toThrow('This OMP version cannot copy a chat. Update OMP to resume this chat') + expect(rig.sent('session/fork')).toEqual([]) + expect(rig.child().closes).toBe(1) + }) + + it('reports a refused copy in plain words and closes the agent', async () => { + const rig = await open({ + spec: OPENCODE, + initialize: FORKS, + script: (agent) => + agent.on('session/fork', (frame) => + agent.fail(frame, -32603, 'Internal error: Internal service failure') + ) + }) + + await expect( + rig.adapter.copyConversation({ identity: identity('opencode'), providerSessionId: 'p' }) + ).rejects.toThrow('OpenCode could not copy this chat.') + expect(rig.child().closes).toBe(1) + }) + + it('gives up on an agent that never answers, and closes it', async () => { + const children: FakeAcpChild[] = [] + const deps = { + spec: OMP, + readLaunch: async () => launchOf(OMP), + connect: (...args: ConstructorParameters) => { + const child = new FakeAcpChild(...args) + child.agent.on('initialize', (frame) => child.agent.reply(frame, FORKS_INITIALIZED)) + children.push(child) + return child + } + } + + await expect( + copyAcpConversation(deps, { identity: identity('omp'), providerSessionId: 'p' }, 10) + ).rejects.toThrow('OMP could not copy this chat.') + expect(children.map((child) => child.closes)).toEqual([1]) + }) +}) diff --git a/src/main/acp/acp-conversation-copy.ts b/src/main/acp/acp-conversation-copy.ts new file mode 100644 index 000000000000..74c0a3867e8a --- /dev/null +++ b/src/main/acp/acp-conversation-copy.ts @@ -0,0 +1,72 @@ +// "Resume in New CLI" for an ACP agent whose CLI has no copy flag: the agent copies the chat's +// conversation over its chat protocol, and the terminal opens the copy with a plain resume, so the +// CLI never becomes a second writer on the conversation the chat owns. + +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { AcpRpcError } from './acp-errors' +import { acpAgentName } from './acp-structured-agent-definitions' +import type { AcpStructuredConnection } from './acp-structured-connection' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' + +/** Bounds a copy, which is one request: past it the agent is closed and the copy fails. */ +export const ACP_CONVERSATION_COPY_TIMEOUT_MS = 60_000 + +/** + * Copies provider conversation `providerSessionId` of the chat `identity` names into a new one the + * agent saves, and answers the copy's id. A short-lived agent runs under the chat's own launch + * (binary, account, environment and folder), whether or not the chat's agent is running: both read + * the same saved conversation, and only the copy is written. + */ +export async function copyAcpConversation( + deps: Pick, + input: { identity: AgentSessionJournalIdentity; providerSessionId: string }, + timeoutMs = ACP_CONVERSATION_COPY_TIMEOUT_MS +): Promise { + const name = acpAgentName(deps.spec.agent) + let connection: AcpStructuredConnection | null = null + let timer: ReturnType | undefined + const timedOut = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${name} did not answer in time.`)), timeoutMs) + }) + try { + // Unsealed: it runs no turn, so it carries none of the chat's identity, and no pane's either. + const launch = await deps.readLaunch(input.identity) + connection = deps.connect( + { + command: launch.command, + args: launch.args, + cwd: launch.cwd, + env: launch.env, + envToDelete: launch.envToDelete + }, + { clientInfo: { name: 'orca', version: '1' } } + ) + return await Promise.race([ + forkAdvertised(connection, input.providerSessionId, launch.cwd), + timedOut + ]) + } catch (error) { + throw new Error( + error instanceof AcpRpcError && error.code === -32601 + ? `This ${name} version cannot copy a chat. Update ${name} to resume this chat in a new CLI.` + : `${name} could not copy this chat.`, + { cause: error } + ) + } finally { + clearTimeout(timer) + await connection?.close().catch(() => false) + } +} + +/** `session/fork`, asked only of an agent that advertises it; -32601 (no such method) otherwise. */ +async function forkAdvertised( + connection: AcpStructuredConnection, + sessionId: string, + cwd: string +): Promise { + const { agentCapabilities } = await connection.initialize() + if (agentCapabilities?.sessionCapabilities?.fork == null) { + throw new AcpRpcError(-32601, 'session/fork is not advertised') + } + return connection.forkSession(sessionId, cwd) +} diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 5f362d0fe7db..8cfd4bbaf425 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -1,5 +1,5 @@ import type { Readable, Writable } from 'node:stream' -import type { z } from 'zod' +import { z } from 'zod' import { AcpAgentError, AcpAuthRequiredError, @@ -28,6 +28,7 @@ import { SetSessionModeResponseSchema, SetSessionModelResponseSchema, SetSessionConfigOptionResponseSchema, + SessionIdSchema, type InitializeRequest, type InitializeResponse, type AuthenticateResponse, @@ -42,6 +43,8 @@ import { type SetSessionModelResponse } from './generated/acp-protocol.generated' +const ForkSessionResponseSchema = z.looseObject({ sessionId: SessionIdSchema }) + type Meta = PromptRequest['_meta'] const withMeta = (meta: Meta): { _meta?: Meta } => (meta ? { _meta: meta } : {}) @@ -142,6 +145,13 @@ export class AcpSessionRuntime { return this.peer.request(method, params, { timeoutMs: null }) } + /** Copies saved session `sessionId` into a new one the agent saves (`session/fork`) and answers + * its id; this connection starts no session of its own. Ask only an agent that advertises it. */ + async forkSession(sessionId: string, cwd: string): Promise { + const params = { sessionId, cwd, mcpServers: [] } + return (await this.call('session/fork', params, ForkSessionResponseSchema)).sessionId + } + start(options: AcpSessionStartOptions): Promise { if (this.started) { return Promise.resolve(this.started) diff --git a/src/main/acp/acp-structured-connection.ts b/src/main/acp/acp-structured-connection.ts index 525189b20035..7a03b5398556 100644 --- a/src/main/acp/acp-structured-connection.ts +++ b/src/main/acp/acp-structured-connection.ts @@ -9,6 +9,7 @@ import type { ProviderProcessLaunch } from '../provider-process/provider-process export type AcpStructuredConnection = Pick< AcpAgentConnection, | 'initialize' + | 'forkSession' | 'start' | 'prompt' | 'cancel' diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 7388ab04ef6c..e5eaa9062060 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -43,6 +43,7 @@ import { import { readAcpLiveOptions, writeAcpSessionOption } from './acp-structured-options' import { readAcpRecoveryHistory } from './acp-recovery-history' import { stopAcpChildren, acpChildStopCapabilities } from './acp-structured-child-stop' +import { copyAcpConversation } from './acp-conversation-copy' export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter { /** Live children, and ones whose exit is not yet proven; a proven exit removes its entry. */ @@ -56,6 +57,9 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte identity }) => readAcpRecoveryHistory(this.deps, identity) + copyConversation: NonNullable = (input) => + copyAcpConversation(this.deps, input) + // The child runs on this runtime's own machine; Windows needs process start-time proof. supportsLocation = (location: AgentSessionExecutionLocation): boolean => supportsSupervisedProviderChildLocation(location, this.deps.isWindowsProcessStartTimeAvailable) @@ -351,11 +355,6 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return session } - private stopGraceMs(): number { - return this.deps.stopGraceMs ?? ACP_STOP_GRACE_MS - } - - private now(): number { - return (this.deps.now ?? Date.now)() - } + private readonly stopGraceMs = (): number => this.deps.stopGraceMs ?? ACP_STOP_GRACE_MS + private readonly now = (): number => (this.deps.now ?? Date.now)() } diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts index db718a4380db..3a0624f73077 100644 --- a/src/main/ai-vault/runtime-session-scanner.test.ts +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -188,6 +188,24 @@ describe('runtime AI Vault session scanner', () => { }) }) + it("keeps the copy a server's chat made for Resume in New CLI", async () => { + mocks.callRuntimeEnvironment.mockResolvedValueOnce({ + ok: true, + result: { useRealCodexHome: false, forkedSessionId: 'ses_copy1' } + }) + + await expect( + prepareRuntimeAiVaultSessionResume('/user-data', 'env-1', { + agent: 'opencode2', + sessionId: 'ses_chat1', + filePath: '/data/opencode/opencode.db', + codexHome: null, + executionHostId: 'runtime:env-1', + fork: true + }) + ).resolves.toEqual({ useRealCodexHome: false, forkedSessionId: 'ses_copy1' }) + }) + it('fails retryably when runtime preparation returns an invalid result', async () => { mocks.callRuntimeEnvironment.mockResolvedValueOnce({ ok: true, result: {} }) diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index e81e29e91603..dcb3bd0d935b 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -33,7 +33,8 @@ export type RuntimeAiVaultScanOptions = { // parent would silently drop it and resume under the wrong account's home. const aiVaultPrepareSessionResumeResultSchema = z.object({ useRealCodexHome: z.boolean(), - substituteCodexHome: z.string().optional() + substituteCodexHome: z.string().optional(), + forkedSessionId: z.string().optional() }) export function getSavedRuntimeAiVaultHostInfos( diff --git a/src/main/ai-vault/structured-chat-conversation-copy.test.ts b/src/main/ai-vault/structured-chat-conversation-copy.test.ts new file mode 100644 index 000000000000..2f979858a58a --- /dev/null +++ b/src/main/ai-vault/structured-chat-conversation-copy.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionProviderHandle } from '../../shared/agent-session-provider-handle' +import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { copyStructuredChatConversation } from './structured-chat-conversation-copy' +import { installCopyingChat as installChat } from './structured-session-ownership.test-support' + +const OMP_ID = '019fd611-2b3c-7d4e-9f50-6b7c8d9e0f1a' +const OPENCODE_ID = 'ses_2f9aB3cD4eF5gH6iJ7kL8mN9oP' +const OMP_FILE = `/Users/me/.omp/agent/sessions/--Users-me-repo--/2026-10-09T10-00-00-000Z_${OMP_ID}.jsonl` + +const acp = (agent: 'opencode' | 'omp', nativeId: string): AgentSessionProviderHandle => ({ + transport: 'acp', + agent, + nativeId +}) + +const fork = ( + overrides: Partial +): AiVaultPrepareSessionResumeArgs => ({ + agent: 'omp', + sessionId: OMP_ID, + filePath: OMP_FILE, + codexHome: null, + executionHostId: 'local', + fork: true, + ...overrides +}) + +afterEach(() => setStructuredAgentSessionHost(null)) + +describe('copying a chat conversation for Resume in New CLI', () => { + it.each([ + { agent: 'omp' as const, handle: acp('omp', OMP_ID), sessionId: OMP_ID }, + { agent: 'opencode2' as const, handle: acp('opencode', OPENCODE_ID), sessionId: OPENCODE_ID } + ])( + "has the chat's host copy a $agent row's conversation", + async ({ agent, handle, sessionId }) => { + const copy = vi.fn(async () => 'copy-1') + installChat(handle, copy) + + await expect( + copyStructuredChatConversation(fork({ agent, sessionId, filePath: `/x/${sessionId}` })) + ).resolves.toEqual({ useRealCodexHome: false, forkedSessionId: 'copy-1' }) + expect(copy).toHaveBeenCalledWith({ + identity: { + sessionId: 'chat-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: handle.agent, + providerHandle: handle + }, + providerSessionId: sessionId + }) + } + ) + + it('leaves a resume, and a fork its CLI makes, to the usual preparation', async () => { + const copy = vi.fn(async () => 'copy-1') + installChat(acp('omp', OMP_ID), copy) + + await expect(copyStructuredChatConversation(fork({ fork: false }))).resolves.toBeNull() + await expect( + copyStructuredChatConversation(fork({ agent: 'claude', sessionId: 'c1' })) + ).resolves.toBeNull() + expect(copy).not.toHaveBeenCalled() + }) + + it('refuses when no chat owns the row any more, never copying another', async () => { + const copy = vi.fn(async () => 'copy-1') + installChat(acp('omp', OMP_ID), copy) + + await expect( + copyStructuredChatConversation(fork({ sessionId: 'other', filePath: '/x/other.jsonl' })) + ).rejects.toThrow('This chat is no longer in Orca') + setStructuredAgentSessionHost(null) + await expect(copyStructuredChatConversation(fork({}))).rejects.toThrow( + 'This chat is no longer in Orca' + ) + expect(copy).not.toHaveBeenCalled() + }) + + it("passes the agent's refusal through", async () => { + installChat( + acp('omp', OMP_ID), + vi.fn(async () => { + throw new Error('OMP could not copy this chat.') + }) + ) + + await expect(copyStructuredChatConversation(fork({}))).rejects.toThrow( + 'OMP could not copy this chat.' + ) + }) +}) diff --git a/src/main/ai-vault/structured-chat-conversation-copy.ts b/src/main/ai-vault/structured-chat-conversation-copy.ts new file mode 100644 index 000000000000..84d8e379c079 --- /dev/null +++ b/src/main/ai-vault/structured-chat-conversation-copy.ts @@ -0,0 +1,40 @@ +import { agentForksThroughChat } from '../../shared/agent-resume-argv' +import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult +} from '../../shared/ai-vault-resume-preparation' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { findResumeOwnership } from './structured-session-ownership' + +/** + * A fork of a chat's conversation, for an agent whose chat makes the copy: the chat's host copies + * it and answers the copy, which the terminal resumes and no chat owns. Null for any other prepare. + * Runs on the host that runs the chat, as every resume preparation does. + */ +export async function copyStructuredChatConversation( + args: AiVaultPrepareSessionResumeArgs +): Promise { + if (!args.fork || !agentForksThroughChat(args.agent)) { + return null + } + const host = getStructuredAgentSessionHost() + const ownership = findResumeOwnership(args) + const record = ownership ? host?.deps.store.getRecord(ownership.sessionId) : undefined + const copy = host?.deps.adapter.copyConversation + if (!host || !ownership || !record || !copy) { + throw new Error('This chat is no longer in Orca, so it cannot be copied to a new CLI.') + } + const forkedSessionId = await copy.call(host.deps.adapter, { + identity: { + sessionId: record.sessionId, + workspaceId: record.location.workspaceId, + hostId: record.location.executionHostId, + agent: record.provider, + providerHandle: + agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle ?? null + }, + providerSessionId: ownership.providerSessionId + }) + return { useRealCodexHome: false, forkedSessionId } +} diff --git a/src/main/ai-vault/structured-session-ownership-client-audience.test.ts b/src/main/ai-vault/structured-session-ownership-client-audience.test.ts index 830bc8b25206..32d91c7e2b9c 100644 --- a/src/main/ai-vault/structured-session-ownership-client-audience.test.ts +++ b/src/main/ai-vault/structured-session-ownership-client-audience.test.ts @@ -6,7 +6,7 @@ import { buildAiVaultResumeShellCommand } from '../../shared/ai-vault-resume-command' import { buildAgentResumeStartupPlan } from '../../shared/tui-agent-resume-startup' -import { agentForksConversations } from '../../shared/agent-resume-argv' +import { agentForksConversations, agentForksThroughChat } from '../../shared/agent-resume-argv' import { isResumableTuiAgent } from '../../shared/agent-session-resume' import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell' import { @@ -220,19 +220,21 @@ describe('resume commands Orca builds for an owned row', () => { } }) - // "Resume in New CLI" opens a copy, so it is no second writer of the chat's conversation. - it.each(everyAgent.filter(({ rowAgent }) => agentForksConversations(rowAgent)))( - 'allows each copy it opens for the $rowAgent row', - async (conversation) => { - installOwnership({ handle: conversation.handle }) - const commands = builtCommands(conversation, true) - expect(commands.length).toBe(7) - for (const command of commands) { - await expect( - assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined), - command - ).resolves.toBeUndefined() - } + // "Resume in New CLI" opens a copy, so it is no second writer of the chat's conversation. A copy + // the chat makes is opened by a plain resume of the copy's own id, which no chat owns. + it.each( + everyAgent.filter( + ({ rowAgent }) => agentForksConversations(rowAgent) && !agentForksThroughChat(rowAgent) + ) + )('allows each copy it opens for the $rowAgent row', async (conversation) => { + installOwnership({ handle: conversation.handle }) + const commands = builtCommands(conversation, true) + expect(commands.length).toBe(7) + for (const command of commands) { + await expect( + assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined), + command + ).resolves.toBeUndefined() } - ) + }) }) diff --git a/src/main/ai-vault/structured-session-ownership.test-support.ts b/src/main/ai-vault/structured-session-ownership.test-support.ts index f8c0c2ec6f7f..06fa2a9f9f3c 100644 --- a/src/main/ai-vault/structured-session-ownership.test-support.ts +++ b/src/main/ai-vault/structured-session-ownership.test-support.ts @@ -4,6 +4,7 @@ import { } from '../../shared/agent-session-record.test-fixture' import type { AgentSessionProviderHandle } from '../../shared/agent-session-provider-handle' import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' +import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { @@ -59,6 +60,34 @@ export function installOwnership( } as never) } +/** A host whose one chat, `chat-1`, owns `handle`'s conversation, and whose adapter copies a + * conversation through `copy`. */ +export function installCopyingChat( + handle: AgentSessionProviderHandle, + copy: StructuredAgentSessionAdapter['copyConversation'] +): void { + const lease = { ...agentSessionLeaseFixture(), sessionId: 'chat-1' } + const fixture = agentSessionRecordFixture(lease) + const record = { + ...fixture, + sessionId: 'chat-1', + provider: handle.agent, + location: { ...fixture.location, workspaceId: 'workspace-1' }, + providerHandleChain: [{ ...fixture.providerHandleChain[0]!, handle }], + lease + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a copy reads only `deps.store` and `deps.adapter.copyConversation`; the rest of the host is never reached. + setStructuredAgentSessionHost({ + deps: { + store: { + listRecords: () => [record], + getRecord: (sessionId: string) => (sessionId === record.sessionId ? record : null) + }, + adapter: { copyConversation: copy } + } + } as never) +} + export function listResult(): AiVaultListResult { const session: AiVaultSession = { id: `local:codex:${PROVIDER_SESSION}`, diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts index 2499f9c50b28..3836b4613e8e 100644 --- a/src/main/ai-vault/structured-session-ownership.ts +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -175,8 +175,9 @@ export async function assertLegacyAiVaultResumeCommandAllowed( } } -function findResumeOwnership( - args: AiVaultPrepareSessionResumeArgs +/** The chat that owns the row a resume or fork names; null when none does. */ +export function findResumeOwnership( + args: Pick ): StructuredProviderSessionOwnership | null { const owner = historyRowOwner(args.agent) if (!owner) { diff --git a/src/main/ipc/ai-vault-resume-chat-copy.test.ts b/src/main/ipc/ai-vault-resume-chat-copy.test.ts new file mode 100644 index 000000000000..01b7b1b56f5b --- /dev/null +++ b/src/main/ipc/ai-vault-resume-chat-copy.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { installCopyingChat } from '../ai-vault/structured-session-ownership.test-support' +import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation' + +vi.mock('electron', () => ({ ipcMain: { handle: vi.fn() } })) + +const { prepareAiVaultSessionResume } = await import('./ai-vault-resume') + +const OMP_ID = '019fd611-2b3c-7d4e-9f50-6b7c8d9e0f1a' +const forkArgs = ( + executionHostId: AiVaultPrepareSessionResumeArgs['executionHostId'] +): AiVaultPrepareSessionResumeArgs => ({ + agent: 'omp', + sessionId: OMP_ID, + filePath: `/Users/me/.omp/agent/sessions/--repo--/x_${OMP_ID}.jsonl`, + codexHome: null, + executionHostId, + fork: true +}) + +afterEach(() => setStructuredAgentSessionHost(null)) + +describe('Resume in New CLI preparation for a chat whose host makes the copy', () => { + it("has this machine's chat make the copy instead of preparing a home", async () => { + const copy = vi.fn(async () => 'copy-1') + installCopyingChat({ transport: 'acp', agent: 'omp', nativeId: OMP_ID }, copy) + const prepareSessionResume = vi.fn() + + await expect( + prepareAiVaultSessionResume(forkArgs('local'), { prepareSessionResume }) + ).resolves.toEqual({ useRealCodexHome: false, forkedSessionId: 'copy-1' }) + expect(prepareSessionResume).not.toHaveBeenCalled() + }) + + it('leaves the copy to the server that runs a remote chat', async () => { + const copy = vi.fn(async () => 'local-copy') + installCopyingChat({ transport: 'acp', agent: 'omp', nativeId: 'unrelated' }, copy) + const prepareRuntimeSessionResume = vi.fn(async () => ({ + useRealCodexHome: false, + forkedSessionId: 'remote-copy' + })) + + await expect( + prepareAiVaultSessionResume(forkArgs('runtime:env-1'), { prepareRuntimeSessionResume }) + ).resolves.toEqual({ useRealCodexHome: false, forkedSessionId: 'remote-copy' }) + expect(prepareRuntimeSessionResume).toHaveBeenCalledWith('env-1', forkArgs('runtime:env-1')) + expect(copy).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/ai-vault-resume.ts b/src/main/ipc/ai-vault-resume.ts index 25469b1d2447..c46eeaecaa20 100644 --- a/src/main/ipc/ai-vault-resume.ts +++ b/src/main/ipc/ai-vault-resume.ts @@ -8,6 +8,7 @@ import { isUnresolvedOwnerHostId, parseExecutionHostId } from '../../shared/exec import { isWslUncPath } from '../../shared/wsl-paths' import { assertLegacyAiVaultResumeAllowed } from '../ai-vault/structured-session-ownership' import { tryEnsureStructuredAgentSessionHostForResume } from '../runtime/structured-agent-session-host-refusal' +import { copyStructuredChatConversation } from '../ai-vault/structured-chat-conversation-copy' export type AiVaultResumeHandlerOptions = { ensureStructuredSessionOwnership?: () => Promise @@ -47,5 +48,8 @@ export async function prepareAiVaultSessionResume( } assertLegacyAiVaultResumeAllowed(args) } - return options.prepareSessionResume?.(args) ?? { useRealCodexHome: false } + return ( + (await copyStructuredChatConversation(args)) ?? + options.prepareSessionResume?.(args) ?? { useRealCodexHome: false } + ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 25e700960780..cb842d04c846 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -200,6 +200,14 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi this.requireAgent(input.identity).adapter.providerHistoryWindow?.(input) ?? Promise.resolve(null) + copyConversation: NonNullable = (input) => { + const { adapter } = this.requireAgent(input.identity) + if (!adapter.copyConversation) { + return Promise.reject(new Error(`${input.identity.agent} cannot copy a conversation`)) + } + return adapter.copyConversation(input) + } + closeSession = (sessionId: string): Promise => this.stopSession(sessionId, (adapter) => adapter.closeSession) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index bddcd06c9645..49c8e48b3976 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -439,6 +439,13 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & identity: AgentSessionJournalIdentity accountHome: AgentSessionAccountHome }): Promise + /** Copies provider conversation `providerSessionId` of the chat into a new one the agent saves, + * answering the copy's id; the chat keeps its own. Runs whether or not the chat's agent is + * running. Absent where the agent copies through its CLI instead (`agentForksThroughChat`). */ + copyConversation?(input: { + identity: AgentSessionJournalIdentity + providerSessionId: string + }): Promise /** Gracefully stops the structured owner after its event stream is drained. */ /** Returns true only after the provider child exit is proven. A root-exit or processless verdict * is thrown only once the session is finalized; read it through `stopAgentSessionProviderRoot`. */ diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index f9d76f4acdd0..7d52ca299811 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -31,6 +31,8 @@ import { } from './ai-vault' import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' import { recordStructuredAgentSessionHostInstallRefusal } from '../../structured-agent-session-host-refusal' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { installCopyingChat } from '../../../ai-vault/structured-session-ownership.test-support' import { configureAiVaultSessionSources, listAiVaultSessions, @@ -252,6 +254,41 @@ describe('aiVault.prepareSessionResume', () => { }) }) +describe('aiVault.prepareSessionResume for a chat whose host makes the copy', () => { + afterEach(() => setStructuredAgentSessionHost(null)) + + it("has the server's chat copy an OMP conversation and answers the copy", async () => { + const providerSessionId = '019fd611-2b3c-7d4e-9f50-6b7c8d9e0f1a' + const copy = vi.fn(async () => 'copy-1') + installCopyingChat({ transport: 'acp', agent: 'omp', nativeId: providerSessionId }, copy) + const prepareAiVaultSessionResume = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only these runtime members. + const runtime = { + getRuntimeId: () => 'test-runtime', + ensureStructuredAgentSessionHost: vi.fn(async () => undefined), + prepareAiVaultSessionResume + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('aiVault.prepareSessionResume', { + agent: 'omp', + sessionId: providerSessionId, + filePath: `/home/me/.omp/agent/sessions/--repo--/x_${providerSessionId}.jsonl`, + codexHome: null, + fork: true + }) + ) + + expect(response).toMatchObject({ + ok: true, + result: { useRealCodexHome: false, forkedSessionId: 'copy-1' } + }) + expect(copy).toHaveBeenCalledWith(expect.objectContaining({ providerSessionId })) + expect(prepareAiVaultSessionResume).not.toHaveBeenCalled() + }) +}) + // Session history and terminal resume are not chats: a process whose chats are refused still // serves them, while any other host failure still fails the request. describe('aiVault methods without a structured host', () => { diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index a5e6d6a58908..effaec21648b 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -10,6 +10,7 @@ import { import { defineMethod } from '../core' import { conditionalAiVaultListResult } from '../../../ai-vault/session-list-fingerprint' import { restampAiVaultListResult } from '../../../ai-vault/session-list-results' +import { copyStructuredChatConversation } from '../../../ai-vault/structured-chat-conversation-copy' import type { AiVaultPrepareSessionResumeArgs } from '../../../../shared/ai-vault-resume-preparation' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { isWslUncPath } from '../../../../shared/wsl-paths' @@ -144,7 +145,9 @@ export const AI_VAULT_METHODS = [ : ensureStructuredAgentSessionHostUnlessRefused(ensureHost)) assertLegacyAiVaultResumeAllowed(args) } - return runtime.prepareAiVaultSessionResume(args) + return ( + (await copyStructuredChatConversation(args)) ?? runtime.prepareAiVaultSessionResume(args) + ) } }) ] diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.test.ts index 9977da1a7ac2..e9c9421e6ff7 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.test.ts @@ -31,11 +31,11 @@ describe('Resume in New CLI eligibility', () => { }) it.each(['opencode', 'opencode2', 'omp'] as const)( - 'withholds it from a %s chat row, whose CLI cannot open a copy', + 'offers it for a %s chat row, whose chat makes the copy', (agent) => { expect( aiVaultSessionCliForkWorktreeId({ agent, ...ROW, structuredSession: OWNED }, RESUMABLE) - ).toBeNull() + ).toBe('repo-1::worktree-2') } ) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts index f5c86c938fab..a3183f900521 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts @@ -1,14 +1,15 @@ -import { translate } from '@/i18n/i18n' import { isAiVaultOwnershipRefusal } from '@/lib/ai-vault-resume-refusal' +import { aiVaultCliForkHostTooOldMessage } from '@/lib/ai-vault-session-resume-preparation' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { getAgentForkArgv, isResumableTuiAgent } from '../../../../shared/agent-session-resume' +import { agentForksThroughChat } from '../../../../shared/agent-resume-argv' import { getAiVaultAgentProviderSession } from '@/lib/ai-vault-resume-command' /** * Where "Resume in New CLI" opens its copy of a conversation native chat owns, or null when the row - * does not offer it. A row no chat owns already resumes in the CLI through plain Resume, and a row - * offers it only when its CLI's copy command can be built (Pi's needs the session file, which a - * remote search hit doesn't carry). + * does not offer it. A row no chat owns already resumes in the CLI through plain Resume. A row offers + * it when its chat makes the copy (OpenCode, OMP), or when its CLI's copy command can be built (Pi's + * needs the session file, which a remote search hit doesn't carry). */ export function aiVaultSessionCliForkWorktreeId( session: Pick, @@ -20,7 +21,7 @@ export function aiVaultSessionCliForkWorktreeId( const providerSession = getAiVaultAgentProviderSession(session) return providerSession && isResumableTuiAgent(session.agent) && - getAgentForkArgv(session.agent, providerSession) + (agentForksThroughChat(session.agent) || getAgentForkArgv(session.agent, providerSession)) ? resume.worktreeId : null } @@ -30,9 +31,6 @@ export function aiVaultSessionCliForkWorktreeId( export function describeAiVaultCliForkFailure(message: string): string { // The desktop preparation's refusal arrives inside Electron's IPC wrapper. 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.' - ) + ? aiVaultCliForkHostTooOldMessage() : message } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts index a17cfb368908..4520b4e3d7af 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts @@ -27,7 +27,9 @@ vi.mock('@/lib/ai-vault-session-resume-preparation', () => ({ prepareAiVaultSessionForFork: mocks.prepareAiVaultSessionForFork, prepareAiVaultSessionForResume: mocks.prepareAiVaultSessionForResume, prepareAiVaultSessionForTerminalResume: mocks.prepareAiVaultSessionForTerminalResume, - dropDeletedSshResumeCwd: async (session: unknown) => session + dropDeletedSshResumeCwd: async (session: unknown) => session, + aiVaultCliForkHostTooOldMessage: () => + 'Update Orca on the host that runs this chat to resume it in a new CLI.' })) vi.mock('@/lib/launch-ai-vault-session', () => ({ launchAiVaultSessionInNewTab: mocks.launchAiVaultSessionInNewTab @@ -91,7 +93,10 @@ function renderActions() { describe('Resume in New CLI launch', () => { beforeEach(() => { vi.clearAllMocks() - mocks.prepareAiVaultSessionForFork.mockImplementation(async (session) => session) + mocks.prepareAiVaultSessionForFork.mockImplementation(async (session) => ({ + session, + copied: false + })) mocks.buildAiVaultForkStartupForWorktree.mockReturnValue(FORK_STARTUP) mocks.launchAiVaultSessionInNewTab.mockReturnValue({ tabId: 'tab-1' }) }) @@ -115,6 +120,28 @@ describe('Resume in New CLI launch', () => { expect(mocks.toastError).not.toHaveBeenCalled() }) + it("opens the copy an OpenCode or OMP chat's host made with a plain resume", async () => { + const chat = { ...owned, agent: 'omp' as const, resumeCommand: 'omp --resume provider-1' } + const copy = { ...chat, sessionId: 'copy-1', filePath: '', resumeCommand: '' } + const RESUME_STARTUP = { command: "omp --resume 'copy-1'", cwd: '/repo' } + mocks.prepareAiVaultSessionForFork.mockResolvedValue({ session: copy, copied: true }) + mocks.buildAiVaultResumeStartupForWorktree.mockReturnValue(RESUME_STARTUP) + + renderActions().handleResumeInNewCli(chat, 'worktree-2') + + await waitFor(() => expect(mocks.launchAiVaultSessionInNewTab).toHaveBeenCalledTimes(1)) + expect(mocks.buildAiVaultResumeStartupForWorktree).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'worktree-2', session: copy }) + ) + expect(mocks.buildAiVaultForkStartupForWorktree).not.toHaveBeenCalled() + expect(mocks.launchAiVaultSessionInNewTab).toHaveBeenCalledWith({ + agent: 'omp', + worktreeId: 'worktree-2', + ...RESUME_STARTUP + }) + expect(mocks.toastError).not.toHaveBeenCalled() + }) + it('shows one toast and opens nothing when the agent cannot fork', async () => { mocks.buildAiVaultForkStartupForWorktree.mockReturnValue(null) 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 08421b670b83..a01119ab6036 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 @@ -182,12 +182,17 @@ export function useAiVaultSessionLaunchActions({ const handleResumeInNewCli = useCallback( (session: AiVaultSession, targetWorktreeId: string): void => { const prepareStartup = async (worktreeId: string): Promise => { - const startup = buildAiVaultForkStartupForWorktree({ - state: useAppStore.getState(), - worktreeId, - session: await prepareAiVaultSessionForFork(session).then(dropDeletedSshResumeCwd), - commandOverride: agentCmdOverrides?.[session.agent] - }) + const prepared = await prepareAiVaultSessionForFork(session) + const target = await dropDeletedSshResumeCwd(prepared.session) + // A copy the chat's host already made is the terminal's own conversation: a plain resume. + const startup = prepared.copied + ? buildResumeStartup(target, worktreeId) + : buildAiVaultForkStartupForWorktree({ + state: useAppStore.getState(), + worktreeId, + session: target, + commandOverride: agentCmdOverrides?.[session.agent] + }) if (!startup) { throw new Error( translate( @@ -200,7 +205,7 @@ export function useAiVaultSessionLaunchActions({ } launchInTerminal(session, targetWorktreeId, prepareStartup, describeAiVaultCliForkFailure) }, - [agentCmdOverrides, launchInTerminal] + [agentCmdOverrides, buildResumeStartup, launchInTerminal] ) const handleResumeInNewChat = useCallback( diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts index ea3483d956ef..17a0a9c84305 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts @@ -192,27 +192,27 @@ describe('resolveTabSessionHistorySubject', () => { ).toEqual({ ...chatSubject }) }) - it('finds a chat of any agent whose CLI can open a copy', () => { - expect( - resolveTabSessionHistorySubject(makeState(), { - tab: { id: 'unified-1', worktreeId: WORKTREE_ID }, - nativeChat: { sessionId: 'orca-chat-1', agent: 'grok' } - }) - ).toEqual({ ...chatSubject }) - }) - - it.each(['opencode', 'omp'] as const)( - 'skips a %s chat, whose CLI cannot open a copy', + it.each(['grok', 'opencode', 'omp'] as const)( + 'finds a %s chat, whose conversation can be copied to its CLI', (agent) => { expect( resolveTabSessionHistorySubject(makeState(), { tab: { id: 'unified-1', worktreeId: WORKTREE_ID }, nativeChat: { sessionId: 'orca-chat-1', agent } }) - ).toBeNull() + ).toEqual({ ...chatSubject }) } ) + it('skips a chat of an agent that cannot copy its conversation', () => { + expect( + resolveTabSessionHistorySubject(makeState(), { + tab: { id: 'unified-1', worktreeId: WORKTREE_ID, launchAgent: 'gemini' }, + structuredSessionId: 'orca-chat-1' + }) + ).toBeNull() + }) + it('finds a terminal tab by the conversation its agent reported', () => { expect( resolveTabSessionHistorySubject(makeState(liveClaudeEntry), { diff --git a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts index b29f32fba47f..8aabc40e6bc0 100644 --- a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts @@ -138,7 +138,8 @@ describe('prepareAiVaultSessionForFork', () => { const prepared = await prepareAiVaultSessionForFork(owned) - expect(prepared.codexHome).toBe('/tmp/orca/codex-accounts/account-2/home') + expect(prepared.copied).toBe(false) + expect(prepared.session.codexHome).toBe('/tmp/orca/codex-accounts/account-2/home') expect(prepareSessionResume).toHaveBeenCalledWith({ agent: 'codex', sessionId: owned.sessionId, @@ -158,9 +159,64 @@ describe('prepareAiVaultSessionForFork', () => { structuredSession: { sessionId: 'session-1', workspaceId: 'worktree-1' } }) - await expect(prepareAiVaultSessionForFork(owned)).resolves.toBe(owned) + await expect(prepareAiVaultSessionForFork(owned)).resolves.toEqual({ + session: owned, + copied: false + }) expect(prepareSessionResume).not.toHaveBeenCalled() }) + + it.each(['opencode', 'opencode2', 'omp'] as const)( + "has the %s chat's host make the copy, and opens that copy, which no chat owns", + async (agent) => { + const prepareSessionResume = vi.fn().mockResolvedValue({ + useRealCodexHome: false, + forkedSessionId: 'copy-1' + }) + stubPreparation(prepareSessionResume) + const owned = session({ + agent, + sessionId: 'chat-conversation', + filePath: '/tmp/chat-conversation.jsonl', + resumeCommand: `${agent} --resume chat-conversation`, + executionHostId: 'runtime:env-1', + structuredSession: { sessionId: 'session-1', workspaceId: 'worktree-1' } + }) + + const prepared = await prepareAiVaultSessionForFork(owned) + + expect(prepareSessionResume).toHaveBeenCalledWith({ + agent, + sessionId: 'chat-conversation', + filePath: '/tmp/chat-conversation.jsonl', + codexHome: null, + executionHostId: 'runtime:env-1', + fork: true + }) + expect(prepared.copied).toBe(true) + expect(prepared.session).toMatchObject({ + agent, + sessionId: 'copy-1', + filePath: '', + resumeCommand: '', + cwd: '/repo', + executionHostId: 'runtime:env-1' + }) + expect(prepared.session).not.toHaveProperty('structuredSession') + } + ) + + it("refuses a host that made no copy rather than resuming the chat's own conversation", async () => { + stubPreparation(vi.fn().mockResolvedValue({ useRealCodexHome: false })) + const owned = session({ + agent: 'omp', + structuredSession: { sessionId: 'session-1', workspaceId: 'worktree-1' } + }) + + await expect(prepareAiVaultSessionForFork(owned)).rejects.toThrow( + 'Update Orca on the host that runs this chat to resume it in a new CLI.' + ) + }) }) describe('dropDeletedSshResumeCwd', () => { 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 04bef7974be9..932b3ae7f18a 100644 --- a/src/renderer/src/lib/ai-vault-session-resume-preparation.ts +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.ts @@ -14,6 +14,8 @@ import { } from '../../../shared/execution-host' import { readIpcErrorMessage } from './ipc-error' import type { AiVaultResumeCommandSession } from './ai-vault-resume-command' +import { agentForksThroughChat } from '../../../shared/agent-resume-argv' +import { translate } from '@/i18n/i18n' export function prepareAiVaultSessionForResume(session: AiVaultSession): Promise { return session.structuredSession || !aiVaultSessionNeedsResumePreparation(session) @@ -78,9 +80,44 @@ function isMissingPairedResumePreparation(error: unknown): boolean { ) } -/** Picks the Codex home a fork of `session` runs under, the same way a resume picks it. */ -export function prepareAiVaultSessionForFork(session: AiVaultSession): Promise { - return prepareAiVaultSessionHome(session, true) +/** + * The conversation "Resume in New CLI" opens. Most agents' fork command makes the copy, so this + * only picks the Codex home it runs under, as a resume does (`copied: false`). An agent whose chat + * 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`). + */ +export async function prepareAiVaultSessionForFork( + session: AiVaultSession +): Promise<{ session: AiVaultSession; copied: boolean }> { + if (!agentForksThroughChat(session.agent)) { + return { session: await prepareAiVaultSessionHome(session, true), copied: false } + } + const result = await window.api.aiVault.prepareSessionResume({ + agent: session.agent, + sessionId: session.sessionId, + filePath: session.filePath, + codexHome: session.codexHome, + executionHostId: session.executionHostId, + fork: true + }) + if (!result.forkedSessionId) { + // A host that predates chat copies makes none; resuming the chat's own would be a second writer. + throw new Error(aiVaultCliForkHostTooOldMessage()) + } + const { structuredSession: _owner, ...copy } = session + // The copy's own file is unknown here; its id resumes it in the chat's folder. + return { + session: { ...copy, sessionId: result.forkedSessionId, filePath: '', resumeCommand: '' }, + copied: true + } +} + +/** Only a host that predates a fork refuses or ignores it, so the failure asks for the update. */ +export function aiVaultCliForkHostTooOldMessage(): string { + return translate( + 'auto.components.right.sidebar.AiVaultPanel.resumeInNewCliHostTooOld', + 'Update Orca on the host that runs this chat to resume it in a new CLI.' + ) } async function prepareAiVaultSessionHome( diff --git a/src/shared/agent-resume-argv.ts b/src/shared/agent-resume-argv.ts index c165ec833855..9ba961cfbe44 100644 --- a/src/shared/agent-resume-argv.ts +++ b/src/shared/agent-resume-argv.ts @@ -91,8 +91,24 @@ const FORK_ARGV: Partial< pi: ({ transcriptPath }) => (transcriptPath ? ['pi', '--fork', transcriptPath] : null) } +/** Agents whose CLI cannot copy a conversation but whose chat protocol can (ACP `session/fork`, as + * OpenCode 2.0.14 and OMP 17.0.5 advertise): the chat's host makes the copy, and the terminal opens + * it with a plain resume. A host whose agent does not advertise the copy refuses it. OpenCode 2.x + * lists its chats' conversations as `opencode2` rows. */ +const FORKS_THROUGH_CHAT: ReadonlySet = new Set([ + 'opencode', + 'opencode2', + 'omp' +]) + +/** Whether "Resume in New CLI" can open a copy of this agent's conversations at all. A row's own + * gate (`aiVaultSessionCliForkWorktreeId`) also checks that its copy can be made. */ export function agentForksConversations(agent: string): boolean { - return Object.hasOwn(FORK_ARGV, agent) + return Object.hasOwn(FORK_ARGV, agent) || FORKS_THROUGH_CHAT.has(agent) +} + +export function agentForksThroughChat(agent: string): boolean { + return FORKS_THROUGH_CHAT.has(agent) } export function getAgentForkArgv( diff --git a/src/shared/ai-vault-resume-preparation.ts b/src/shared/ai-vault-resume-preparation.ts index 93e15accf08a..e5761e453a9b 100644 --- a/src/shared/ai-vault-resume-preparation.ts +++ b/src/shared/ai-vault-resume-preparation.ts @@ -16,6 +16,9 @@ export type AiVaultPrepareSessionResumeResult = { // 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. substituteCodexHome?: string + /** The copy a fork made of a chat's conversation, for an agent whose chat makes it + * (`agentForksThroughChat`). Absent from older hosts, which make none. */ + forkedSessionId?: string } export type AiVaultSessionResumePreparation = ( diff --git a/src/shared/tui-agent-fork-startup.test.ts b/src/shared/tui-agent-fork-startup.test.ts index e56b5b0892b4..0b2cee6590d9 100644 --- a/src/shared/tui-agent-fork-startup.test.ts +++ b/src/shared/tui-agent-fork-startup.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { agentForksConversations, getAgentForkArgv } from './agent-resume-argv' +import { + agentForksConversations, + agentForksThroughChat, + getAgentForkArgv +} from './agent-resume-argv' import { buildAgentResumeStartupPlan } from './tui-agent-startup' const SESSION = { key: 'session_id', id: 's1' } as const @@ -13,11 +17,28 @@ describe('agent fork startup', () => { '/s/a.jsonl' ]) expect(getAgentForkArgv('pi', SESSION)).toBeNull() - expect(getAgentForkArgv('opencode', SESSION)).toBeNull() - expect(getAgentForkArgv('omp', SESSION)).toBeNull() expect(agentForksConversations('constructor')).toBe(false) }) + it('forks OpenCode and OMP through their chat, never a resume of the original', () => { + for (const agent of ['opencode', 'opencode2', 'omp'] as const) { + expect(agentForksConversations(agent)).toBe(true) + expect(agentForksThroughChat(agent)).toBe(true) + expect(getAgentForkArgv(agent, SESSION)).toBeNull() + expect( + buildAgentResumeStartupPlan({ + agent, + providerSession: SESSION, + cmdOverrides: {}, + platform: 'darwin', + fork: true + }) + ).toBeNull() + } + expect(agentForksThroughChat('claude')).toBe(false) + expect(agentForksConversations('gemini')).toBe(false) + }) + it('forks Claude and Codex', () => { expect(getAgentForkArgv('claude', SESSION)).toEqual([ 'claude', From 340d4e409dbaad6787349b27d2aae0958a7ee91b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:10:39 -0700 Subject: [PATCH 2/7] fix(chat): open an OpenCode 2.x chat's CLI copy with opencode, not opencode2 The copy kept the row's agent, so Resume in New CLI ran `opencode2 --standalone --session `, which OpenCode 2.x doesn't install. The chat's host now answers the copy with the agent whose CLI opens it: the chat's own. --- src/main/ai-vault/runtime-session-scanner.test.ts | 10 ++++++++-- src/main/ai-vault/runtime-session-scanner.ts | 3 ++- .../structured-chat-conversation-copy.test.ts | 6 +++++- .../ai-vault/structured-chat-conversation-copy.ts | 5 ++++- src/main/ipc/ai-vault-resume-chat-copy.test.ts | 12 +++++++++--- src/main/runtime/rpc/methods/ai-vault.test.ts | 2 +- .../lib/ai-vault-session-resume-preparation.test.ts | 5 +++-- .../src/lib/ai-vault-session-resume-preparation.ts | 7 ++++--- src/shared/ai-vault-resume-preparation.ts | 7 ++++--- 9 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts index 3a0624f73077..975b9e90211e 100644 --- a/src/main/ai-vault/runtime-session-scanner.test.ts +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -191,7 +191,10 @@ describe('runtime AI Vault session scanner', () => { it("keeps the copy a server's chat made for Resume in New CLI", async () => { mocks.callRuntimeEnvironment.mockResolvedValueOnce({ ok: true, - result: { useRealCodexHome: false, forkedSessionId: 'ses_copy1' } + result: { + useRealCodexHome: false, + forkedSession: { sessionId: 'ses_copy1', agent: 'opencode' } + } }) await expect( @@ -203,7 +206,10 @@ describe('runtime AI Vault session scanner', () => { executionHostId: 'runtime:env-1', fork: true }) - ).resolves.toEqual({ useRealCodexHome: false, forkedSessionId: 'ses_copy1' }) + ).resolves.toEqual({ + useRealCodexHome: false, + forkedSession: { sessionId: 'ses_copy1', agent: 'opencode' } + }) }) it('fails retryably when runtime preparation returns an invalid result', async () => { diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index dcb3bd0d935b..1d17f273f797 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { + AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT, type AiVaultListArgs, type AiVaultListResult, @@ -34,7 +35,7 @@ export type RuntimeAiVaultScanOptions = { const aiVaultPrepareSessionResumeResultSchema = z.object({ useRealCodexHome: z.boolean(), substituteCodexHome: z.string().optional(), - forkedSessionId: z.string().optional() + forkedSession: z.object({ sessionId: z.string(), agent: z.enum(AI_VAULT_AGENTS) }).optional() }) export function getSavedRuntimeAiVaultHostInfos( diff --git a/src/main/ai-vault/structured-chat-conversation-copy.test.ts b/src/main/ai-vault/structured-chat-conversation-copy.test.ts index 2f979858a58a..0414fc8d136b 100644 --- a/src/main/ai-vault/structured-chat-conversation-copy.test.ts +++ b/src/main/ai-vault/structured-chat-conversation-copy.test.ts @@ -41,7 +41,11 @@ describe('copying a chat conversation for Resume in New CLI', () => { await expect( copyStructuredChatConversation(fork({ agent, sessionId, filePath: `/x/${sessionId}` })) - ).resolves.toEqual({ useRealCodexHome: false, forkedSessionId: 'copy-1' }) + ).resolves.toEqual({ + useRealCodexHome: false, + // The copy opens with the CLI that ran the chat: `opencode` for an OpenCode 2.x row. + forkedSession: { sessionId: 'copy-1', agent: handle.agent } + }) expect(copy).toHaveBeenCalledWith({ identity: { sessionId: 'chat-1', diff --git a/src/main/ai-vault/structured-chat-conversation-copy.ts b/src/main/ai-vault/structured-chat-conversation-copy.ts index 84d8e379c079..e09622278295 100644 --- a/src/main/ai-vault/structured-chat-conversation-copy.ts +++ b/src/main/ai-vault/structured-chat-conversation-copy.ts @@ -36,5 +36,8 @@ export async function copyStructuredChatConversation( }, providerSessionId: ownership.providerSessionId }) - return { useRealCodexHome: false, forkedSessionId } + return { + useRealCodexHome: false, + forkedSession: { sessionId: forkedSessionId, agent: record.provider } + } } diff --git a/src/main/ipc/ai-vault-resume-chat-copy.test.ts b/src/main/ipc/ai-vault-resume-chat-copy.test.ts index 01b7b1b56f5b..c7ab19153962 100644 --- a/src/main/ipc/ai-vault-resume-chat-copy.test.ts +++ b/src/main/ipc/ai-vault-resume-chat-copy.test.ts @@ -29,7 +29,10 @@ describe('Resume in New CLI preparation for a chat whose host makes the copy', ( await expect( prepareAiVaultSessionResume(forkArgs('local'), { prepareSessionResume }) - ).resolves.toEqual({ useRealCodexHome: false, forkedSessionId: 'copy-1' }) + ).resolves.toEqual({ + useRealCodexHome: false, + forkedSession: { sessionId: 'copy-1', agent: 'omp' } + }) expect(prepareSessionResume).not.toHaveBeenCalled() }) @@ -38,12 +41,15 @@ describe('Resume in New CLI preparation for a chat whose host makes the copy', ( installCopyingChat({ transport: 'acp', agent: 'omp', nativeId: 'unrelated' }, copy) const prepareRuntimeSessionResume = vi.fn(async () => ({ useRealCodexHome: false, - forkedSessionId: 'remote-copy' + forkedSession: { sessionId: 'remote-copy', agent: 'omp' } })) await expect( prepareAiVaultSessionResume(forkArgs('runtime:env-1'), { prepareRuntimeSessionResume }) - ).resolves.toEqual({ useRealCodexHome: false, forkedSessionId: 'remote-copy' }) + ).resolves.toEqual({ + useRealCodexHome: false, + forkedSession: { sessionId: 'remote-copy', agent: 'omp' } + }) expect(prepareRuntimeSessionResume).toHaveBeenCalledWith('env-1', forkArgs('runtime:env-1')) expect(copy).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index 7d52ca299811..165c0386b90b 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -282,7 +282,7 @@ describe('aiVault.prepareSessionResume for a chat whose host makes the copy', () expect(response).toMatchObject({ ok: true, - result: { useRealCodexHome: false, forkedSessionId: 'copy-1' } + result: { useRealCodexHome: false, forkedSession: { sessionId: 'copy-1', agent: 'omp' } } }) expect(copy).toHaveBeenCalledWith(expect.objectContaining({ providerSessionId })) expect(prepareAiVaultSessionResume).not.toHaveBeenCalled() diff --git a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts index 8aabc40e6bc0..fee9f3a9be98 100644 --- a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts @@ -171,7 +171,7 @@ describe('prepareAiVaultSessionForFork', () => { async (agent) => { const prepareSessionResume = vi.fn().mockResolvedValue({ useRealCodexHome: false, - forkedSessionId: 'copy-1' + forkedSession: { sessionId: 'copy-1', agent: agent === 'opencode2' ? 'opencode' : agent } }) stubPreparation(prepareSessionResume) const owned = session({ @@ -194,8 +194,9 @@ describe('prepareAiVaultSessionForFork', () => { fork: true }) expect(prepared.copied).toBe(true) + // An OpenCode 2.x chat's conversation lists as `opencode2`; its copy opens with `opencode`. expect(prepared.session).toMatchObject({ - agent, + agent: agent === 'opencode2' ? 'opencode' : agent, sessionId: 'copy-1', filePath: '', resumeCommand: '', 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 932b3ae7f18a..d46dbe9bb079 100644 --- a/src/renderer/src/lib/ai-vault-session-resume-preparation.ts +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.ts @@ -100,14 +100,15 @@ export async function prepareAiVaultSessionForFork( executionHostId: session.executionHostId, fork: true }) - if (!result.forkedSessionId) { + if (!result.forkedSession) { // A host that predates chat copies makes none; resuming the chat's own would be a second writer. throw new Error(aiVaultCliForkHostTooOldMessage()) } const { structuredSession: _owner, ...copy } = session - // The copy's own file is unknown here; its id resumes it in the chat's folder. + // The copy's own file is unknown here; its id resumes it in the chat's folder, with the chat's CLI. + const { sessionId, agent } = result.forkedSession return { - session: { ...copy, sessionId: result.forkedSessionId, filePath: '', resumeCommand: '' }, + session: { ...copy, agent, sessionId, filePath: '', resumeCommand: '' }, copied: true } } diff --git a/src/shared/ai-vault-resume-preparation.ts b/src/shared/ai-vault-resume-preparation.ts index e5761e453a9b..b6a150bdb647 100644 --- a/src/shared/ai-vault-resume-preparation.ts +++ b/src/shared/ai-vault-resume-preparation.ts @@ -1,4 +1,4 @@ -import type { AiVaultSession } from './ai-vault-types' +import type { AiVaultAgent, AiVaultSession } from './ai-vault-types' export type AiVaultPrepareSessionResumeArgs = Pick< AiVaultSession, @@ -17,8 +17,9 @@ export type AiVaultPrepareSessionResumeResult = { // Absent (older hosts included) means resume keeps the session's own home. substituteCodexHome?: string /** The copy a fork made of a chat's conversation, for an agent whose chat makes it - * (`agentForksThroughChat`). Absent from older hosts, which make none. */ - forkedSessionId?: string + * (`agentForksThroughChat`): its id, and the agent whose CLI opens it, the chat's (an OpenCode + * 2.x chat lists as `opencode2` but runs `opencode`). Absent from older hosts, which make none. */ + forkedSession?: { sessionId: string; agent: AiVaultAgent } } export type AiVaultSessionResumePreparation = ( From 368b511ec3676d2c97ff1431e23767eadb8cbb32 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:16:39 -0700 Subject: [PATCH 3/7] fix(chat): type the copied conversation's agent as a history agent --- src/main/ai-vault/session-list-result-validation.ts | 2 +- src/main/ai-vault/structured-chat-conversation-copy.ts | 7 ++++++- src/main/ipc/ai-vault-resume-chat-copy.test.ts | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/ai-vault/session-list-result-validation.ts b/src/main/ai-vault/session-list-result-validation.ts index 5b005eb60bf5..fcae149b68a7 100644 --- a/src/main/ai-vault/session-list-result-validation.ts +++ b/src/main/ai-vault/session-list-result-validation.ts @@ -10,7 +10,7 @@ import { normalizeExecutionHostId } from '../../shared/execution-host' const aiVaultAgentSet = new Set(AI_VAULT_AGENTS) -function isAiVaultAgent(agent: string): agent is AiVaultAgent { +export function isAiVaultAgent(agent: string): agent is AiVaultAgent { return aiVaultAgentSet.has(agent) } diff --git a/src/main/ai-vault/structured-chat-conversation-copy.ts b/src/main/ai-vault/structured-chat-conversation-copy.ts index e09622278295..71badd861a50 100644 --- a/src/main/ai-vault/structured-chat-conversation-copy.ts +++ b/src/main/ai-vault/structured-chat-conversation-copy.ts @@ -5,6 +5,7 @@ import type { AiVaultPrepareSessionResumeResult } from '../../shared/ai-vault-resume-preparation' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { isAiVaultAgent } from './session-list-result-validation' import { findResumeOwnership } from './structured-session-ownership' /** @@ -38,6 +39,10 @@ export async function copyStructuredChatConversation( }) return { useRealCodexHome: false, - forkedSession: { sessionId: forkedSessionId, agent: record.provider } + forkedSession: { + sessionId: forkedSessionId, + // 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 + } } } diff --git a/src/main/ipc/ai-vault-resume-chat-copy.test.ts b/src/main/ipc/ai-vault-resume-chat-copy.test.ts index c7ab19153962..1d7ded4a18ad 100644 --- a/src/main/ipc/ai-vault-resume-chat-copy.test.ts +++ b/src/main/ipc/ai-vault-resume-chat-copy.test.ts @@ -41,7 +41,7 @@ describe('Resume in New CLI preparation for a chat whose host makes the copy', ( installCopyingChat({ transport: 'acp', agent: 'omp', nativeId: 'unrelated' }, copy) const prepareRuntimeSessionResume = vi.fn(async () => ({ useRealCodexHome: false, - forkedSession: { sessionId: 'remote-copy', agent: 'omp' } + forkedSession: { sessionId: 'remote-copy', agent: 'omp' as const } })) await expect( From 88e4b5c0e42f08dcd62950a4aa2fd2d00b37ee22 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:52:11 -0700 Subject: [PATCH 4/7] fix: preserve independent chat copies through CLI launch --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + src/main/acp/acp-conversation-copies.ts | 37 +++ .../acp-conversation-copy-lifecycle.test.ts | 166 +++++++++++++ ...-conversation-copy-opencode-server.test.ts | 131 +++++++++++ src/main/acp/acp-conversation-copy.test.ts | 219 +++++++++++++++++- src/main/acp/acp-conversation-copy.ts | 134 ++++++++--- .../acp-structured-session-adapter-deps.ts | 2 + .../acp/acp-structured-session-adapter.ts | 18 +- .../ai-vault/runtime-session-scanner.test.ts | 32 ++- src/main/ai-vault/runtime-session-scanner.ts | 8 +- .../structured-chat-conversation-copy.test.ts | 49 +++- .../structured-chat-conversation-copy.ts | 13 +- ...ructured-session-ownership.test-support.ts | 6 +- .../ipc/ai-vault-resume-chat-copy.test.ts | 27 ++- ...ged-data-account-spawn-environment.test.ts | 29 +++ .../structured-agent-session-adapter.ts | 4 +- .../structured-agent-configured-args.test.ts | 2 +- .../structured-agent-configured-args.ts | 4 +- ...a-runtime-conversation-copy-resume.test.ts | 86 +++++++ .../ai-vault-copy-server-route.test.ts | 77 ++++++ src/main/runtime/rpc/methods/ai-vault.test.ts | 14 +- .../structured-agent-runtime-registrations.ts | 1 + .../ai-vault-session-cli-fork.test.ts | 10 + .../ai-vault-session-cli-fork.ts | 4 +- .../ai-vault-session-launch-actions.test.ts | 90 ++++++- .../ai-vault-session-launch-actions.ts | 51 ++-- .../ai-vault-session-launch-feedback.ts | 24 ++ .../tab-session-history-switch.test.ts | 4 +- .../src/lib/ai-vault-omp-cold-resume.test.ts | 38 +++ .../src/lib/ai-vault-resume-command.ts | 9 +- .../lib/ai-vault-session-fork-startup.test.ts | 50 +++- .../src/lib/ai-vault-session-fork-startup.ts | 81 +++++++ ...i-vault-session-resume-preparation.test.ts | 8 +- .../ai-vault-session-resume-preparation.ts | 7 +- ...b-runtime-conversation-copy-resume.test.ts | 185 +++++++++++++++ src/shared/ai-vault-resume-preparation.ts | 7 +- src/shared/opencode-launch-command.test.ts | 23 +- src/shared/opencode-launch-command.ts | 16 ++ 38 files changed, 1558 insertions(+), 109 deletions(-) create mode 100644 src/main/acp/acp-conversation-copies.ts create mode 100644 src/main/acp/acp-conversation-copy-lifecycle.test.ts create mode 100644 src/main/acp/acp-conversation-copy-opencode-server.test.ts create mode 100644 src/main/runtime/orca-runtime-conversation-copy-resume.test.ts create mode 100644 src/main/runtime/rpc/methods/ai-vault-copy-server-route.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-launch-feedback.ts create mode 100644 src/renderer/src/runtime/web-runtime-conversation-copy-resume.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 2225b13bbc4e..b7fed8b483ff 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -16,6 +16,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/acp/acp-timeline-turn-failures.test.ts', 'src/main/acp/acp-structured-session-adapter.test.ts', 'src/main/acp/acp-conversation-copy.test.ts', + 'src/main/acp/acp-conversation-copy-lifecycle.test.ts', 'src/main/pi/rpc-turn-races.test.ts', 'src/main/acp/acp-structured-child-session-end.test.ts', 'src/main/acp/acp-structured-child-stop.test.ts', diff --git a/src/main/acp/acp-conversation-copies.ts b/src/main/acp/acp-conversation-copies.ts new file mode 100644 index 000000000000..ed453a3f82cf --- /dev/null +++ b/src/main/acp/acp-conversation-copies.ts @@ -0,0 +1,37 @@ +import { randomUUID } from 'node:crypto' +import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { ProviderAcquisitionStarts } from '../provider-process/provider-acquisition-starts' +import { copyAcpConversation } from './acp-conversation-copy' +import type { AcpStructuredConnection } from './acp-structured-connection' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' + +/** Copy children have their own cleanup lifecycle; they never gate the original chat's start. */ +export class AcpConversationCopies { + private readonly children = new ProviderAcquisitionStarts() + private readonly shutdown = new AbortController() + private readonly pending = new Set>() + + constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {} + + copy: NonNullable = async (input) => { + const copy = copyAcpConversation(this.deps, input, { + signal: this.shutdown.signal, + track: (connection) => this.children.retainFailed(randomUUID(), connection) + }) + this.pending.add(copy) + try { + return await copy + } finally { + this.pending.delete(copy) + } + } + + async closeAll(): Promise { + this.shutdown.abort() + await Promise.allSettled(this.pending) + const closed = await Promise.all( + this.children.failedSessionIds().map((id) => this.children.stopFailed(id)) + ) + return closed.every(Boolean) + } +} diff --git a/src/main/acp/acp-conversation-copy-lifecycle.test.ts b/src/main/acp/acp-conversation-copy-lifecycle.test.ts new file mode 100644 index 000000000000..8316508122a8 --- /dev/null +++ b/src/main/acp/acp-conversation-copy-lifecycle.test.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { acpLaunchSpecFor } from './acp-launch-specs' +import { copyAcpConversation } from './acp-conversation-copy' +import { + openAcpAdapterRig, + type FakeAcpChild, + type AcpAdapterRig +} from './acp-structured-adapter.test-support' + +vi.mock('../native-chat/session-file-resolver', () => ({ + resolveSessionFilePath: async () => '/pinned/sessions/copy.jsonl' +})) + +const spec = acpLaunchSpecFor('omp')! +const FORKS = { agentCapabilities: { sessionCapabilities: { fork: {} } } } +const input = { + identity: { + sessionId: 'chat', + workspaceId: 'workspace', + hostId: 'local', + agent: 'omp', + providerHandle: null + }, + providerSessionId: 'original', + accountHome: { variable: 'PI_CODING_AGENT_DIR', path: '/pinned' } +} +const rigs: AcpAdapterRig[] = [] +afterEach(async () => { + await Promise.all(rigs.splice(0).map((rig) => rig.adapter.closeAll())) +}) +async function open(script: (agent: FakeAcpChild['agent']) => void) { + const rig = await openAcpAdapterRig({ spec, initialize: FORKS, script }) + rigs.push(rig) + return rig +} + +function forkResponse(id: string) { + return (agent: FakeAcpChild['agent']) => + agent.on('session/fork', (frame) => agent.reply(frame, { sessionId: id })) +} + +describe('ACP copy cleanup ownership', () => { + it('retains a failed cleanup, permits another copy, and retries at shutdown', async () => { + let reply = false + const rig = await open((agent) => + agent.on('session/fork', (frame) => { + if (reply) { + agent.reply(frame, { sessionId: 'copy' }) + } + }) + ) + const connecting = rig.adapter.copyConversation(input) + await vi.waitFor(() => expect(rig.sent('session/fork')).toHaveLength(1)) + const child = rig.child() + child.proveClose = async () => false + child.agent.reply(rig.sent('session/fork')[0]!, { sessionId: 'copy' }) + await expect(connecting).resolves.toHaveProperty('sessionId', 'copy') + reply = true + await expect(rig.adapter.copyConversation(input)).resolves.toHaveProperty('sessionId', 'copy') + expect(child.closes).toBe(1) + child.proveClose = async () => { + child.exit() + return true + } + await rig.adapter.closeAll() + expect(child.closes).toBe(2) + }) + + it('rederives a retained child exit before shutdown instead of retrying a dead child', async () => { + const rig = await open(() => {}) + const copying = rig.adapter.copyConversation(input) + await vi.waitFor(() => expect(rig.sent('session/fork')).toHaveLength(1)) + const child = rig.child() + child.proveClose = async () => false + child.agent.reply(rig.sent('session/fork')[0]!, { sessionId: 'copy' }) + await copying + child.exit() + await rig.adapter.closeAll() + expect(child.closes).toBe(1) + }) + + it('settles an active fork before shutdown retries its failed cleanup', async () => { + const rig = await open(() => {}) + const copying = rig.adapter.copyConversation(input) + const rejected = expect(copying).rejects.toThrow('OMP could not copy this chat') + await vi.waitFor(() => expect(rig.sent('session/fork')).toHaveLength(1)) + const child = rig.child() + child.proveClose = vi + .fn() + .mockResolvedValueOnce(false) + .mockImplementation(async () => { + child.exit() + return true + }) + await rig.adapter.closeAll() + await rejected + expect(child.closes).toBe(2) + }) + + it('rejects the source identity before handing the result to a terminal', async () => { + const rig = await open(forkResponse('original')) + await expect(rig.adapter.copyConversation(input)).rejects.toThrow( + 'OMP could not copy this chat' + ) + expect(rig.child().closes).toBe(1) + }) + + it('bounds launch resolution and never starts a late child', async () => { + let resolveLaunch: ( + launch: Awaited[0]['readLaunch']>> + ) => void = () => {} + const readLaunch = new Promise< + Awaited[0]['readLaunch']>> + >((resolve) => { + resolveLaunch = resolve + }) + const connect = vi.fn() + await expect( + copyAcpConversation({ spec, readLaunch: () => readLaunch, connect }, input, { + timeoutMs: 5, + track: () => {} + }) + ).rejects.toThrow('OMP could not copy this chat') + resolveLaunch({ + spec, + command: 'fake', + args: [], + cwd: '/repo', + env: {}, + envToDelete: [], + fullAccess: false, + resume: null + }) + await Promise.resolve() + expect(connect).not.toHaveBeenCalled() + }) + + it('shutdown during launch resolution prevents any child even when the launch resolves later', async () => { + let resolveLaunch: ( + launch: Awaited[0]['readLaunch']>> + ) => void = () => {} + const readLaunch = new Promise< + Awaited[0]['readLaunch']>> + >((resolve) => { + resolveLaunch = resolve + }) + const rig = await openAcpAdapterRig({ spec, deps: { readLaunch: () => readLaunch } }) + rigs.push(rig) + const copying = rig.adapter.copyConversation(input) + const rejected = expect(copying).rejects.toThrow('OMP could not copy this chat') + await rig.adapter.closeAll() + await rejected + resolveLaunch({ + spec, + command: 'fake', + args: [], + cwd: '/repo', + env: {}, + envToDelete: [], + fullAccess: false, + resume: null + }) + await Promise.resolve() + expect(rig.spawned).toHaveLength(0) + }) +}) diff --git a/src/main/acp/acp-conversation-copy-opencode-server.test.ts b/src/main/acp/acp-conversation-copy-opencode-server.test.ts new file mode 100644 index 000000000000..340e3eda11e1 --- /dev/null +++ b/src/main/acp/acp-conversation-copy-opencode-server.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest' +import { acpLaunchSpecFor } from './acp-launch-specs' +import { copyAcpConversation } from './acp-conversation-copy' +import { FakeAcpChild } from './acp-structured-adapter.test-support' +import { createAcpStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { structuredAgentConfiguredArgs } from '../native-chat/structured-agent-configured-args' + +const UNMANAGED = { kind: 'opencode' as const, locator: { kind: 'unmanaged' as const } } +const identity = { + sessionId: 'chat', + workspaceId: 'workspace', + hostId: 'local', + agent: 'opencode', + providerHandle: null +} +const input = { identity, providerSessionId: 'original', accountHome: UNMANAGED } +const spec = { + ...acpLaunchSpecFor('opencode')!, + account: { + ...acpLaunchSpecFor('opencode')!.account, + resolve: async () => UNMANAGED, + environment: (_home: unknown, env: Record) => env + } +} + +function readLaunch(command?: string) { + const record = { + ...agentSessionRecordFixture(), + sessionId: 'chat', + provider: 'opencode', + accountHome: UNMANAGED, + providerHandleChain: [] + } + const resolver = createAcpStructuredLaunchResolver(spec, { + store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, + readJournal: () => null, + resolveWorkspacePath: async () => '/synthetic-repo', + resolveEnvironment: async () => ({ HOME: '/synthetic-home', PATH: '/synthetic-bin' }), + resolveCommandSettings: () => ({ agentCmdOverrides: command ? { opencode: command } : {} }), + resolveCommand: () => '/synthetic-bin/opencode', + probeVersion: async (_input, supports) => supports('2.0.27') + }) + return (identity: Parameters[1]['identity']) => resolver({ identity }) +} + +function connect() { + const children: FakeAcpChild[] = [] + const connect = vi.fn((...args: ConstructorParameters) => { + const child = new FakeAcpChild(...args) + child.agent.on('initialize', (frame) => + child.agent.reply(frame, { + protocolVersion: 1, + agentCapabilities: { sessionCapabilities: { fork: {} } } + }) + ) + child.agent.on('session/fork', (frame) => child.agent.reply(frame, { sessionId: 'copy' })) + children.push(child) + return child + }) + return { connect, children } +} + +describe('OpenCode copied CLI server routing', () => { + it('already refuses a command-string server override in the real native launch resolver before copy', async () => { + const rig = connect() + await expect( + copyAcpConversation( + { + spec, + readLaunch: readLaunch('opencode --server http://synthetic-server'), + connect: rig.connect + }, + input, + { track: () => {} } + ) + ).rejects.toMatchObject({ + cause: expect.objectContaining({ + message: 'the opencode Command setting is not a runnable program' + }) + }) + expect(rig.connect).not.toHaveBeenCalled() + }) + + it.each(['--server http://synthetic-server', '--server=http://synthetic-server'])( + 'refuses actual host default arguments before making a copy: %s', + async (configured) => { + const host = { agentDefaultArgs: { opencode: configured } } + const rig = connect() + const resolveLaunchArgs = vi.fn(() => + structuredAgentConfiguredArgs('opencode', host, 'linux') + ) + await expect( + copyAcpConversation( + { spec, readLaunch: readLaunch(), connect: rig.connect, resolveLaunchArgs }, + input, + { track: () => {} } + ) + ).rejects.toThrow('Remove --server') + expect(resolveLaunchArgs).toHaveBeenCalledWith() + expect(rig.connect).not.toHaveBeenCalled() + } + ) + + it('does not treat a positional server word as a server route', async () => { + const rig = connect() + const resolveLaunchArgs = () => ['--', '--server'] + await expect( + copyAcpConversation( + { spec, readLaunch: readLaunch(), connect: rig.connect, resolveLaunchArgs }, + input, + { track: () => {} } + ) + ).resolves.toEqual({ sessionId: 'copy' }) + expect(rig.children).toHaveLength(1) + expect(rig.children[0]?.closes).toBe(1) + }) + + it('bounds a host settings read before any copy child can start', async () => { + const rig = connect() + const resolveLaunchArgs = () => new Promise(() => {}) + await expect( + copyAcpConversation( + { spec, readLaunch: readLaunch(), connect: rig.connect, resolveLaunchArgs }, + input, + { timeoutMs: 5, track: () => {} } + ) + ).rejects.toThrow('OpenCode could not copy this chat') + expect(rig.connect).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/acp/acp-conversation-copy.test.ts b/src/main/acp/acp-conversation-copy.test.ts index d2627c382703..7c6a5399b6bf 100644 --- a/src/main/acp/acp-conversation-copy.test.ts +++ b/src/main/acp/acp-conversation-copy.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { environmentForStructuredOpenCodeAccountHome } from '../opencode/opencode-structured-account-home' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import { acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' import { @@ -9,6 +10,12 @@ import { } from './acp-structured-adapter.test-support' import { copyAcpConversation } from './acp-conversation-copy' +const resolveFile = vi.hoisted(() => + vi.fn(async (): Promise => '/account-a/sessions/copy-1.jsonl') +) +vi.mock('../native-chat/session-file-resolver', () => ({ resolveSessionFilePath: resolveFile })) + +const accountHome = { variable: 'PI_CODING_AGENT_DIR', path: '/account-a' } const OMP = acpLaunchSpecFor('omp')! const OPENCODE = acpLaunchSpecFor('opencode')! const FORKS = { agentCapabilities: { loadSession: true, sessionCapabilities: { fork: {} } } } @@ -26,7 +33,7 @@ const launchOf = (spec: AcpLaunchSpec) => ({ command: `/opt/bin/${spec.command}`, args: ['acp'], cwd: '/workspace/project', - env: {}, + env: { PI_CODING_AGENT_DIR: '/account-a' }, envToDelete: [], fullAccess: false, resume: null @@ -65,10 +72,22 @@ describe('copying a chat conversation for the CLI', () => { await expect( rig.adapter.copyConversation({ identity: identity(spec.agent), - providerSessionId: PROVIDER_SESSION + providerSessionId: PROVIDER_SESSION, + accountHome: + spec.agent === 'omp' + ? accountHome + : { kind: 'opencode', locator: { kind: 'unmanaged' } } }) - ).resolves.toBe('copy-1') + ).resolves.toMatchObject({ sessionId: 'copy-1' }) + if (spec.agent === 'omp') { + expect(resolveFile).toHaveBeenCalledWith( + 'omp', + 'copy-1', + { ompSessionsDir: '/account-a/sessions' }, + expect.any(AbortSignal) + ) + } expect(rig.sent('session/fork').map((frame) => frame.params)).toEqual([ { sessionId: PROVIDER_SESSION, cwd: '/workspace/project', mcpServers: [] } ]) @@ -86,6 +105,177 @@ describe('copying a chat conversation for the CLI', () => { } ) + it('returns the matching managed account the OpenCode copy actually used', async () => { + const pinned = { + kind: 'opencode' as const, + locator: { + kind: 'managed' as const, + managedProfileId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + } + } + const environmentForAccount = vi.fn(() => ({ + XDG_DATA_HOME: '/account-a/data', + XDG_STATE_HOME: '/account-a/state', + OPENCODE_AUTH_CONTENT: '', + OPENCODE_DB: 'opencode.db' + })) + const spec = { + ...OPENCODE, + account: { + ...OPENCODE.account, + resolve: async () => ({ + kind: 'opencode' as const, + locator: { + kind: 'managed' as const, + managedProfileId: pinned.locator.managedProfileId + } + }), + environment: ( + home: Parameters[0], + env: Record + ) => { + if (!('kind' in home)) { + throw new Error('Wrong account kind') + } + return environmentForStructuredOpenCodeAccountHome(home, { + managedAccounts: { environmentForAccount, restoreOriginalEnvironment: () => {} }, + baseEnvironment: env + }) + } + } + } + const rig = await open({ + spec, + initialize: FORKS, + deps: { + readLaunch: async () => ({ + ...launchOf(spec), + env: { + XDG_DATA_HOME: '/account-a/data', + XDG_STATE_HOME: '/account-a/state', + ARBITRARY_SECRET: 'test-only', + ORCA_PANE_KEY: 'source-pane' + } + }) + }, + script: (agent) => + agent.on('session/fork', (frame) => agent.reply(frame, { sessionId: 'copy-1' })) + }) + const copy = await rig.adapter.copyConversation({ + identity: identity('opencode'), + providerSessionId: 'original', + accountHome: pinned + }) + expect(copy).toEqual({ sessionId: 'copy-1' }) + expect(copy).not.toHaveProperty('launchConfig') + expect(copy).not.toHaveProperty('env') + expect(environmentForAccount).not.toHaveBeenCalled() + }) + + it.each([ + { kind: 'opencode' as const, locator: { kind: 'unmanaged' as const } }, + { + kind: 'opencode' as const, + locator: { + kind: 'managed' as const, + managedProfileId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + } + } + ])('refuses an OpenCode account mismatch before copying', async (accountHome) => { + const spec = { + ...OPENCODE, + account: { + ...OPENCODE.account, + resolve: async () => ({ + kind: 'opencode' as const, + locator: { + kind: 'managed' as const, + managedProfileId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + } + }) + } + } + const rig = await open({ spec, initialize: FORKS }) + await expect( + rig.adapter.copyConversation({ + identity: identity('opencode'), + providerSessionId: 'original', + accountHome + }) + ).rejects.toThrow('Select the account this chat uses') + expect(rig.spawned).toEqual([]) + }) + + it('rederives an account change during fork before admitting the copy to a terminal', async () => { + const pinned = { + kind: 'opencode' as const, + locator: { + kind: 'managed' as const, + managedProfileId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + } + } + let selected = pinned + const spec = { ...OPENCODE, account: { ...OPENCODE.account, resolve: async () => selected } } + const rig = await open({ + spec, + initialize: FORKS, + script: (agent) => + agent.on('session/fork', (frame) => { + selected = { + kind: 'opencode', + locator: { kind: 'managed', managedProfileId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' } + } + agent.reply(frame, { sessionId: 'copy-created-in-a' }) + }) + }) + await expect( + rig.adapter.copyConversation({ + identity: identity('opencode'), + providerSessionId: 'original', + accountHome: pinned + }) + ).rejects.toThrow('Select the account this chat uses') + expect(rig.sent('session/fork')).toHaveLength(1) + expect(rig.child().closes).toBe(1) + }) + + it('refuses an OMP copy whose pinned transcript cannot be resolved', async () => { + resolveFile.mockResolvedValueOnce(null) + const rig = await open({ + spec: OMP, + initialize: FORKS, + script: (agent) => + agent.on('session/fork', (frame) => agent.reply(frame, { sessionId: 'copy-1' })) + }) + await expect( + rig.adapter.copyConversation({ + identity: identity('omp'), + providerSessionId: 'original', + accountHome + }) + ).rejects.toThrow('OMP could not copy this chat') + }) + + it.each(['', ' ', ' copy ', '-copy', 'copy\ncontrol', 'x'.repeat(513)])( + 'rejects a malformed provider fork identity', + async (id) => { + const rig = await open({ + spec: OPENCODE, + initialize: FORKS, + script: (agent) => + agent.on('session/fork', (frame) => agent.reply(frame, { sessionId: id })) + }) + await expect( + rig.adapter.copyConversation({ + identity: identity('opencode'), + providerSessionId: 'original', + accountHome: { kind: 'opencode', locator: { kind: 'unmanaged' } } + }) + ).rejects.toThrow('OpenCode could not copy this chat') + expect(rig.child().closes).toBe(1) + } + ) + it('copies while the chat runs, beside its own agent and without touching it', async () => { const rig = await open({ spec: OMP, @@ -98,7 +288,8 @@ describe('copying a chat conversation for the CLI', () => { await rig.adapter.copyConversation({ identity: identity('omp'), - providerSessionId: PROVIDER_SESSION + providerSessionId: PROVIDER_SESSION, + accountHome }) expect(rig.child()).not.toBe(chat) @@ -110,7 +301,11 @@ describe('copying a chat conversation for the CLI', () => { const rig = await open({ spec: OMP, initialize: { agentCapabilities: { loadSession: true } } }) await expect( - rig.adapter.copyConversation({ identity: identity('omp'), providerSessionId: 'p' }) + rig.adapter.copyConversation({ + identity: identity('omp'), + providerSessionId: 'p', + accountHome + }) ).rejects.toThrow('This OMP version cannot copy a chat. Update OMP to resume this chat') expect(rig.sent('session/fork')).toEqual([]) expect(rig.child().closes).toBe(1) @@ -127,7 +322,11 @@ describe('copying a chat conversation for the CLI', () => { }) await expect( - rig.adapter.copyConversation({ identity: identity('opencode'), providerSessionId: 'p' }) + rig.adapter.copyConversation({ + identity: identity('opencode'), + providerSessionId: 'p', + accountHome: { kind: 'opencode', locator: { kind: 'unmanaged' } } + }) ).rejects.toThrow('OpenCode could not copy this chat.') expect(rig.child().closes).toBe(1) }) @@ -146,7 +345,11 @@ describe('copying a chat conversation for the CLI', () => { } await expect( - copyAcpConversation(deps, { identity: identity('omp'), providerSessionId: 'p' }, 10) + copyAcpConversation( + deps, + { identity: identity('omp'), providerSessionId: 'p', accountHome }, + { timeoutMs: 10, track: () => {} } + ) ).rejects.toThrow('OMP could not copy this chat.') expect(children.map((child) => child.closes)).toEqual([1]) }) diff --git a/src/main/acp/acp-conversation-copy.ts b/src/main/acp/acp-conversation-copy.ts index 74c0a3867e8a..afe9ddee32a1 100644 --- a/src/main/acp/acp-conversation-copy.ts +++ b/src/main/acp/acp-conversation-copy.ts @@ -1,36 +1,65 @@ -// "Resume in New CLI" for an ACP agent whose CLI has no copy flag: the agent copies the chat's -// conversation over its chat protocol, and the terminal opens the copy with a plain resume, so the -// CLI never becomes a second writer on the conversation the chat owns. - +import { normalizeAgentProviderSession } from '../../shared/agent-session-resume' +import { + assertOpenCodeCopyUsesLocalStore, + OpenCodeCopyServerRouteError +} from '../../shared/opencode-launch-command' +// The chat protocol saves an independent conversation; its ordinary CLI opens that copy. import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { + agentSessionAccountHomesEqual, + type AgentSessionAccountHome +} from '../../shared/agent-session-account-home' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import type { AiVaultConversationCopy } from '../../shared/ai-vault-resume-preparation' +import { resolveSessionFilePath } from '../native-chat/session-file-resolver' +import { resolveOmpSessionsDir } from '../ai-vault/omp-session-root' import { AcpRpcError } from './acp-errors' import { acpAgentName } from './acp-structured-agent-definitions' import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' -/** Bounds a copy, which is one request: past it the agent is closed and the copy fails. */ +class AcpCopyAccountMismatchError extends Error { + constructor() { + super('Select the account this chat uses before opening its copy in a CLI.') + } +} + export const ACP_CONVERSATION_COPY_TIMEOUT_MS = 60_000 -/** - * Copies provider conversation `providerSessionId` of the chat `identity` names into a new one the - * agent saves, and answers the copy's id. A short-lived agent runs under the chat's own launch - * (binary, account, environment and folder), whether or not the chat's agent is running: both read - * the same saved conversation, and only the copy is written. - */ export async function copyAcpConversation( - deps: Pick, - input: { identity: AgentSessionJournalIdentity; providerSessionId: string }, - timeoutMs = ACP_CONVERSATION_COPY_TIMEOUT_MS -): Promise { + deps: Pick< + AcpStructuredSessionAdapterDeps, + 'spec' | 'readLaunch' | 'resolveLaunchArgs' | 'connect' | 'logger' + >, + input: { + identity: AgentSessionJournalIdentity + providerSessionId: string + accountHome: AgentSessionAccountHome + }, + options: { + timeoutMs?: number + signal?: AbortSignal + track: (connection: AcpStructuredConnection) => void + } +): Promise { const name = acpAgentName(deps.spec.agent) + const timeout = AbortSignal.timeout(options.timeoutMs ?? ACP_CONVERSATION_COPY_TIMEOUT_MS) + const signal = options.signal ? AbortSignal.any([timeout, options.signal]) : timeout let connection: AcpStructuredConnection | null = null - let timer: ReturnType | undefined - const timedOut = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`${name} did not answer in time.`)), timeoutMs) - }) try { - // Unsealed: it runs no turn, so it carries none of the chat's identity, and no pane's either. - const launch = await deps.readLaunch(input.identity) + signal.throwIfAborted() + if (deps.spec.agent === 'opencode') { + const args = await waitForPromiseWithSignal( + Promise.resolve(deps.resolveLaunchArgs?.() ?? []), + signal + ) + assertOpenCodeCopyUsesLocalStore(args) + } + signal.throwIfAborted() + const launch = await waitForPromiseWithSignal(deps.readLaunch(input.identity), signal) + signal.throwIfAborted() + await assertCopyAccountSelected(deps.spec, input.accountHome, launch.env, signal) + signal.throwIfAborted() connection = deps.connect( { command: launch.command, @@ -41,11 +70,44 @@ export async function copyAcpConversation( }, { clientInfo: { name: 'orca', version: '1' } } ) - return await Promise.race([ + options.track(connection) + const sessionId = await waitForPromiseWithSignal( forkAdvertised(connection, input.providerSessionId, launch.cwd), - timedOut - ]) + signal + ) + await assertCopyAccountSelected(deps.spec, input.accountHome, launch.env, signal) + const providerSession = normalizeAgentProviderSession({ key: 'session_id', id: sessionId }) + if (!providerSession || providerSession.id !== sessionId) { + throw new Error('The agent returned an invalid copied conversation identity.') + } + if (sessionId === input.providerSessionId) { + throw new Error('The agent returned the original conversation instead of a copy.') + } + const filePath = + deps.spec.agent === 'omp' + ? await resolveSessionFilePath( + 'omp', + sessionId, + { + ompSessionsDir: resolveOmpSessionsDir({ env: launch.env }) + }, + signal + ) + : undefined + if (deps.spec.agent === 'omp' && !filePath) { + throw new Error('OMP did not save the copied conversation in its pinned session directory.') + } + return { + sessionId, + ...(filePath ? { filePath } : {}) + } } catch (error) { + if ( + error instanceof AcpCopyAccountMismatchError || + error instanceof OpenCodeCopyServerRouteError + ) { + throw error + } throw new Error( error instanceof AcpRpcError && error.code === -32601 ? `This ${name} version cannot copy a chat. Update ${name} to resume this chat in a new CLI.` @@ -53,12 +115,15 @@ export async function copyAcpConversation( { cause: error } ) } finally { - clearTimeout(timer) - await connection?.close().catch(() => false) + if (connection && !(await connection.close().catch(() => false))) { + deps.logger?.warn('ACP conversation copy child exit is unproven; retained for cleanup', { + scope: 'acp-conversation-copy', + sessionId: input.identity.sessionId + }) + } } } -/** `session/fork`, asked only of an agent that advertises it; -32601 (no such method) otherwise. */ async function forkAdvertised( connection: AcpStructuredConnection, sessionId: string, @@ -70,3 +135,18 @@ async function forkAdvertised( } return connection.forkSession(sessionId, cwd) } + +/** Rederive selection at the fork boundaries; ordinary terminals still own their normal routing. */ +async function assertCopyAccountSelected( + spec: AcpStructuredSessionAdapterDeps['spec'], + home: AgentSessionAccountHome, + launchEnv: Record, + signal: AbortSignal +): Promise { + if ('kind' in home && home.kind === 'opencode') { + const selected = await waitForPromiseWithSignal(spec.account.resolve({ launchEnv }), signal) + if (!agentSessionAccountHomesEqual(home, selected)) { + throw new AcpCopyAccountMismatchError() + } + } +} diff --git a/src/main/acp/acp-structured-session-adapter-deps.ts b/src/main/acp/acp-structured-session-adapter-deps.ts index 64f4d120791c..ec2802e38f1b 100644 --- a/src/main/acp/acp-structured-session-adapter-deps.ts +++ b/src/main/acp/acp-structured-session-adapter-deps.ts @@ -25,6 +25,8 @@ export type AcpStructuredSessionAdapterDeps = { }) => Promise /** The same launch for a read that starts no child (restart recovery): the agent's env, unsealed. */ readLaunch: (identity: AgentSessionJournalIdentity) => Promise + /** Current host arguments before protocol-specific filtering; copies open through the CLI. */ + resolveLaunchArgs?: () => Promise | string[] /** The chat's journal as it stands, read without opening it; null when it has none. */ readJournal?: (sessionId: string) => JournalLoad | null /** Starts the agent's process and owns its protocol: `createAcpAgentConnection` in production. */ diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index e5eaa9062060..15704a1e58ae 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -43,22 +43,25 @@ import { import { readAcpLiveOptions, writeAcpSessionOption } from './acp-structured-options' import { readAcpRecoveryHistory } from './acp-recovery-history' import { stopAcpChildren, acpChildStopCapabilities } from './acp-structured-child-stop' -import { copyAcpConversation } from './acp-conversation-copy' +import { AcpConversationCopies } from './acp-conversation-copies' export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter { /** Live children, and ones whose exit is not yet proven; a proven exit removes its entry. */ private readonly sessions = new Map() private readonly starts = new ProviderAcquisitionStarts() + private readonly copies: AcpConversationCopies - constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {} + constructor(private readonly deps: AcpStructuredSessionAdapterDeps) { + this.copies = new AcpConversationCopies(deps) + } /** Restart recovery's evidence; null for an agent whose own store Orca cannot read. */ - providerHistoryWindow: NonNullable = ({ - identity - }) => readAcpRecoveryHistory(this.deps, identity) + providerHistoryWindow: NonNullable = ( + input + ) => readAcpRecoveryHistory(this.deps, input.identity) copyConversation: NonNullable = (input) => - copyAcpConversation(this.deps, input) + this.copies.copy(input) // The child runs on this runtime's own machine; Windows needs process start-time proof. supportsLocation = (location: AgentSessionExecutionLocation): boolean => @@ -252,10 +255,11 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte forceCloseSession = (sessionId: string): Promise => this.stop(sessionId, false) async closeAll(): Promise { + const copiedChildren = this.copies.closeAll() const ids = new Set([...this.sessions.keys(), ...this.starts.failedSessionIds()]) // A start still under way is the host's to abort: its teardown does, before this runs. const proven = await Promise.all([...ids].map((sessionId) => this.stop(sessionId, true))) - if (proven.includes(false)) { + if (!(await copiedChildren) || proven.includes(false)) { throw new Error('an ACP agent child could not be proven stopped') } } diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts index 975b9e90211e..4c34299b8c0a 100644 --- a/src/main/ai-vault/runtime-session-scanner.test.ts +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -193,7 +193,10 @@ describe('runtime AI Vault session scanner', () => { ok: true, result: { useRealCodexHome: false, - forkedSession: { sessionId: 'ses_copy1', agent: 'opencode' } + forkedSession: { + sessionId: 'ses_copy1', + agent: 'opencode' + } } }) @@ -208,8 +211,33 @@ describe('runtime AI Vault session scanner', () => { }) ).resolves.toEqual({ useRealCodexHome: false, - forkedSession: { sessionId: 'ses_copy1', agent: 'opencode' } + forkedSession: { + sessionId: 'ses_copy1', + agent: 'opencode' + } + }) + }) + + it('keeps the copied OMP transcript path returned by its host', async () => { + const forkedSession = { + sessionId: 'omp-copy', + agent: 'omp' as const, + filePath: '/pinned-omp/sessions/copy.jsonl' + } + mocks.callRuntimeEnvironment.mockResolvedValueOnce({ + ok: true, + result: { useRealCodexHome: false, forkedSession } }) + await expect( + prepareRuntimeAiVaultSessionResume('/user-data', 'env-1', { + agent: 'omp', + sessionId: 'omp-source', + filePath: '/pinned-omp/sessions/source.jsonl', + codexHome: null, + executionHostId: 'runtime:env-1', + fork: true + }) + ).resolves.toEqual({ useRealCodexHome: false, forkedSession }) }) it('fails retryably when runtime preparation returns an invalid result', async () => { diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index 1d17f273f797..b60c71ddce69 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -35,7 +35,13 @@ export type RuntimeAiVaultScanOptions = { const aiVaultPrepareSessionResumeResultSchema = z.object({ useRealCodexHome: z.boolean(), substituteCodexHome: z.string().optional(), - forkedSession: z.object({ sessionId: z.string(), agent: z.enum(AI_VAULT_AGENTS) }).optional() + forkedSession: z + .object({ + sessionId: z.string(), + agent: z.enum(AI_VAULT_AGENTS), + filePath: z.string().optional() + }) + .optional() }) export function getSavedRuntimeAiVaultHostInfos( diff --git a/src/main/ai-vault/structured-chat-conversation-copy.test.ts b/src/main/ai-vault/structured-chat-conversation-copy.test.ts index 0414fc8d136b..2759c2de6250 100644 --- a/src/main/ai-vault/structured-chat-conversation-copy.test.ts +++ b/src/main/ai-vault/structured-chat-conversation-copy.test.ts @@ -1,3 +1,7 @@ +import { + agentSessionRecordFixture, + agentSessionLeaseFixture +} from '../../shared/agent-session-record.test-fixture' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionProviderHandle } from '../../shared/agent-session-provider-handle' import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation' @@ -36,7 +40,10 @@ describe('copying a chat conversation for Resume in New CLI', () => { ])( "has the chat's host copy a $agent row's conversation", async ({ agent, handle, sessionId }) => { - const copy = vi.fn(async () => 'copy-1') + const copy = vi.fn(async () => ({ + sessionId: 'copy-1', + filePath: '/sessions/copy-1.jsonl' + })) installChat(handle, copy) await expect( @@ -44,7 +51,11 @@ describe('copying a chat conversation for Resume in New CLI', () => { ).resolves.toEqual({ useRealCodexHome: false, // The copy opens with the CLI that ran the chat: `opencode` for an OpenCode 2.x row. - forkedSession: { sessionId: 'copy-1', agent: handle.agent } + forkedSession: { + sessionId: 'copy-1', + filePath: '/sessions/copy-1.jsonl', + agent: handle.agent + } }) expect(copy).toHaveBeenCalledWith({ identity: { @@ -54,13 +65,38 @@ describe('copying a chat conversation for Resume in New CLI', () => { agent: handle.agent, providerHandle: handle }, - providerSessionId: sessionId + providerSessionId: sessionId, + accountHome: expect.any(Object) }) } ) + it.each(['source', 'owned'])('rejects a %s fork id on the execution host', async (returned) => { + const lease = agentSessionLeaseFixture() + const fixture = agentSessionRecordFixture(lease) + const copy = vi.fn(async () => ({ + sessionId: returned === 'source' ? OMP_ID : 'other-owned' + })) + installChat(acp('omp', OMP_ID), copy, [ + { + ...fixture, + sessionId: 'other-chat', + provider: 'omp', + providerHandleChain: [ + { ...fixture.providerHandleChain[0]!, handle: acp('omp', 'other-owned') } + ] + } + ]) + await expect(copyStructuredChatConversation(fork({}))).rejects.toThrow( + 'already owned by a chat' + ) + }) + it('leaves a resume, and a fork its CLI makes, to the usual preparation', async () => { - const copy = vi.fn(async () => 'copy-1') + const copy = vi.fn(async () => ({ + sessionId: 'copy-1', + filePath: '/sessions/copy-1.jsonl' + })) installChat(acp('omp', OMP_ID), copy) await expect(copyStructuredChatConversation(fork({ fork: false }))).resolves.toBeNull() @@ -71,7 +107,10 @@ describe('copying a chat conversation for Resume in New CLI', () => { }) it('refuses when no chat owns the row any more, never copying another', async () => { - const copy = vi.fn(async () => 'copy-1') + const copy = vi.fn(async () => ({ + sessionId: 'copy-1', + filePath: '/sessions/copy-1.jsonl' + })) installChat(acp('omp', OMP_ID), copy) await expect( diff --git a/src/main/ai-vault/structured-chat-conversation-copy.ts b/src/main/ai-vault/structured-chat-conversation-copy.ts index 71badd861a50..275f8f1d6914 100644 --- a/src/main/ai-vault/structured-chat-conversation-copy.ts +++ b/src/main/ai-vault/structured-chat-conversation-copy.ts @@ -26,7 +26,7 @@ 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.') } - const forkedSessionId = await copy.call(host.deps.adapter, { + const forkedSession = await copy.call(host.deps.adapter, { identity: { sessionId: record.sessionId, workspaceId: record.location.workspaceId, @@ -35,12 +35,19 @@ export async function copyStructuredChatConversation( providerHandle: agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle ?? null }, - providerSessionId: ownership.providerSessionId + providerSessionId: ownership.providerSessionId, + accountHome: record.accountHome }) + if ( + forkedSession.sessionId === ownership.providerSessionId || + findResumeOwnership({ agent: args.agent, sessionId: forkedSession.sessionId, filePath: '' }) + ) { + throw new Error('The agent returned a conversation already owned by a chat instead of a copy.') + } return { useRealCodexHome: false, forkedSession: { - sessionId: forkedSessionId, + ...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 } diff --git a/src/main/ai-vault/structured-session-ownership.test-support.ts b/src/main/ai-vault/structured-session-ownership.test-support.ts index 06fa2a9f9f3c..f1b8048549b9 100644 --- a/src/main/ai-vault/structured-session-ownership.test-support.ts +++ b/src/main/ai-vault/structured-session-ownership.test-support.ts @@ -1,3 +1,4 @@ +import type { AgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionLeaseFixture, agentSessionRecordFixture @@ -64,7 +65,8 @@ export function installOwnership( * conversation through `copy`. */ export function installCopyingChat( handle: AgentSessionProviderHandle, - copy: StructuredAgentSessionAdapter['copyConversation'] + copy: StructuredAgentSessionAdapter['copyConversation'], + otherRecords: AgentSessionRecord[] = [] ): void { const lease = { ...agentSessionLeaseFixture(), sessionId: 'chat-1' } const fixture = agentSessionRecordFixture(lease) @@ -80,7 +82,7 @@ export function installCopyingChat( setStructuredAgentSessionHost({ deps: { store: { - listRecords: () => [record], + listRecords: () => [record, ...otherRecords], getRecord: (sessionId: string) => (sessionId === record.sessionId ? record : null) }, adapter: { copyConversation: copy } diff --git a/src/main/ipc/ai-vault-resume-chat-copy.test.ts b/src/main/ipc/ai-vault-resume-chat-copy.test.ts index 1d7ded4a18ad..908f6b9d08b9 100644 --- a/src/main/ipc/ai-vault-resume-chat-copy.test.ts +++ b/src/main/ipc/ai-vault-resume-chat-copy.test.ts @@ -23,7 +23,10 @@ afterEach(() => setStructuredAgentSessionHost(null)) describe('Resume in New CLI preparation for a chat whose host makes the copy', () => { it("has this machine's chat make the copy instead of preparing a home", async () => { - const copy = vi.fn(async () => 'copy-1') + const copy = vi.fn(async () => ({ + sessionId: 'copy-1', + filePath: '/sessions/copy-1.jsonl' + })) installCopyingChat({ transport: 'acp', agent: 'omp', nativeId: OMP_ID }, copy) const prepareSessionResume = vi.fn() @@ -31,24 +34,38 @@ describe('Resume in New CLI preparation for a chat whose host makes the copy', ( prepareAiVaultSessionResume(forkArgs('local'), { prepareSessionResume }) ).resolves.toEqual({ useRealCodexHome: false, - forkedSession: { sessionId: 'copy-1', agent: 'omp' } + forkedSession: { + sessionId: 'copy-1', + filePath: '/sessions/copy-1.jsonl', + agent: 'omp' + } }) expect(prepareSessionResume).not.toHaveBeenCalled() }) it('leaves the copy to the server that runs a remote chat', async () => { - const copy = vi.fn(async () => 'local-copy') + const copy = vi.fn(async () => ({ + sessionId: 'local-copy' + })) installCopyingChat({ transport: 'acp', agent: 'omp', nativeId: 'unrelated' }, copy) const prepareRuntimeSessionResume = vi.fn(async () => ({ useRealCodexHome: false, - forkedSession: { sessionId: 'remote-copy', agent: 'omp' as const } + forkedSession: { + sessionId: 'remote-copy', + filePath: '/sessions/remote-copy.jsonl', + agent: 'omp' as const + } })) await expect( prepareAiVaultSessionResume(forkArgs('runtime:env-1'), { prepareRuntimeSessionResume }) ).resolves.toEqual({ useRealCodexHome: false, - forkedSession: { sessionId: 'remote-copy', agent: 'omp' } + forkedSession: { + sessionId: 'remote-copy', + filePath: '/sessions/remote-copy.jsonl', + agent: 'omp' + } }) expect(prepareRuntimeSessionResume).toHaveBeenCalledWith('env-1', forkArgs('runtime:env-1')) expect(copy).not.toHaveBeenCalled() diff --git a/src/main/ipc/pty/managed-data-account-spawn-environment.test.ts b/src/main/ipc/pty/managed-data-account-spawn-environment.test.ts index ed13aa02de94..6e2127675cf4 100644 --- a/src/main/ipc/pty/managed-data-account-spawn-environment.test.ts +++ b/src/main/ipc/pty/managed-data-account-spawn-environment.test.ts @@ -70,6 +70,35 @@ async function spawnDeletions( } describe.each(['renderer', 'runtime'])('%s managed account daemon environment', (route) => { + it('keeps the selected account B for an ordinary explicit account-shaped environment', async () => { + selected.value = { + XDG_DATA_HOME: '/account-b/data', + XDG_STATE_HOME: '/account-b/state', + OPENCODE_AUTH_CONTENT: '', + OPENCODE_DB: 'opencode.db' + } + const env = { + XDG_DATA_HOME: '/explicit-account-a/data', + XDG_STATE_HOME: '/explicit-account-a/state', + OPENCODE_AUTH_CONTENT: '', + OPENCODE_DB: 'opencode.db' + } + applyManagedDataAccountEnvironment(env, { launchAgent: 'opencode' }) + const envToDelete = await spawnDeletions(route, env) + const child = createDaemonPtyEnvironment({ + sessionId: 'pane', + cols: 80, + rows: 24, + env, + envToDelete + }) + expect(child).toMatchObject({ + ...selected.value, + ORCA_DATA_ACCOUNT_DATA_HOME: '/account-b/data', + ORCA_DATA_ACCOUNT_STATE_HOME: '/account-b/state' + }) + }) + it('honors explicit user deletions and preserves unknown daemon metadata', async () => { const env: Record = {} const envToDelete = await spawnDeletions(route, env, ['XDG_STATE_HOME']) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 49c8e48b3976..664cb423838b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -1,3 +1,4 @@ +import type { AiVaultConversationCopy } from '../../../shared/ai-vault-resume-preparation' import type { AgentSessionAccountKind, AgentSessionUnavailable @@ -445,7 +446,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & copyConversation?(input: { identity: AgentSessionJournalIdentity providerSessionId: string - }): Promise + accountHome: AgentSessionAccountHome + }): Promise /** Gracefully stops the structured owner after its event stream is drained. */ /** Returns true only after the provider child exit is proven. A root-exit or processless verdict * is thrown only once the session is finalized; read it through `stopAgentSessionProviderRoot`. */ diff --git a/src/main/native-chat/structured-agent-configured-args.test.ts b/src/main/native-chat/structured-agent-configured-args.test.ts index 58823a406088..4daf3556527d 100644 --- a/src/main/native-chat/structured-agent-configured-args.test.ts +++ b/src/main/native-chat/structured-agent-configured-args.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { structuredAgentConfiguredArgs } from './structured-agent-configured-args' describe('structured chat configured Arguments', () => { - it.each(['claude', 'codex'] as const)( + it.each(['claude', 'codex', 'opencode', 'omp', 'grok'] as const)( 'reads the existing %s setting and preserves quoted values', (agent) => { expect( diff --git a/src/main/native-chat/structured-agent-configured-args.ts b/src/main/native-chat/structured-agent-configured-args.ts index 3b60c7642680..49aac2e398bd 100644 --- a/src/main/native-chat/structured-agent-configured-args.ts +++ b/src/main/native-chat/structured-agent-configured-args.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from '../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../shared/tui-agent' import type { GlobalSettings } from '../../shared/global-settings-types' import { resolveTuiAgentLaunchArgs } from '../../shared/tui-agent-launch-defaults' import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' @@ -6,7 +6,7 @@ import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-termi /** Use the same grouping rules as terminal launches before the provider filters its owned flags. */ export function structuredAgentConfiguredArgs( - agent: AgentSessionHandleProvider, + agent: TuiAgent, settings: Partial>, platform: NodeJS.Platform = process.platform ): string[] { diff --git a/src/main/runtime/orca-runtime-conversation-copy-resume.test.ts b/src/main/runtime/orca-runtime-conversation-copy-resume.test.ts new file mode 100644 index 000000000000..582dc973e632 --- /dev/null +++ b/src/main/runtime/orca-runtime-conversation-copy-resume.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { EnsureAgentSessionParams } from '../../shared/rpc-contract/agent-session-params' + +function runtime() { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: explicit resume reads only these settings; terminal creation is mocked. + const runtime = new OrcaRuntimeService({ + getSettings: () => ({ + agentDefaultArgs: { opencode: '--model host-model' }, + agentDefaultEnv: { + opencode: { + OPENCODE_AUTH_CONTENT: 'synthetic-host-auth', + HOME: '/host-home', + OPENCODE_CONFIG_DIR: '/host-config' + }, + omp: { PI_CODING_AGENT_DIR: '/current-omp' } + } + }) + } as never) + Object.assign(runtime, { + resolveTerminalWorkspaceLaunchScope: async () => ({ + id: 'worktree', + path: '/repo', + connectionId: null + }), + getAgentLaunchPlatformForWorkspace: () => 'linux' + }) + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: 'term_copy', + tabId: 'copy-tab', + paneKey: 'copy-tab:copy-leaf', + ptyId: 'copy-pty', + worktreeId: 'worktree', + title: null, + surface: 'background' + }) + return { runtime, createTerminal } +} + +describe('ordinary host resume of a copied conversation', () => { + it('uses the actual OpenCode executable and preserves private mode plus host custom environment', async () => { + const rig = runtime() + await rig.runtime.ensureAgentSession( + EnsureAgentSessionParams.parse({ + kind: 'explicit', + worktree: 'id:worktree', + agent: 'opencode', + agentArgs: '--standalone', + providerSession: { key: 'session_id', id: 'copied-session' } + }) + ) + expect(rig.createTerminal).toHaveBeenCalledWith( + 'id:worktree', + expect.objectContaining({ + launchAgent: 'opencode', + command: "opencode '--standalone' '--session' 'copied-session'", + env: { + OPENCODE_AUTH_CONTENT: 'synthetic-host-auth', + HOME: '/host-home', + OPENCODE_CONFIG_DIR: '/host-config' + } + }) + ) + }) + + it('opens the OMP copy by its explicit file using the ordinary host environment', async () => { + const rig = runtime() + await rig.runtime.ensureAgentSession( + EnsureAgentSessionParams.parse({ + kind: 'explicit', + worktree: 'id:worktree', + agent: 'omp', + ompResumeFilePath: '/pinned-omp/sessions/copied.jsonl', + providerSession: { key: 'session_id', id: 'copied-session' } + }) + ) + expect(rig.createTerminal).toHaveBeenCalledWith( + 'id:worktree', + expect.objectContaining({ + launchAgent: 'omp', + command: expect.stringContaining("'--resume' '/pinned-omp/sessions/copied.jsonl'"), + env: { PI_CODING_AGENT_DIR: '/current-omp' } + }) + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/ai-vault-copy-server-route.test.ts b/src/main/runtime/rpc/methods/ai-vault-copy-server-route.test.ts new file mode 100644 index 000000000000..40dd63e704da --- /dev/null +++ b/src/main/runtime/rpc/methods/ai-vault-copy-server-route.test.ts @@ -0,0 +1,77 @@ +import '../unused-default-rpc-methods.test-fixture' +import { afterEach, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import { AI_VAULT_METHODS } from './ai-vault' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { acpLaunchSpecFor } from '../../../acp/acp-launch-specs' +import { copyAcpConversation } from '../../../acp/acp-conversation-copy' +import { structuredAgentConfiguredArgs } from '../../../native-chat/structured-agent-configured-args' +import { installCopyingChat } from '../../../ai-vault/structured-session-ownership.test-support' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' + +afterEach(() => setStructuredAgentSessionHost(null)) + +it('refuses a paired-host server setting before making a copy, even when no client server flag is sent', async () => { + const hostSettings = createGlobalSettingsFixture({ + agentDefaultArgs: { opencode: '--server http://synthetic-host-server' } + }) + const resolveLaunchArgs = vi.fn(() => + structuredAgentConfiguredArgs('opencode', hostSettings, 'linux') + ) + const connect = vi.fn() + const spec = acpLaunchSpecFor('opencode')! + const providerSessionId = 'ses_2f9aB3cD4eF5gH6iJ7kL8mN9oP' + installCopyingChat( + { transport: 'acp', agent: 'opencode', nativeId: providerSessionId }, + (input) => + copyAcpConversation( + { + spec, + resolveLaunchArgs, + connect, + readLaunch: async () => ({ + spec, + command: '/synthetic-bin/opencode', + args: ['acp'], + cwd: '/host-repo', + env: {}, + envToDelete: [], + fullAccess: false, + resume: null + }) + }, + input, + { track: () => {} } + ) + ) + const prepareAiVaultSessionResume = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this RPC handler reads only these runtime methods; no terminal or scanner is entered. + const runtime = { + getRuntimeId: () => 'host-runtime', + ensureStructuredAgentSessionHost: async () => undefined, + prepareAiVaultSessionResume + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) + const response = await dispatcher.dispatch({ + id: 'copy-request', + authToken: 'synthetic-token', + method: 'aiVault.prepareSessionResume', + params: { + agent: 'opencode2', + sessionId: providerSessionId, + filePath: '/host-store/opencode.db', + codexHome: null, + executionHostId: 'runtime:client', + fork: true + } + }) + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('Remove --server') } + }) + expect(resolveLaunchArgs).toHaveBeenCalledOnce() + expect(connect).not.toHaveBeenCalled() + expect(prepareAiVaultSessionResume).not.toHaveBeenCalled() + expect(JSON.stringify(response)).not.toContain('synthetic-host-server') +}) diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index 165c0386b90b..cea3f989ed55 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -259,7 +259,10 @@ describe('aiVault.prepareSessionResume for a chat whose host makes the copy', () it("has the server's chat copy an OMP conversation and answers the copy", async () => { const providerSessionId = '019fd611-2b3c-7d4e-9f50-6b7c8d9e0f1a' - const copy = vi.fn(async () => 'copy-1') + const copy = vi.fn(async () => ({ + sessionId: 'copy-1', + filePath: '/sessions/copy-1.jsonl' + })) installCopyingChat({ transport: 'acp', agent: 'omp', nativeId: providerSessionId }, copy) const prepareAiVaultSessionResume = vi.fn() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only these runtime members. @@ -282,7 +285,14 @@ describe('aiVault.prepareSessionResume for a chat whose host makes the copy', () expect(response).toMatchObject({ ok: true, - result: { useRealCodexHome: false, forkedSession: { sessionId: 'copy-1', agent: 'omp' } } + result: { + useRealCodexHome: false, + forkedSession: { + sessionId: 'copy-1', + filePath: '/sessions/copy-1.jsonl', + agent: 'omp' + } + } }) expect(copy).toHaveBeenCalledWith(expect.objectContaining({ providerSessionId })) expect(prepareAiVaultSessionResume).not.toHaveBeenCalled() diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index a770451e54ae..a062e7620949 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -241,6 +241,7 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio readJournal, resolveLaunch: composeStructuredLaunch(definition, sources, part), readLaunch: composeStructuredLaunchRead(definition, sources, part), + resolveLaunchArgs: () => deps.resolveLaunchArgs(spec.agent), connect: (launch, options) => createAcpAgentConnection(launch, options), onChildWorkEvidence: (sessionId, evidence) => context.host()?.publishChildWorkEvidence(sessionId, evidence), diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.test.ts index e9c9421e6ff7..3ed7f3c7294b 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.test.ts @@ -1,3 +1,4 @@ +import { OpenCodeCopyServerRouteError } from '../../../../shared/opencode-launch-command' import { describe, expect, it } from 'vitest' import { aiVaultSessionCliForkWorktreeId, @@ -81,6 +82,15 @@ describe('Resume in New CLI failure text', () => { expect(describeAiVaultCliForkFailure(message)).toBe(UPDATE) }) + it('keeps server refusal feedback plain when Electron wraps the host error', () => { + const message = new OpenCodeCopyServerRouteError().message + expect( + describeAiVaultCliForkFailure( + `Error invoking remote method 'aiVault:prepareSessionResume': Error: ${message}` + ) + ).toBe(message) + }) + it('passes any other failure through unchanged', () => { const message = 'The session host is unavailable. Reconnect it and retry resume.' expect(describeAiVaultCliForkFailure(message)).toBe(message) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts index a3183f900521..b2a2d40c0f3e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts @@ -1,3 +1,4 @@ +import { compactIpcErrorMessage } from '@/lib/ipc-error' import { isAiVaultOwnershipRefusal } from '@/lib/ai-vault-resume-refusal' import { aiVaultCliForkHostTooOldMessage } from '@/lib/ai-vault-session-resume-preparation' import type { AiVaultSession } from '../../../../shared/ai-vault-types' @@ -30,7 +31,8 @@ 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 isAiVaultOwnershipRefusal(message) ? aiVaultCliForkHostTooOldMessage() - : message + : (code ?? message) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts index 4520b4e3d7af..d57d04d95044 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts @@ -1,3 +1,4 @@ +import type * as ForkStartup from '@/lib/ai-vault-session-fork-startup' // @vitest-environment happy-dom import { renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -6,6 +7,7 @@ import type { WebRuntimeTerminalCreateOutcome } from '@/runtime/web-runtime-sess const mocks = vi.hoisted(() => ({ buildAiVaultForkStartupForWorktree: vi.fn(), + getState: vi.fn(), buildAiVaultResumeStartupForWorktree: vi.fn(), prepareAiVaultSessionForFork: vi.fn(), prepareAiVaultSessionForResume: vi.fn(), @@ -16,10 +18,16 @@ const mocks = vi.hoisted(() => ({ toastSuccess: vi.fn() })) -vi.mock('@/lib/ai-vault-session-fork-startup', () => ({ +vi.mock('@/lib/ai-vault-session-fork-startup', async (importOriginal) => ({ + ...(await importOriginal()), + buildAiVaultCopiedSessionStartupForWorktree: (args: { session: AiVaultSession }) => ({ + ...mocks.buildAiVaultResumeStartupForWorktree(args), + agent: args.session.agent + }), buildAiVaultForkStartupForWorktree: mocks.buildAiVaultForkStartupForWorktree })) vi.mock('@/lib/ai-vault-resume-command', () => ({ + resolveAiVaultResumeHost: () => ({ platform: 'linux', codexHome: null, liveShell: undefined }), buildAiVaultResumeStartupForWorktree: mocks.buildAiVaultResumeStartupForWorktree, buildAiVaultResumeCopyCommandForWorktree: vi.fn() })) @@ -48,7 +56,7 @@ vi.mock('./ai-vault-session-resume-in-chat-launch', () => ({ })) vi.mock('sonner', () => ({ toast: { error: mocks.toastError, success: mocks.toastSuccess } })) vi.mock('@/store', () => ({ - useAppStore: { getState: () => ({ activeWorktreeId: 'worktree-1', settings: {} }) } + useAppStore: { getState: mocks.getState } })) import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-actions' @@ -79,11 +87,12 @@ const owned: AiVaultSession = { const FORK_STARTUP = { command: "claude --resume 'provider-1' --fork-session", cwd: '/repo' } -function renderActions() { +function renderActions(agentCmdOverrides?: { opencode?: string }) { return renderHook(() => useAiVaultSessionLaunchActions({ activeWorktree: null, activeWorktreeId: 'worktree-1', + agentCmdOverrides, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked launch target never reads it. targetState: {} as never }) @@ -93,6 +102,7 @@ function renderActions() { describe('Resume in New CLI launch', () => { beforeEach(() => { vi.clearAllMocks() + mocks.getState.mockReturnValue({ activeWorktreeId: 'worktree-1', settings: {} }) mocks.prepareAiVaultSessionForFork.mockImplementation(async (session) => ({ session, copied: false @@ -101,6 +111,44 @@ describe('Resume in New CLI launch', () => { mocks.launchAiVaultSessionInNewTab.mockReturnValue({ tabId: 'tab-1' }) }) + it.each(['command', 'arguments'] as const)( + 'refuses effective OpenCode %s server routing before asking for any copy', + async (location) => { + const chat = { ...owned, agent: 'opencode2' as const } + mocks.getState.mockReturnValue({ + activeWorktreeId: 'worktree-1', + settings: { + agentDefaultArgs: + location === 'arguments' ? { opencode: '--server=http://synthetic-server' } : {} + } + }) + renderActions( + location === 'command' + ? { opencode: 'opencode --server http://synthetic-server' } + : undefined + ).handleResumeInNewCli(chat, 'worktree-2') + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1)) + expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('Remove --server')) + expect(mocks.prepareAiVaultSessionForFork).not.toHaveBeenCalled() + expect(mocks.launchAiVaultSessionInNewTab).not.toHaveBeenCalled() + } + ) + + it('leaves ordinary resumes on their configured server route', async () => { + const session = { ...owned, agent: 'opencode' as const, structuredSession: undefined } + mocks.getState.mockReturnValue({ + activeWorktreeId: 'worktree-1', + settings: { agentDefaultArgs: { opencode: '--server http://synthetic-server' } } + }) + mocks.prepareAiVaultSessionForResume.mockResolvedValue(session) + mocks.buildAiVaultResumeStartupForWorktree.mockReturnValue({ + command: 'opencode --server http://synthetic-server --session existing' + }) + renderActions().handleResume(session, 'worktree-2') + await waitFor(() => expect(mocks.launchAiVaultSessionInNewTab).toHaveBeenCalledTimes(1)) + expect(mocks.toastError).not.toHaveBeenCalled() + }) + it('opens the fork plan in the chosen workspace, never the plain resume plan', async () => { renderActions().handleResumeInNewCli(owned, 'worktree-2') @@ -142,6 +190,42 @@ describe('Resume in New CLI launch', () => { expect(mocks.toastError).not.toHaveBeenCalled() }) + it('launches an OpenCode 2 copy as its actual opencode provider', async () => { + const chat = { ...owned, agent: 'opencode2' as const } + const copy = { + ...chat, + agent: 'opencode' as const, + sessionId: 'copy-1', + filePath: '', + resumeCommand: '' + } + const launchConfig = { + agentArgs: '', + agentEnv: { XDG_DATA_HOME: '/account-a/data', XDG_STATE_HOME: '/account-a/state' } + } + mocks.prepareAiVaultSessionForFork.mockResolvedValue({ + session: copy, + copied: true + }) + mocks.buildAiVaultResumeStartupForWorktree.mockReturnValue({ + command: 'opencode --session copy-1', + launchConfig, + providerSession: { key: 'session_id', id: 'copy-1' } + }) + renderActions().handleResumeInNewCli(chat, 'worktree-2') + await waitFor(() => expect(mocks.launchAiVaultSessionInNewTab).toHaveBeenCalledTimes(1)) + expect(mocks.buildAiVaultResumeStartupForWorktree).toHaveBeenCalledWith( + expect.objectContaining({ session: copy }) + ) + expect(mocks.launchAiVaultSessionInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ + agent: 'opencode', + launchConfig, + providerSession: { key: 'session_id', id: 'copy-1' } + }) + ) + }) + it('shows one toast and opens nothing when the agent cannot fork', async () => { mocks.buildAiVaultForkStartupForWorktree.mockReturnValue(null) 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 a01119ab6036..8baee0b389fc 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,3 +1,7 @@ +import { + notifyAiVaultSessionPreparationFailure, + notifyAiVaultSessionQueued +} from './ai-vault-session-launch-feedback' import { useCallback, useState } from 'react' import { toast } from 'sonner' import { @@ -6,7 +10,11 @@ import { type AiVaultResumeCommandSession, type AiVaultResumeStartup } from '@/lib/ai-vault-resume-command' -import { buildAiVaultForkStartupForWorktree } from '@/lib/ai-vault-session-fork-startup' +import { + assertAiVaultCopiedSessionLaunchAllowed, + buildAiVaultCopiedSessionStartupForWorktree, + buildAiVaultForkStartupForWorktree +} from '@/lib/ai-vault-session-fork-startup' import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session' import { useAppStore } from '@/store' import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types' @@ -26,7 +34,6 @@ 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 @@ -111,15 +118,6 @@ export function useAiVaultSessionLaunchActions({ if (!targetId) { return } - const showQueuedToast = (): void => { - toast.success( - translate( - 'auto.components.right.sidebar.AiVaultPanel.agentSessionQueued', - '{{value0}} session queued', - { value0: agentLabel(session.agent) } - ) - ) - } void prepareStartup(targetId.worktreeId) .then((startup) => { const launchResult = launchAiVaultSessionInNewTab({ @@ -143,14 +141,14 @@ export function useAiVaultSessionLaunchActions({ if (useAppStore.getState().activeWorktreeId !== targetId.worktreeId) { activateAiVaultResumeWorkspace(targetId.worktreeId) } - showQueuedToast() + notifyAiVaultSessionQueued(session.agent) }) return } if (useAppStore.getState().activeWorktreeId !== targetId.worktreeId) { activateAiVaultResumeWorkspace(targetId.worktreeId) } - showQueuedToast() + notifyAiVaultSessionQueued(session.agent) }) .catch((error: unknown) => notifyAiVaultSessionPreparationFailure(error, session.agent, describeFailure) @@ -182,11 +180,25 @@ export function useAiVaultSessionLaunchActions({ const handleResumeInNewCli = useCallback( (session: AiVaultSession, targetWorktreeId: string): void => { const prepareStartup = async (worktreeId: string): Promise => { + assertAiVaultCopiedSessionLaunchAllowed({ + state: useAppStore.getState(), + worktreeId, + session, + commandOverride: agentCmdOverrides?.opencode + }) const prepared = await prepareAiVaultSessionForFork(session) const target = await dropDeletedSshResumeCwd(prepared.session) // A copy the chat's host already made is the terminal's own conversation: a plain resume. const startup = prepared.copied - ? buildResumeStartup(target, worktreeId) + ? buildAiVaultCopiedSessionStartupForWorktree( + { + state: useAppStore.getState(), + worktreeId, + session: target, + commandOverride: agentCmdOverrides?.[target.agent] + }, + session.agent + ) : buildAiVaultForkStartupForWorktree({ state: useAppStore.getState(), worktreeId, @@ -205,7 +217,7 @@ export function useAiVaultSessionLaunchActions({ } launchInTerminal(session, targetWorktreeId, prepareStartup, describeAiVaultCliForkFailure) }, - [agentCmdOverrides, buildResumeStartup, launchInTerminal] + [agentCmdOverrides, launchInTerminal] ) const handleResumeInNewChat = useCallback( @@ -288,15 +300,6 @@ export function useAiVaultSessionLaunchActions({ } } -function notifyAiVaultSessionPreparationFailure( - error: unknown, - agent: AiVaultAgent, - describeFailure: (message: string) => string = (message) => message -): void { - const described = error instanceof Error ? new Error(describeFailure(error.message)) : error - toast.error(describeAiVaultResumeFailure(described, agent)) -} - function resolveAiVaultSessionLaunchTargetOrNotify( args: Parameters[0] ): Extract, { status: 'ready' }> | null { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-feedback.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-feedback.ts new file mode 100644 index 000000000000..54e8851fe97b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-feedback.ts @@ -0,0 +1,24 @@ +import { toast } from 'sonner' +import { describeAiVaultResumeFailure } from '@/lib/ai-vault-resume-refusal' +import { translate } from '@/i18n/i18n' +import { agentLabel } from './ai-vault-session-filters' +import type { AiVaultAgent } from '../../../../shared/ai-vault-types' + +export function notifyAiVaultSessionQueued(agent: AiVaultAgent): void { + toast.success( + translate( + 'auto.components.right.sidebar.AiVaultPanel.agentSessionQueued', + '{{value0}} session queued', + { value0: agentLabel(agent) } + ) + ) +} + +export function notifyAiVaultSessionPreparationFailure( + error: unknown, + agent: AiVaultAgent, + describeFailure: (message: string) => string = (message) => message +): void { + const described = error instanceof Error ? new Error(describeFailure(error.message)) : error + toast.error(describeAiVaultResumeFailure(described, agent)) +} diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts index 17a0a9c84305..2afb05bfd7c7 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts @@ -207,8 +207,8 @@ describe('resolveTabSessionHistorySubject', () => { it('skips a chat of an agent that cannot copy its conversation', () => { expect( resolveTabSessionHistorySubject(makeState(), { - tab: { id: 'unified-1', worktreeId: WORKTREE_ID, launchAgent: 'gemini' }, - structuredSessionId: 'orca-chat-1' + tab: { id: 'unified-1', worktreeId: WORKTREE_ID }, + nativeChat: { sessionId: 'orca-chat-1', agent: 'gemini' } }) ).toBeNull() }) diff --git a/src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts b/src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts index 256858d3ad29..07cf99624a7c 100644 --- a/src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts +++ b/src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts @@ -1,3 +1,4 @@ +import { buildAiVaultCopiedSessionStartupForWorktree } from './ai-vault-session-fork-startup' import { describe, expect, it } from 'vitest' import type { AppState } from '@/store/types' import { createTestStore, makeTab } from '@/store/slices/store-test-helpers' @@ -5,6 +6,43 @@ import { buildAgentResumeStartupPlan } from './tui-agent-startup' import { buildAiVaultResumeStartupForWorktree } from './ai-vault-resume-command' describe('AI Vault OMP cold resume', () => { + it('preserves local OpenCode standalone mode and existing custom host settings', () => { + const store = createTestStore() + const state = store.getState() + Object.assign(state, { + settings: { + agentDefaultArgs: { opencode: '--model synthetic-model' }, + agentDefaultEnv: { + opencode: { + OPENCODE_AUTH_CONTENT: 'synthetic-local-auth', + HOME: '/host-home', + OPENCODE_CONFIG_DIR: '/host-config' + } + } + } + }) + const startup = buildAiVaultCopiedSessionStartupForWorktree( + { + state, + session: { + agent: 'opencode', + sessionId: 'copy-session', + cwd: '/repo', + codexHome: null, + executionHostId: 'local' + } + }, + 'opencode2' + ) + expect(startup.command).toContain("'--standalone'") + expect(startup.command).toContain("'--model' 'synthetic-model'") + expect(startup.env).toMatchObject({ + OPENCODE_AUTH_CONTENT: 'synthetic-local-auth', + HOME: '/host-home', + OPENCODE_CONFIG_DIR: '/host-config' + }) + }) + it('keeps the custom session store after the resumed process reports its id', () => { const store = createTestStore() store.setState({ diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index 4a736d19c12a..3adf28976466 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -36,6 +36,7 @@ export type AiVaultResumeCommandSession = Pick< > export type AiVaultResumeStartup = { + agent?: AiVaultSession['agent'] command: string cwd?: string env?: Record @@ -59,6 +60,7 @@ export type AiVaultResumeWorktreeArgs = { worktreeId?: string | null session: AiVaultResumeCommandSession commandOverride?: string | null + agentArgs?: string } export function buildAiVaultResumeCopyCommandForWorktree(args: AiVaultResumeWorktreeArgs): string { @@ -173,10 +175,9 @@ function buildAiVaultResumeForWorktree( }, platform, shell: liveShell, - agentArgs: resolveTuiAgentLaunchArgs( - args.session.agent, - args.state.settings?.agentDefaultArgs - ), + agentArgs: + args.agentArgs ?? + resolveTuiAgentLaunchArgs(args.session.agent, args.state.settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(args.session.agent, args.state.settings?.agentDefaultEnv), resumeInLaunchCwd: true, ...(args.session.agent === 'omp' && resumeFilePath diff --git a/src/renderer/src/lib/ai-vault-session-fork-startup.test.ts b/src/renderer/src/lib/ai-vault-session-fork-startup.test.ts index 14f21a2c8571..055c32a58a58 100644 --- a/src/renderer/src/lib/ai-vault-session-fork-startup.test.ts +++ b/src/renderer/src/lib/ai-vault-session-fork-startup.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store/types' -import { buildAiVaultForkStartupForWorktree } from './ai-vault-session-fork-startup' +import { + buildAiVaultCopiedSessionStartupForWorktree, + buildAiVaultForkStartupForWorktree +} from './ai-vault-session-fork-startup' vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'darwin' @@ -83,3 +86,48 @@ describe('AI Vault fork into the CLI', () => { ).toBeNull() }) }) + +describe('copied OpenCode 2 launch mode', () => { + const session = { + agent: 'opencode', + sessionId: 'ses_copied', + cwd: '/Users/ada/repo', + codexHome: null + } as const + + it('adds stock standalone mode once to the actual provider', () => { + const startup = buildAiVaultCopiedSessionStartupForWorktree( + { state: makeState(), session }, + 'opencode2' + ) + expect(startup.command).toBe("opencode '--standalone' '--session' 'ses_copied'") + expect(startup.launchConfig?.agentArgs).toBe("'--standalone'") + }) + + it.each(['argument', 'settings'])('lets an explicit %s executable control its mode', (source) => { + const state = makeState() + if (source === 'settings' && state.settings) { + state.settings.agentCmdOverrides = { opencode: '/synthetic/team-opencode' } + } + const startup = buildAiVaultCopiedSessionStartupForWorktree( + { + state, + session, + ...(source === 'argument' ? { commandOverride: '/synthetic/team-opencode' } : {}) + }, + 'opencode2' + ) + expect(startup.command).toBe("/synthetic/team-opencode '--session' 'ses_copied'") + expect(startup.launchConfig?.agentArgs).toBe('') + }) + + it('keeps already configured standalone without injecting it again', () => { + const state = makeState() + if (state.settings) { + state.settings.agentDefaultArgs = { opencode: '--standalone --port 4321' } + } + const startup = buildAiVaultCopiedSessionStartupForWorktree({ state, session }, 'opencode2') + expect(startup.command).toBe("opencode '--standalone' '--port' '4321' '--session' 'ses_copied'") + expect(startup.launchConfig?.agentArgs).toBe('--standalone --port 4321') + }) +}) diff --git a/src/renderer/src/lib/ai-vault-session-fork-startup.ts b/src/renderer/src/lib/ai-vault-session-fork-startup.ts index c94c08d5acc6..34b8a5d93e4b 100644 --- a/src/renderer/src/lib/ai-vault-session-fork-startup.ts +++ b/src/renderer/src/lib/ai-vault-session-fork-startup.ts @@ -1,3 +1,16 @@ +import { assertOpenCodeCopyUsesLocalStore } from '../../../shared/opencode-launch-command' +import { findOptionOccurrence } from '../../../shared/command-option-occurrence' +import { + getTuiAgentLaunchCommand, + TUI_AGENT_CONFIG, + isTuiAgent +} from '../../../shared/tui-agent-config' +import { + tokenizeStartupCommand, + resolveStartupShell, + quoteStartupArg +} from '../../../shared/tui-agent-startup-shell' +import type { AiVaultAgent } from '../../../shared/ai-vault-types' import { normalizeAiVaultResumeFilePath } from '../../../shared/ai-vault-resume-path' import { isResumableTuiAgent } from '../../../shared/agent-session-resume' import { @@ -11,6 +24,7 @@ import { import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' import { getAiVaultAgentProviderSession, + buildAiVaultResumeStartupForWorktree, resolveAiVaultResumeHost, type AiVaultResumeStartup, type AiVaultResumeWorktreeArgs @@ -66,3 +80,70 @@ export function buildAiVaultForkStartupForWorktree( launchConfig: plan.launchConfig } } + +/** Keeps the history row's launch mode while the copied conversation opens with its actual CLI. */ +export function buildAiVaultCopiedSessionStartupForWorktree( + args: AiVaultResumeWorktreeArgs, + sourceAgent: AiVaultAgent +): AiVaultResumeStartup { + const { platform, liveShell } = resolveAiVaultResumeHost(args) + const shell = resolveStartupShell(platform, liveShell) + const configuredArgs = isTuiAgent(args.session.agent) + ? resolveTuiAgentLaunchArgs(args.session.agent, args.state.settings?.agentDefaultArgs) + : '' + const configured = tokenizeStartupCommand(configuredArgs, shell) + if (!configured.ok) { + throw new Error('This session cannot be opened in the CLI.') + } + const commandOverride = + args.commandOverride?.trim() || + args.state.settings?.agentCmdOverrides?.[args.session.agent]?.trim() + const mode = + sourceAgent !== args.session.agent && + isTuiAgent(sourceAgent) && + !commandOverride && + !findOptionOccurrence(configured.tokens, ['--standalone'], true) + ? tokenizeStartupCommand( + getTuiAgentLaunchCommand(TUI_AGENT_CONFIG[sourceAgent], platform), + shell + ) + : null + if (mode && !mode.ok) { + throw new Error('This session cannot be opened in the CLI.') + } + const agentArgs = [ + mode?.ok + ? mode.tokens + .slice(1) + .map((token) => quoteStartupArg(token, shell)) + .join(' ') + : '', + configuredArgs + ] + .filter(Boolean) + .join(' ') + return { + ...buildAiVaultResumeStartupForWorktree({ ...args, agentArgs }), + agent: args.session.agent + } +} + +/** Check effective client launch choices before asking the host to save a copy. */ +export function assertAiVaultCopiedSessionLaunchAllowed(args: AiVaultResumeWorktreeArgs): void { + if (args.session.agent !== 'opencode' && args.session.agent !== 'opencode2') { + return + } + const { platform, liveShell } = resolveAiVaultResumeHost(args) + const command = + args.commandOverride?.trim() || + args.state.settings?.agentCmdOverrides?.opencode || + getTuiAgentLaunchCommand(TUI_AGENT_CONFIG.opencode, platform) + const parsed = tokenizeStartupCommand( + `${command} ${resolveTuiAgentLaunchArgs('opencode', args.state.settings?.agentDefaultArgs)}`, + resolveStartupShell(platform, liveShell) + ) + if (!parsed.ok) { + throw new Error('This session cannot be opened in the CLI.') + } + assertOpenCodeCopyUsesLocalStore(parsed.tokens.slice(1)) +} diff --git a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts index fee9f3a9be98..cfccc4c72698 100644 --- a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts @@ -171,7 +171,11 @@ describe('prepareAiVaultSessionForFork', () => { async (agent) => { const prepareSessionResume = vi.fn().mockResolvedValue({ useRealCodexHome: false, - forkedSession: { sessionId: 'copy-1', agent: agent === 'opencode2' ? 'opencode' : agent } + forkedSession: { + sessionId: 'copy-1', + filePath: '/sessions/copy-1.jsonl', + agent: agent === 'opencode2' ? 'opencode' : agent + } }) stubPreparation(prepareSessionResume) const owned = session({ @@ -198,7 +202,7 @@ describe('prepareAiVaultSessionForFork', () => { expect(prepared.session).toMatchObject({ agent: agent === 'opencode2' ? 'opencode' : agent, sessionId: 'copy-1', - filePath: '', + filePath: '/sessions/copy-1.jsonl', resumeCommand: '', cwd: '/repo', executionHostId: 'runtime:env-1' 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 d46dbe9bb079..794718ef4d00 100644 --- a/src/renderer/src/lib/ai-vault-session-resume-preparation.ts +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.ts @@ -100,15 +100,14 @@ export async function prepareAiVaultSessionForFork( executionHostId: session.executionHostId, fork: true }) - if (!result.forkedSession) { + if (!result.forkedSession || (session.agent === 'omp' && !result.forkedSession.filePath)) { // A host that predates chat copies makes none; resuming the chat's own would be a second writer. throw new Error(aiVaultCliForkHostTooOldMessage()) } const { structuredSession: _owner, ...copy } = session - // The copy's own file is unknown here; its id resumes it in the chat's folder, with the chat's CLI. - const { sessionId, agent } = result.forkedSession + const { sessionId, agent, filePath } = result.forkedSession return { - session: { ...copy, agent, sessionId, filePath: '', resumeCommand: '' }, + session: { ...copy, agent, sessionId, filePath: filePath ?? '', resumeCommand: '' }, copied: true } } diff --git a/src/renderer/src/runtime/web-runtime-conversation-copy-resume.test.ts b/src/renderer/src/runtime/web-runtime-conversation-copy-resume.test.ts new file mode 100644 index 000000000000..5b3451b96288 --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-conversation-copy-resume.test.ts @@ -0,0 +1,185 @@ +import { buildAiVaultCopiedSessionStartupForWorktree } from '@/lib/ai-vault-session-fork-startup' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as TrackingDecisions from './web-session-tabs-sync/tracking-decisions' +import { createWebRuntimeSessionTerminal } from './web-runtime-terminal-creation' +import { resetWebSessionCloseIntentForTests } from './web-session-close-intent' +import { + ENVIRONMENT_ID, + FOCUS_LEAF_ID, + WORKTREE_ID, + makeSnapshot, + resetTerminalCreateEnvironment, + stubTerminalCreateEnvironment +} from './web-runtime-session-test-harness' +import { stubRuntimeEnvironmentBridge } from './runtime-environment-bridge-fixture' + +const mocks = vi.hoisted(() => ({ + getState: vi.fn(), + setState: vi.fn(), + subscribe: vi.fn(), + setActiveWorktree: vi.fn(), + createBrowserTab: vi.fn(), + closeEmptyGroup: vi.fn(), + moveUnifiedTabToGroup: vi.fn(), + setRemoteBrowserPageHandle: vi.fn(), + focusBrowserTabInWorktree: vi.fn(), + applyWebSessionTabsSnapshot: vi.fn(), + decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), + acceptReplayedWebSessionTabsSnapshot: vi.fn(), + resolveHostSessionTabIdForWebSessionTab: vi.fn(), + trackTerminalPaneSplit: vi.fn(), + deliverLaunchPromptToAgentTab: vi.fn(), + seedNativeChatLaunchDraftForAgentTab: vi.fn(), + getRuntimeEnvironmentIdForWorktree: vi.fn(), + hasMaterializedWebRuntimeBrowserPage: vi.fn() +})) + +vi.mock('../store', () => ({ + useAppStore: { + getState: mocks.getState, + setState: mocks.setState, + subscribe: mocks.subscribe + } +})) + +vi.mock('./web-session-tabs-sync/tracking-lifecycle', () => ({ + acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration +})) +vi.mock('./web-session-tabs-sync/snapshot-api', () => ({ + applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot +})) +vi.mock('./web-session-tabs-sync/tracking-decisions', async (importOriginal) => ({ + ...(await importOriginal()), + decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot +})) +vi.mock('./web-session-tabs-sync/store-patch', () => ({ + applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { + mocks.setState(buildPatch) + // The production caller invokes the returned settle receipt. + return () => {} + } +})) +vi.mock('./web-session-tabs-sync/tracking-mappings', () => ({ + resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab +})) + +vi.mock('@/lib/feature-education-telemetry', () => ({ + trackTerminalPaneSplit: mocks.trackTerminalPaneSplit +})) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: mocks.getRuntimeEnvironmentIdForWorktree +})) + +vi.mock('@/lib/agent-launch-prompt-delivery', () => ({ + deliverLaunchPromptToAgentTab: mocks.deliverLaunchPromptToAgentTab, + seedNativeChatLaunchDraftForAgentTab: mocks.seedNativeChatLaunchDraftForAgentTab +})) + +vi.mock('./web-runtime-browser-materialization', () => ({ + hasMaterializedWebRuntimeBrowserPage: mocks.hasMaterializedWebRuntimeBrowserPage +})) + +afterEach(() => resetWebSessionCloseIntentForTests()) + +describe('copied conversation ordinary host resume', () => { + beforeEach(() => stubTerminalCreateEnvironment(mocks)) + afterEach(() => resetTerminalCreateEnvironment()) + + it.each([ + { agent: 'opencode' as const, sourceAgent: 'opencode2' as const, filePath: undefined }, + { + agent: 'omp' as const, + sourceAgent: 'omp' as const, + filePath: '/pinned-omp/sessions/copy.jsonl' + } + ])( + 'sends the actual $agent provider, mode and copied file through the ordinary host request', + async ({ agent, sourceAgent, filePath }) => { + const state = mocks.getState() + Object.assign(state, { + settings: { + ...state.settings, + agentDefaultEnv: { + opencode: { OPENCODE_AUTH_CONTENT: 'synthetic-client-auth', HOME: '/client-home' } + } + } + }) + const startup = buildAiVaultCopiedSessionStartupForWorktree( + { + state, + worktreeId: WORKTREE_ID, + session: { + agent, + sessionId: 'copy-session', + filePath, + cwd: '/repo', + codexHome: null, + executionHostId: 'runtime:web-env-1', + executionHostPlatform: 'linux' + } + }, + sourceAgent + ) + const runtimeCall = vi.fn(async (request: { method: string }) => { + if (request.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-1', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1', 'agent-session.omp-resume-path.v1'] + } + } + } + if (request.method === 'terminal.ensureAgentSession') { + return { + id: 'resume', + ok: true, + result: { + terminal: { + handle: 'term_copy', + worktreeId: WORKTREE_ID, + tabId: 'host-copy', + paneKey: `host-copy:${FOCUS_LEAF_ID}` + }, + disposition: 'created' + } + } + } + return { id: 'list', ok: true, result: makeSnapshot() } + }) + stubRuntimeEnvironmentBridge({ call: runtimeCall }) + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID, + agentSessionKind: 'resume', + launchAgent: agent, + ...startup + }) + ).resolves.toEqual({ status: 'created' }) + const request = runtimeCall.mock.calls.find( + ([request]) => request.method === 'terminal.ensureAgentSession' + )?.[0] + expect(request).toMatchObject({ + params: { + agent, + providerSession: { key: 'session_id', id: 'copy-session' }, + ...(filePath + ? { ompResumeFilePath: filePath } + : { agentArgs: expect.stringContaining('--standalone') }) + } + }) + expect(request).not.toHaveProperty('params.commandPath') + expect(request).not.toHaveProperty('params.launchConfig') + expect(JSON.stringify(request)).not.toContain('synthetic-client-auth') + expect(JSON.stringify(request)).not.toContain('/client-home') + } + ) +}) diff --git a/src/shared/ai-vault-resume-preparation.ts b/src/shared/ai-vault-resume-preparation.ts index b6a150bdb647..2281e624b2de 100644 --- a/src/shared/ai-vault-resume-preparation.ts +++ b/src/shared/ai-vault-resume-preparation.ts @@ -1,5 +1,10 @@ import type { AiVaultAgent, AiVaultSession } from './ai-vault-types' +export type AiVaultConversationCopy = { + sessionId: string + filePath?: string +} + export type AiVaultPrepareSessionResumeArgs = Pick< AiVaultSession, 'agent' | 'filePath' | 'codexHome' | 'executionHostId' @@ -19,7 +24,7 @@ export type AiVaultPrepareSessionResumeResult = { /** The copy a fork made of a chat's conversation, for an agent whose chat makes it * (`agentForksThroughChat`): its id, and the agent whose CLI opens it, the chat's (an OpenCode * 2.x chat lists as `opencode2` but runs `opencode`). Absent from older hosts, which make none. */ - forkedSession?: { sessionId: string; agent: AiVaultAgent } + forkedSession?: AiVaultConversationCopy & { agent: AiVaultAgent } } export type AiVaultSessionResumePreparation = ( diff --git a/src/shared/opencode-launch-command.test.ts b/src/shared/opencode-launch-command.test.ts index dc30a5290031..57d2f252951a 100644 --- a/src/shared/opencode-launch-command.test.ts +++ b/src/shared/opencode-launch-command.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { isOpenCode2LaunchCommand, selectOpenCodeHookAgent } from './opencode-launch-command' +import { + assertOpenCodeCopyUsesLocalStore, + isOpenCode2LaunchCommand, + selectOpenCodeHookAgent +} from './opencode-launch-command' describe('isOpenCode2LaunchCommand', () => { it.each(['opencode2', '/usr/local/bin/opencode2', 'opencode2.exe', 'opencode2.cmd'])( @@ -40,3 +44,20 @@ describe('selectOpenCodeHookAgent after a sleep prefix', () => { ).toBeNull() }) }) + +describe('OpenCode host-local copy launch', () => { + it.each( + [['--server', 'http://synthetic-server'], ['--server=http://synthetic-server']].map((args) => ({ + args + })) + )('refuses an explicit server route before a copy', ({ args }) => { + expect(() => assertOpenCodeCopyUsesLocalStore(args)).toThrow('Remove --server') + }) + it.each( + [[], ['--standalone'], ['--', '--server'], ['--prompt=explain --server']].map((args) => ({ + args + })) + )('keeps ordinary local and positional argument meanings', ({ args }) => { + expect(() => assertOpenCodeCopyUsesLocalStore(args)).not.toThrow() + }) +}) diff --git a/src/shared/opencode-launch-command.ts b/src/shared/opencode-launch-command.ts index 86c39837081d..ad3617fc7722 100644 --- a/src/shared/opencode-launch-command.ts +++ b/src/shared/opencode-launch-command.ts @@ -1,5 +1,21 @@ import type { TuiAgent } from './tui-agent' import { getAgentLaunchExecutableName } from './agent-command-line-intent' +import { findOptionOccurrence } from './command-option-occurrence' + +export class OpenCodeCopyServerRouteError extends Error { + constructor() { + super( + 'Remove --server from the OpenCode command or arguments in Settings to open a copy in a CLI.' + ) + } +} + +/** A configured server may not hold the copy saved by this execution host. */ +export function assertOpenCodeCopyUsesLocalStore(args: readonly string[]): void { + if (findOptionOccurrence(args, ['--server'], true)) { + throw new OpenCodeCopyServerRouteError() + } +} function openCodeCommandAgent(launchCommand: string | undefined): 'opencode' | 'opencode2' | null { const binary = getAgentLaunchExecutableName(launchCommand) From 6aa1a33f680467b965aff844e5d4f9f2f3541aef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:53:30 -0700 Subject: [PATCH 5/7] test: keep only the tests that pin user-visible behavior --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 - .../acp-conversation-copy-lifecycle.test.ts | 166 ------- ...-conversation-copy-opencode-server.test.ts | 131 ------ src/main/acp/acp-conversation-copy.test.ts | 413 ++++-------------- .../ai-vault/runtime-session-scanner.test.ts | 69 +-- .../structured-chat-conversation-copy.test.ts | 57 +-- .../ipc/ai-vault-resume-chat-copy.test.ts | 21 - ...ged-data-account-spawn-environment.test.ts | 29 -- ...a-runtime-conversation-copy-resume.test.ts | 86 ---- .../ai-vault-copy-server-route.test.ts | 77 ---- .../ai-vault-session-launch-actions.test.ts | 94 +--- .../tab-session-history-switch.test.ts | 9 - .../lib/ai-vault-session-fork-startup.test.ts | 50 +-- ...i-vault-session-resume-preparation.test.ts | 45 -- ...b-runtime-conversation-copy-resume.test.ts | 185 -------- src/shared/opencode-launch-command.test.ts | 23 +- src/shared/tui-agent-fork-startup.test.ts | 27 +- 17 files changed, 147 insertions(+), 1336 deletions(-) delete mode 100644 src/main/acp/acp-conversation-copy-lifecycle.test.ts delete mode 100644 src/main/acp/acp-conversation-copy-opencode-server.test.ts delete mode 100644 src/main/runtime/orca-runtime-conversation-copy-resume.test.ts delete mode 100644 src/main/runtime/rpc/methods/ai-vault-copy-server-route.test.ts delete mode 100644 src/renderer/src/runtime/web-runtime-conversation-copy-resume.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index b7fed8b483ff..2225b13bbc4e 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -16,7 +16,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/acp/acp-timeline-turn-failures.test.ts', 'src/main/acp/acp-structured-session-adapter.test.ts', 'src/main/acp/acp-conversation-copy.test.ts', - 'src/main/acp/acp-conversation-copy-lifecycle.test.ts', 'src/main/pi/rpc-turn-races.test.ts', 'src/main/acp/acp-structured-child-session-end.test.ts', 'src/main/acp/acp-structured-child-stop.test.ts', diff --git a/src/main/acp/acp-conversation-copy-lifecycle.test.ts b/src/main/acp/acp-conversation-copy-lifecycle.test.ts deleted file mode 100644 index 8316508122a8..000000000000 --- a/src/main/acp/acp-conversation-copy-lifecycle.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { acpLaunchSpecFor } from './acp-launch-specs' -import { copyAcpConversation } from './acp-conversation-copy' -import { - openAcpAdapterRig, - type FakeAcpChild, - type AcpAdapterRig -} from './acp-structured-adapter.test-support' - -vi.mock('../native-chat/session-file-resolver', () => ({ - resolveSessionFilePath: async () => '/pinned/sessions/copy.jsonl' -})) - -const spec = acpLaunchSpecFor('omp')! -const FORKS = { agentCapabilities: { sessionCapabilities: { fork: {} } } } -const input = { - identity: { - sessionId: 'chat', - workspaceId: 'workspace', - hostId: 'local', - agent: 'omp', - providerHandle: null - }, - providerSessionId: 'original', - accountHome: { variable: 'PI_CODING_AGENT_DIR', path: '/pinned' } -} -const rigs: AcpAdapterRig[] = [] -afterEach(async () => { - await Promise.all(rigs.splice(0).map((rig) => rig.adapter.closeAll())) -}) -async function open(script: (agent: FakeAcpChild['agent']) => void) { - const rig = await openAcpAdapterRig({ spec, initialize: FORKS, script }) - rigs.push(rig) - return rig -} - -function forkResponse(id: string) { - return (agent: FakeAcpChild['agent']) => - agent.on('session/fork', (frame) => agent.reply(frame, { sessionId: id })) -} - -describe('ACP copy cleanup ownership', () => { - it('retains a failed cleanup, permits another copy, and retries at shutdown', async () => { - let reply = false - const rig = await open((agent) => - agent.on('session/fork', (frame) => { - if (reply) { - agent.reply(frame, { sessionId: 'copy' }) - } - }) - ) - const connecting = rig.adapter.copyConversation(input) - await vi.waitFor(() => expect(rig.sent('session/fork')).toHaveLength(1)) - const child = rig.child() - child.proveClose = async () => false - child.agent.reply(rig.sent('session/fork')[0]!, { sessionId: 'copy' }) - await expect(connecting).resolves.toHaveProperty('sessionId', 'copy') - reply = true - await expect(rig.adapter.copyConversation(input)).resolves.toHaveProperty('sessionId', 'copy') - expect(child.closes).toBe(1) - child.proveClose = async () => { - child.exit() - return true - } - await rig.adapter.closeAll() - expect(child.closes).toBe(2) - }) - - it('rederives a retained child exit before shutdown instead of retrying a dead child', async () => { - const rig = await open(() => {}) - const copying = rig.adapter.copyConversation(input) - await vi.waitFor(() => expect(rig.sent('session/fork')).toHaveLength(1)) - const child = rig.child() - child.proveClose = async () => false - child.agent.reply(rig.sent('session/fork')[0]!, { sessionId: 'copy' }) - await copying - child.exit() - await rig.adapter.closeAll() - expect(child.closes).toBe(1) - }) - - it('settles an active fork before shutdown retries its failed cleanup', async () => { - const rig = await open(() => {}) - const copying = rig.adapter.copyConversation(input) - const rejected = expect(copying).rejects.toThrow('OMP could not copy this chat') - await vi.waitFor(() => expect(rig.sent('session/fork')).toHaveLength(1)) - const child = rig.child() - child.proveClose = vi - .fn() - .mockResolvedValueOnce(false) - .mockImplementation(async () => { - child.exit() - return true - }) - await rig.adapter.closeAll() - await rejected - expect(child.closes).toBe(2) - }) - - it('rejects the source identity before handing the result to a terminal', async () => { - const rig = await open(forkResponse('original')) - await expect(rig.adapter.copyConversation(input)).rejects.toThrow( - 'OMP could not copy this chat' - ) - expect(rig.child().closes).toBe(1) - }) - - it('bounds launch resolution and never starts a late child', async () => { - let resolveLaunch: ( - launch: Awaited[0]['readLaunch']>> - ) => void = () => {} - const readLaunch = new Promise< - Awaited[0]['readLaunch']>> - >((resolve) => { - resolveLaunch = resolve - }) - const connect = vi.fn() - await expect( - copyAcpConversation({ spec, readLaunch: () => readLaunch, connect }, input, { - timeoutMs: 5, - track: () => {} - }) - ).rejects.toThrow('OMP could not copy this chat') - resolveLaunch({ - spec, - command: 'fake', - args: [], - cwd: '/repo', - env: {}, - envToDelete: [], - fullAccess: false, - resume: null - }) - await Promise.resolve() - expect(connect).not.toHaveBeenCalled() - }) - - it('shutdown during launch resolution prevents any child even when the launch resolves later', async () => { - let resolveLaunch: ( - launch: Awaited[0]['readLaunch']>> - ) => void = () => {} - const readLaunch = new Promise< - Awaited[0]['readLaunch']>> - >((resolve) => { - resolveLaunch = resolve - }) - const rig = await openAcpAdapterRig({ spec, deps: { readLaunch: () => readLaunch } }) - rigs.push(rig) - const copying = rig.adapter.copyConversation(input) - const rejected = expect(copying).rejects.toThrow('OMP could not copy this chat') - await rig.adapter.closeAll() - await rejected - resolveLaunch({ - spec, - command: 'fake', - args: [], - cwd: '/repo', - env: {}, - envToDelete: [], - fullAccess: false, - resume: null - }) - await Promise.resolve() - expect(rig.spawned).toHaveLength(0) - }) -}) diff --git a/src/main/acp/acp-conversation-copy-opencode-server.test.ts b/src/main/acp/acp-conversation-copy-opencode-server.test.ts deleted file mode 100644 index 340e3eda11e1..000000000000 --- a/src/main/acp/acp-conversation-copy-opencode-server.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { acpLaunchSpecFor } from './acp-launch-specs' -import { copyAcpConversation } from './acp-conversation-copy' -import { FakeAcpChild } from './acp-structured-adapter.test-support' -import { createAcpStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support' -import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import { structuredAgentConfiguredArgs } from '../native-chat/structured-agent-configured-args' - -const UNMANAGED = { kind: 'opencode' as const, locator: { kind: 'unmanaged' as const } } -const identity = { - sessionId: 'chat', - workspaceId: 'workspace', - hostId: 'local', - agent: 'opencode', - providerHandle: null -} -const input = { identity, providerSessionId: 'original', accountHome: UNMANAGED } -const spec = { - ...acpLaunchSpecFor('opencode')!, - account: { - ...acpLaunchSpecFor('opencode')!.account, - resolve: async () => UNMANAGED, - environment: (_home: unknown, env: Record) => env - } -} - -function readLaunch(command?: string) { - const record = { - ...agentSessionRecordFixture(), - sessionId: 'chat', - provider: 'opencode', - accountHome: UNMANAGED, - providerHandleChain: [] - } - const resolver = createAcpStructuredLaunchResolver(spec, { - store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, - readJournal: () => null, - resolveWorkspacePath: async () => '/synthetic-repo', - resolveEnvironment: async () => ({ HOME: '/synthetic-home', PATH: '/synthetic-bin' }), - resolveCommandSettings: () => ({ agentCmdOverrides: command ? { opencode: command } : {} }), - resolveCommand: () => '/synthetic-bin/opencode', - probeVersion: async (_input, supports) => supports('2.0.27') - }) - return (identity: Parameters[1]['identity']) => resolver({ identity }) -} - -function connect() { - const children: FakeAcpChild[] = [] - const connect = vi.fn((...args: ConstructorParameters) => { - const child = new FakeAcpChild(...args) - child.agent.on('initialize', (frame) => - child.agent.reply(frame, { - protocolVersion: 1, - agentCapabilities: { sessionCapabilities: { fork: {} } } - }) - ) - child.agent.on('session/fork', (frame) => child.agent.reply(frame, { sessionId: 'copy' })) - children.push(child) - return child - }) - return { connect, children } -} - -describe('OpenCode copied CLI server routing', () => { - it('already refuses a command-string server override in the real native launch resolver before copy', async () => { - const rig = connect() - await expect( - copyAcpConversation( - { - spec, - readLaunch: readLaunch('opencode --server http://synthetic-server'), - connect: rig.connect - }, - input, - { track: () => {} } - ) - ).rejects.toMatchObject({ - cause: expect.objectContaining({ - message: 'the opencode Command setting is not a runnable program' - }) - }) - expect(rig.connect).not.toHaveBeenCalled() - }) - - it.each(['--server http://synthetic-server', '--server=http://synthetic-server'])( - 'refuses actual host default arguments before making a copy: %s', - async (configured) => { - const host = { agentDefaultArgs: { opencode: configured } } - const rig = connect() - const resolveLaunchArgs = vi.fn(() => - structuredAgentConfiguredArgs('opencode', host, 'linux') - ) - await expect( - copyAcpConversation( - { spec, readLaunch: readLaunch(), connect: rig.connect, resolveLaunchArgs }, - input, - { track: () => {} } - ) - ).rejects.toThrow('Remove --server') - expect(resolveLaunchArgs).toHaveBeenCalledWith() - expect(rig.connect).not.toHaveBeenCalled() - } - ) - - it('does not treat a positional server word as a server route', async () => { - const rig = connect() - const resolveLaunchArgs = () => ['--', '--server'] - await expect( - copyAcpConversation( - { spec, readLaunch: readLaunch(), connect: rig.connect, resolveLaunchArgs }, - input, - { track: () => {} } - ) - ).resolves.toEqual({ sessionId: 'copy' }) - expect(rig.children).toHaveLength(1) - expect(rig.children[0]?.closes).toBe(1) - }) - - it('bounds a host settings read before any copy child can start', async () => { - const rig = connect() - const resolveLaunchArgs = () => new Promise(() => {}) - await expect( - copyAcpConversation( - { spec, readLaunch: readLaunch(), connect: rig.connect, resolveLaunchArgs }, - input, - { timeoutMs: 5, track: () => {} } - ) - ).rejects.toThrow('OpenCode could not copy this chat') - expect(rig.connect).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/acp/acp-conversation-copy.test.ts b/src/main/acp/acp-conversation-copy.test.ts index 7c6a5399b6bf..25fc9eed346f 100644 --- a/src/main/acp/acp-conversation-copy.test.ts +++ b/src/main/acp/acp-conversation-copy.test.ts @@ -1,356 +1,137 @@ -import { environmentForStructuredOpenCodeAccountHome } from '../opencode/opencode-structured-account-home' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import { acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' +import { acpLaunchSpecFor } from './acp-launch-specs' +import { copyAcpConversation } from './acp-conversation-copy' import { FakeAcpChild, openAcpAdapterRig, type AcpAdapterRig, PROVIDER_SESSION } from './acp-structured-adapter.test-support' -import { copyAcpConversation } from './acp-conversation-copy' - -const resolveFile = vi.hoisted(() => - vi.fn(async (): Promise => '/account-a/sessions/copy-1.jsonl') -) -vi.mock('../native-chat/session-file-resolver', () => ({ resolveSessionFilePath: resolveFile })) -const accountHome = { variable: 'PI_CODING_AGENT_DIR', path: '/account-a' } -const OMP = acpLaunchSpecFor('omp')! -const OPENCODE = acpLaunchSpecFor('opencode')! +vi.mock('../native-chat/session-file-resolver', () => ({ + resolveSessionFilePath: async () => '/account-a/sessions/copy-1.jsonl' +})) const FORKS = { agentCapabilities: { loadSession: true, sessionCapabilities: { fork: {} } } } -const FORKS_INITIALIZED = { protocolVersion: 1, ...FORKS } -const identity = (agent: string): AgentSessionJournalIdentity => ({ - sessionId: 'orca-chat-1', - workspaceId: 'workspace-1', - hostId: 'local', - agent, - providerHandle: null -}) - -const launchOf = (spec: AcpLaunchSpec) => ({ - spec, - command: `/opt/bin/${spec.command}`, - args: ['acp'], - cwd: '/workspace/project', - env: { PI_CODING_AGENT_DIR: '/account-a' }, - envToDelete: [], - fullAccess: false, - resume: null -}) - +const input = { + identity: { + sessionId: 'orca-chat-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'omp', + providerHandle: null + }, + providerSessionId: PROVIDER_SESSION, + accountHome: { variable: 'PI_CODING_AGENT_DIR', path: '/account-a' } +} const rigs: AcpAdapterRig[] = [] afterEach(async () => { await Promise.all(rigs.splice(0).map((rig) => rig.adapter.closeAll())) }) -async function open(options: Parameters[0]): Promise { - const rig = await openAcpAdapterRig(options) +async function open(agent: 'omp' | 'opencode' = 'omp') { + const rig = await openAcpAdapterRig({ + spec: acpLaunchSpecFor(agent)!, + initialize: FORKS, + script: (agent) => + agent.on('session/fork', (frame) => agent.reply(frame, { sessionId: 'copy-1' })) + }) rigs.push(rig) return rig } describe('copying a chat conversation for the CLI', () => { - it.each([OMP, OPENCODE])( - 'has $agent copy the conversation under the chat launch and answers the copy', - async (spec) => { - const reads: string[] = [] - const rig = await open({ - spec, - initialize: FORKS, - deps: { - // The unsealed launch a recovery read uses, with the keys the host strips from a child. - readLaunch: async (chat) => { - reads.push(chat.sessionId) - return { ...launchOf(spec), envToDelete: ['ORCA_PANE_KEY', 'ORCA_AGENT_HOOK_PORT'] } - } - }, - script: (agent) => - agent.on('session/fork', (frame) => agent.reply(frame, { sessionId: 'copy-1' })) - }) - - await expect( - rig.adapter.copyConversation({ - identity: identity(spec.agent), - providerSessionId: PROVIDER_SESSION, - accountHome: - spec.agent === 'omp' - ? accountHome - : { kind: 'opencode', locator: { kind: 'unmanaged' } } - }) - ).resolves.toMatchObject({ sessionId: 'copy-1' }) - - if (spec.agent === 'omp') { - expect(resolveFile).toHaveBeenCalledWith( - 'omp', - 'copy-1', - { ompSessionsDir: '/account-a/sessions' }, - expect.any(AbortSignal) - ) + it.each(['omp', 'opencode'] as const)( + 'copies a stopped and running %s chat without loading or prompting its original', + async (agent) => { + const rig = await open(agent) + const request = { + ...input, + identity: { ...input.identity, agent }, + accountHome: + agent === 'omp' + ? input.accountHome + : { kind: 'opencode' as const, locator: { kind: 'unmanaged' as const } } } - expect(rig.sent('session/fork').map((frame) => frame.params)).toEqual([ - { sessionId: PROVIDER_SESSION, cwd: '/workspace/project', mcpServers: [] } - ]) - // Only the copy is made: the chat's conversation is never loaded, resumed or prompted. + const expected = { + sessionId: 'copy-1', + ...(agent === 'omp' ? { filePath: '/account-a/sessions/copy-1.jsonl' } : {}) + } + await expect(rig.adapter.copyConversation(request)).resolves.toEqual(expected) expect(rig.child().agent.frames.map((frame) => frame.method)).toEqual([ 'initialize', 'session/fork' ]) - // The short-lived agent is closed, and runs the chat's launch unsealed, for no chat or pane. - expect(rig.child().closes).toBe(1) - expect(reads).toEqual(['orca-chat-1']) - expect(rig.child().launch.envToDelete).toEqual( - expect.arrayContaining(['ORCA_PANE_KEY', 'ORCA_AGENT_HOOK_PORT']) - ) - } - ) - - it('returns the matching managed account the OpenCode copy actually used', async () => { - const pinned = { - kind: 'opencode' as const, - locator: { - kind: 'managed' as const, - managedProfileId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' - } - } - const environmentForAccount = vi.fn(() => ({ - XDG_DATA_HOME: '/account-a/data', - XDG_STATE_HOME: '/account-a/state', - OPENCODE_AUTH_CONTENT: '', - OPENCODE_DB: 'opencode.db' - })) - const spec = { - ...OPENCODE, - account: { - ...OPENCODE.account, - resolve: async () => ({ - kind: 'opencode' as const, - locator: { - kind: 'managed' as const, - managedProfileId: pinned.locator.managedProfileId - } - }), - environment: ( - home: Parameters[0], - env: Record - ) => { - if (!('kind' in home)) { - throw new Error('Wrong account kind') - } - return environmentForStructuredOpenCodeAccountHome(home, { - managedAccounts: { environmentForAccount, restoreOriginalEnvironment: () => {} }, - baseEnvironment: env - }) - } - } - } - const rig = await open({ - spec, - initialize: FORKS, - deps: { - readLaunch: async () => ({ - ...launchOf(spec), - env: { - XDG_DATA_HOME: '/account-a/data', - XDG_STATE_HOME: '/account-a/state', - ARBITRARY_SECRET: 'test-only', - ORCA_PANE_KEY: 'source-pane' - } - }) - }, - script: (agent) => - agent.on('session/fork', (frame) => agent.reply(frame, { sessionId: 'copy-1' })) - }) - const copy = await rig.adapter.copyConversation({ - identity: identity('opencode'), - providerSessionId: 'original', - accountHome: pinned - }) - expect(copy).toEqual({ sessionId: 'copy-1' }) - expect(copy).not.toHaveProperty('launchConfig') - expect(copy).not.toHaveProperty('env') - expect(environmentForAccount).not.toHaveBeenCalled() - }) - - it.each([ - { kind: 'opencode' as const, locator: { kind: 'unmanaged' as const } }, - { - kind: 'opencode' as const, - locator: { - kind: 'managed' as const, - managedProfileId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' - } - } - ])('refuses an OpenCode account mismatch before copying', async (accountHome) => { - const spec = { - ...OPENCODE, - account: { - ...OPENCODE.account, - resolve: async () => ({ - kind: 'opencode' as const, - locator: { - kind: 'managed' as const, - managedProfileId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' - } - }) - } - } - const rig = await open({ spec, initialize: FORKS }) - await expect( - rig.adapter.copyConversation({ - identity: identity('opencode'), - providerSessionId: 'original', - accountHome + expect(rig.sent('session/fork')[0]?.params).toEqual({ + sessionId: PROVIDER_SESSION, + cwd: '/workspace/project', + mcpServers: [] }) - ).rejects.toThrow('Select the account this chat uses') - expect(rig.spawned).toEqual([]) - }) - - it('rederives an account change during fork before admitting the copy to a terminal', async () => { - const pinned = { - kind: 'opencode' as const, - locator: { - kind: 'managed' as const, - managedProfileId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' - } - } - let selected = pinned - const spec = { ...OPENCODE, account: { ...OPENCODE.account, resolve: async () => selected } } - const rig = await open({ - spec, - initialize: FORKS, - script: (agent) => - agent.on('session/fork', (frame) => { - selected = { - kind: 'opencode', - locator: { kind: 'managed', managedProfileId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' } - } - agent.reply(frame, { sessionId: 'copy-created-in-a' }) - }) - }) - await expect( - rig.adapter.copyConversation({ - identity: identity('opencode'), - providerSessionId: 'original', - accountHome: pinned - }) - ).rejects.toThrow('Select the account this chat uses') - expect(rig.sent('session/fork')).toHaveLength(1) - expect(rig.child().closes).toBe(1) - }) - - it('refuses an OMP copy whose pinned transcript cannot be resolved', async () => { - resolveFile.mockResolvedValueOnce(null) - const rig = await open({ - spec: OMP, - initialize: FORKS, - script: (agent) => - agent.on('session/fork', (frame) => agent.reply(frame, { sessionId: 'copy-1' })) - }) - await expect( - rig.adapter.copyConversation({ - identity: identity('omp'), - providerSessionId: 'original', - accountHome - }) - ).rejects.toThrow('OMP could not copy this chat') - }) - - it.each(['', ' ', ' copy ', '-copy', 'copy\ncontrol', 'x'.repeat(513)])( - 'rejects a malformed provider fork identity', - async (id) => { - const rig = await open({ - spec: OPENCODE, - initialize: FORKS, - script: (agent) => - agent.on('session/fork', (frame) => agent.reply(frame, { sessionId: id })) - }) - await expect( - rig.adapter.copyConversation({ - identity: identity('opencode'), - providerSessionId: 'original', - accountHome: { kind: 'opencode', locator: { kind: 'unmanaged' } } - }) - ).rejects.toThrow('OpenCode could not copy this chat') + expect(rig.child().closes).toBe(1) + await rig.acquire() + const chat = rig.child() + const frames = [...chat.agent.frames] + await expect(rig.adapter.copyConversation(request)).resolves.toEqual(expected) + expect(chat.agent.frames).toEqual(frames) + expect(chat.closes).toBe(0) + expect(rig.child()).not.toBe(chat) expect(rig.child().closes).toBe(1) } ) - it('copies while the chat runs, beside its own agent and without touching it', async () => { - const rig = await open({ - spec: OMP, - initialize: FORKS, - script: (agent) => - agent.on('session/fork', (frame) => agent.reply(frame, { sessionId: 'copy-1' })) - }) - await rig.acquire() - const chat = rig.child() - - await rig.adapter.copyConversation({ - identity: identity('omp'), - providerSessionId: PROVIDER_SESSION, - accountHome + it('permits another copy after unproven cleanup and retries the retained child at shutdown', async () => { + const rig = await openAcpAdapterRig({ spec: acpLaunchSpecFor('omp')!, initialize: FORKS }) + rigs.push(rig) + const copying = rig.adapter.copyConversation(input) + await vi.waitFor(() => expect(rig.sent('session/fork')).toHaveLength(1)) + const child = rig.child() + child.proveClose = async () => false + child.agent.reply(rig.sent('session/fork')[0]!, { sessionId: 'copy-1' }) + await expect(copying).resolves.toHaveProperty('sessionId', 'copy-1') + const next = rig.adapter.copyConversation(input) + await vi.waitFor(() => { + expect(rig.child()).not.toBe(child) + expect(rig.sent('session/fork')).toHaveLength(1) }) - - expect(rig.child()).not.toBe(chat) - expect(chat.closes).toBe(0) - expect(chat.agent.frames.some((frame) => frame.method === 'session/fork')).toBe(false) - }) - - it('asks for an update when the installed agent does not offer the copy', async () => { - const rig = await open({ spec: OMP, initialize: { agentCapabilities: { loadSession: true } } }) - - await expect( - rig.adapter.copyConversation({ - identity: identity('omp'), - providerSessionId: 'p', - accountHome - }) - ).rejects.toThrow('This OMP version cannot copy a chat. Update OMP to resume this chat') - expect(rig.sent('session/fork')).toEqual([]) - expect(rig.child().closes).toBe(1) - }) - - it('reports a refused copy in plain words and closes the agent', async () => { - const rig = await open({ - spec: OPENCODE, - initialize: FORKS, - script: (agent) => - agent.on('session/fork', (frame) => - agent.fail(frame, -32603, 'Internal error: Internal service failure') - ) - }) - - await expect( - rig.adapter.copyConversation({ - identity: identity('opencode'), - providerSessionId: 'p', - accountHome: { kind: 'opencode', locator: { kind: 'unmanaged' } } - }) - ).rejects.toThrow('OpenCode could not copy this chat.') - expect(rig.child().closes).toBe(1) + rig.child().agent.reply(rig.sent('session/fork')[0]!, { sessionId: 'copy-2' }) + await expect(next).resolves.toHaveProperty('sessionId', 'copy-2') + child.proveClose = async () => { + child.exit() + return true + } + await rig.adapter.closeAll() + expect(child.closes).toBe(2) }) - it('gives up on an agent that never answers, and closes it', async () => { + it('bounds an unanswered fork and closes its temporary process', async () => { const children: FakeAcpChild[] = [] - const deps = { - spec: OMP, - readLaunch: async () => launchOf(OMP), - connect: (...args: ConstructorParameters) => { - const child = new FakeAcpChild(...args) - child.agent.on('initialize', (frame) => child.agent.reply(frame, FORKS_INITIALIZED)) - children.push(child) - return child - } - } - + const spec = acpLaunchSpecFor('omp')! await expect( copyAcpConversation( - deps, - { identity: identity('omp'), providerSessionId: 'p', accountHome }, + { + spec, + readLaunch: async () => ({ + spec, + command: 'fake', + args: [], + cwd: '/repo', + env: {}, + envToDelete: [], + fullAccess: false, + resume: null + }), + connect: (...args: ConstructorParameters) => { + const child = new FakeAcpChild(...args) + child.agent.on('initialize', (frame) => + child.agent.reply(frame, { protocolVersion: 1, ...FORKS }) + ) + children.push(child) + return child + } + }, + input, { timeoutMs: 10, track: () => {} } ) - ).rejects.toThrow('OMP could not copy this chat.') + ).rejects.toThrow('OMP could not copy this chat') expect(children.map((child) => child.closes)).toEqual([1]) }) }) diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts index 4c34299b8c0a..9fe60aa09723 100644 --- a/src/main/ai-vault/runtime-session-scanner.test.ts +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -188,57 +188,28 @@ describe('runtime AI Vault session scanner', () => { }) }) - it("keeps the copy a server's chat made for Resume in New CLI", async () => { - mocks.callRuntimeEnvironment.mockResolvedValueOnce({ - ok: true, - result: { - useRealCodexHome: false, - forkedSession: { - sessionId: 'ses_copy1', - agent: 'opencode' - } - } - }) - - await expect( - prepareRuntimeAiVaultSessionResume('/user-data', 'env-1', { - agent: 'opencode2', - sessionId: 'ses_chat1', - filePath: '/data/opencode/opencode.db', - codexHome: null, - executionHostId: 'runtime:env-1', - fork: true + it.each([ + { agent: 'opencode' as const, sessionId: 'opencode-copy' }, + { agent: 'omp' as const, sessionId: 'omp-copy', filePath: '/pinned-omp/sessions/copy.jsonl' } + ])( + 'preserves the $agent copy identity and transcript across the paired-host response', + async (forkedSession) => { + mocks.callRuntimeEnvironment.mockResolvedValueOnce({ + ok: true, + result: { useRealCodexHome: false, forkedSession } }) - ).resolves.toEqual({ - useRealCodexHome: false, - forkedSession: { - sessionId: 'ses_copy1', - agent: 'opencode' - } - }) - }) - - it('keeps the copied OMP transcript path returned by its host', async () => { - const forkedSession = { - sessionId: 'omp-copy', - agent: 'omp' as const, - filePath: '/pinned-omp/sessions/copy.jsonl' + await expect( + prepareRuntimeAiVaultSessionResume('/user-data', 'env-1', { + agent: forkedSession.agent === 'opencode' ? 'opencode2' : 'omp', + sessionId: 'source', + filePath: '/source', + codexHome: null, + executionHostId: 'runtime:env-1', + fork: true + }) + ).resolves.toEqual({ useRealCodexHome: false, forkedSession }) } - mocks.callRuntimeEnvironment.mockResolvedValueOnce({ - ok: true, - result: { useRealCodexHome: false, forkedSession } - }) - await expect( - prepareRuntimeAiVaultSessionResume('/user-data', 'env-1', { - agent: 'omp', - sessionId: 'omp-source', - filePath: '/pinned-omp/sessions/source.jsonl', - codexHome: null, - executionHostId: 'runtime:env-1', - fork: true - }) - ).resolves.toEqual({ useRealCodexHome: false, forkedSession }) - }) + ) it('fails retryably when runtime preparation returns an invalid result', async () => { mocks.callRuntimeEnvironment.mockResolvedValueOnce({ ok: true, result: {} }) diff --git a/src/main/ai-vault/structured-chat-conversation-copy.test.ts b/src/main/ai-vault/structured-chat-conversation-copy.test.ts index 2759c2de6250..d14df1807ffa 100644 --- a/src/main/ai-vault/structured-chat-conversation-copy.test.ts +++ b/src/main/ai-vault/structured-chat-conversation-copy.test.ts @@ -11,7 +11,7 @@ import { installCopyingChat as installChat } from './structured-session-ownershi const OMP_ID = '019fd611-2b3c-7d4e-9f50-6b7c8d9e0f1a' const OPENCODE_ID = 'ses_2f9aB3cD4eF5gH6iJ7kL8mN9oP' -const OMP_FILE = `/Users/me/.omp/agent/sessions/--Users-me-repo--/2026-10-09T10-00-00-000Z_${OMP_ID}.jsonl` +const OMP_FILE = `/sessions/${OMP_ID}.jsonl` const acp = (agent: 'opencode' | 'omp', nativeId: string): AgentSessionProviderHandle => ({ transport: 'acp', @@ -57,17 +57,6 @@ describe('copying a chat conversation for Resume in New CLI', () => { agent: handle.agent } }) - expect(copy).toHaveBeenCalledWith({ - identity: { - sessionId: 'chat-1', - workspaceId: 'workspace-1', - hostId: 'local', - agent: handle.agent, - providerHandle: handle - }, - providerSessionId: sessionId, - accountHome: expect.any(Object) - }) } ) @@ -91,48 +80,4 @@ describe('copying a chat conversation for Resume in New CLI', () => { 'already owned by a chat' ) }) - - it('leaves a resume, and a fork its CLI makes, to the usual preparation', async () => { - const copy = vi.fn(async () => ({ - sessionId: 'copy-1', - filePath: '/sessions/copy-1.jsonl' - })) - installChat(acp('omp', OMP_ID), copy) - - await expect(copyStructuredChatConversation(fork({ fork: false }))).resolves.toBeNull() - await expect( - copyStructuredChatConversation(fork({ agent: 'claude', sessionId: 'c1' })) - ).resolves.toBeNull() - expect(copy).not.toHaveBeenCalled() - }) - - it('refuses when no chat owns the row any more, never copying another', async () => { - const copy = vi.fn(async () => ({ - sessionId: 'copy-1', - filePath: '/sessions/copy-1.jsonl' - })) - installChat(acp('omp', OMP_ID), copy) - - await expect( - copyStructuredChatConversation(fork({ sessionId: 'other', filePath: '/x/other.jsonl' })) - ).rejects.toThrow('This chat is no longer in Orca') - setStructuredAgentSessionHost(null) - await expect(copyStructuredChatConversation(fork({}))).rejects.toThrow( - 'This chat is no longer in Orca' - ) - expect(copy).not.toHaveBeenCalled() - }) - - it("passes the agent's refusal through", async () => { - installChat( - acp('omp', OMP_ID), - vi.fn(async () => { - throw new Error('OMP could not copy this chat.') - }) - ) - - await expect(copyStructuredChatConversation(fork({}))).rejects.toThrow( - 'OMP could not copy this chat.' - ) - }) }) diff --git a/src/main/ipc/ai-vault-resume-chat-copy.test.ts b/src/main/ipc/ai-vault-resume-chat-copy.test.ts index 908f6b9d08b9..9f38fbf24e2a 100644 --- a/src/main/ipc/ai-vault-resume-chat-copy.test.ts +++ b/src/main/ipc/ai-vault-resume-chat-copy.test.ts @@ -22,27 +22,6 @@ const forkArgs = ( afterEach(() => setStructuredAgentSessionHost(null)) describe('Resume in New CLI preparation for a chat whose host makes the copy', () => { - it("has this machine's chat make the copy instead of preparing a home", async () => { - const copy = vi.fn(async () => ({ - sessionId: 'copy-1', - filePath: '/sessions/copy-1.jsonl' - })) - installCopyingChat({ transport: 'acp', agent: 'omp', nativeId: OMP_ID }, copy) - const prepareSessionResume = vi.fn() - - await expect( - prepareAiVaultSessionResume(forkArgs('local'), { prepareSessionResume }) - ).resolves.toEqual({ - useRealCodexHome: false, - forkedSession: { - sessionId: 'copy-1', - filePath: '/sessions/copy-1.jsonl', - agent: 'omp' - } - }) - expect(prepareSessionResume).not.toHaveBeenCalled() - }) - it('leaves the copy to the server that runs a remote chat', async () => { const copy = vi.fn(async () => ({ sessionId: 'local-copy' diff --git a/src/main/ipc/pty/managed-data-account-spawn-environment.test.ts b/src/main/ipc/pty/managed-data-account-spawn-environment.test.ts index 6e2127675cf4..ed13aa02de94 100644 --- a/src/main/ipc/pty/managed-data-account-spawn-environment.test.ts +++ b/src/main/ipc/pty/managed-data-account-spawn-environment.test.ts @@ -70,35 +70,6 @@ async function spawnDeletions( } describe.each(['renderer', 'runtime'])('%s managed account daemon environment', (route) => { - it('keeps the selected account B for an ordinary explicit account-shaped environment', async () => { - selected.value = { - XDG_DATA_HOME: '/account-b/data', - XDG_STATE_HOME: '/account-b/state', - OPENCODE_AUTH_CONTENT: '', - OPENCODE_DB: 'opencode.db' - } - const env = { - XDG_DATA_HOME: '/explicit-account-a/data', - XDG_STATE_HOME: '/explicit-account-a/state', - OPENCODE_AUTH_CONTENT: '', - OPENCODE_DB: 'opencode.db' - } - applyManagedDataAccountEnvironment(env, { launchAgent: 'opencode' }) - const envToDelete = await spawnDeletions(route, env) - const child = createDaemonPtyEnvironment({ - sessionId: 'pane', - cols: 80, - rows: 24, - env, - envToDelete - }) - expect(child).toMatchObject({ - ...selected.value, - ORCA_DATA_ACCOUNT_DATA_HOME: '/account-b/data', - ORCA_DATA_ACCOUNT_STATE_HOME: '/account-b/state' - }) - }) - it('honors explicit user deletions and preserves unknown daemon metadata', async () => { const env: Record = {} const envToDelete = await spawnDeletions(route, env, ['XDG_STATE_HOME']) diff --git a/src/main/runtime/orca-runtime-conversation-copy-resume.test.ts b/src/main/runtime/orca-runtime-conversation-copy-resume.test.ts deleted file mode 100644 index 582dc973e632..000000000000 --- a/src/main/runtime/orca-runtime-conversation-copy-resume.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { OrcaRuntimeService } from './orca-runtime' -import { EnsureAgentSessionParams } from '../../shared/rpc-contract/agent-session-params' - -function runtime() { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: explicit resume reads only these settings; terminal creation is mocked. - const runtime = new OrcaRuntimeService({ - getSettings: () => ({ - agentDefaultArgs: { opencode: '--model host-model' }, - agentDefaultEnv: { - opencode: { - OPENCODE_AUTH_CONTENT: 'synthetic-host-auth', - HOME: '/host-home', - OPENCODE_CONFIG_DIR: '/host-config' - }, - omp: { PI_CODING_AGENT_DIR: '/current-omp' } - } - }) - } as never) - Object.assign(runtime, { - resolveTerminalWorkspaceLaunchScope: async () => ({ - id: 'worktree', - path: '/repo', - connectionId: null - }), - getAgentLaunchPlatformForWorkspace: () => 'linux' - }) - const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ - handle: 'term_copy', - tabId: 'copy-tab', - paneKey: 'copy-tab:copy-leaf', - ptyId: 'copy-pty', - worktreeId: 'worktree', - title: null, - surface: 'background' - }) - return { runtime, createTerminal } -} - -describe('ordinary host resume of a copied conversation', () => { - it('uses the actual OpenCode executable and preserves private mode plus host custom environment', async () => { - const rig = runtime() - await rig.runtime.ensureAgentSession( - EnsureAgentSessionParams.parse({ - kind: 'explicit', - worktree: 'id:worktree', - agent: 'opencode', - agentArgs: '--standalone', - providerSession: { key: 'session_id', id: 'copied-session' } - }) - ) - expect(rig.createTerminal).toHaveBeenCalledWith( - 'id:worktree', - expect.objectContaining({ - launchAgent: 'opencode', - command: "opencode '--standalone' '--session' 'copied-session'", - env: { - OPENCODE_AUTH_CONTENT: 'synthetic-host-auth', - HOME: '/host-home', - OPENCODE_CONFIG_DIR: '/host-config' - } - }) - ) - }) - - it('opens the OMP copy by its explicit file using the ordinary host environment', async () => { - const rig = runtime() - await rig.runtime.ensureAgentSession( - EnsureAgentSessionParams.parse({ - kind: 'explicit', - worktree: 'id:worktree', - agent: 'omp', - ompResumeFilePath: '/pinned-omp/sessions/copied.jsonl', - providerSession: { key: 'session_id', id: 'copied-session' } - }) - ) - expect(rig.createTerminal).toHaveBeenCalledWith( - 'id:worktree', - expect.objectContaining({ - launchAgent: 'omp', - command: expect.stringContaining("'--resume' '/pinned-omp/sessions/copied.jsonl'"), - env: { PI_CODING_AGENT_DIR: '/current-omp' } - }) - ) - }) -}) diff --git a/src/main/runtime/rpc/methods/ai-vault-copy-server-route.test.ts b/src/main/runtime/rpc/methods/ai-vault-copy-server-route.test.ts deleted file mode 100644 index 40dd63e704da..000000000000 --- a/src/main/runtime/rpc/methods/ai-vault-copy-server-route.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import '../unused-default-rpc-methods.test-fixture' -import { afterEach, expect, it, vi } from 'vitest' -import { RpcDispatcher } from '../dispatcher' -import { AI_VAULT_METHODS } from './ai-vault' -import type { OrcaRuntimeService } from '../../orca-runtime' -import { acpLaunchSpecFor } from '../../../acp/acp-launch-specs' -import { copyAcpConversation } from '../../../acp/acp-conversation-copy' -import { structuredAgentConfiguredArgs } from '../../../native-chat/structured-agent-configured-args' -import { installCopyingChat } from '../../../ai-vault/structured-session-ownership.test-support' -import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' - -afterEach(() => setStructuredAgentSessionHost(null)) - -it('refuses a paired-host server setting before making a copy, even when no client server flag is sent', async () => { - const hostSettings = createGlobalSettingsFixture({ - agentDefaultArgs: { opencode: '--server http://synthetic-host-server' } - }) - const resolveLaunchArgs = vi.fn(() => - structuredAgentConfiguredArgs('opencode', hostSettings, 'linux') - ) - const connect = vi.fn() - const spec = acpLaunchSpecFor('opencode')! - const providerSessionId = 'ses_2f9aB3cD4eF5gH6iJ7kL8mN9oP' - installCopyingChat( - { transport: 'acp', agent: 'opencode', nativeId: providerSessionId }, - (input) => - copyAcpConversation( - { - spec, - resolveLaunchArgs, - connect, - readLaunch: async () => ({ - spec, - command: '/synthetic-bin/opencode', - args: ['acp'], - cwd: '/host-repo', - env: {}, - envToDelete: [], - fullAccess: false, - resume: null - }) - }, - input, - { track: () => {} } - ) - ) - const prepareAiVaultSessionResume = vi.fn() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this RPC handler reads only these runtime methods; no terminal or scanner is entered. - const runtime = { - getRuntimeId: () => 'host-runtime', - ensureStructuredAgentSessionHost: async () => undefined, - prepareAiVaultSessionResume - } as unknown as OrcaRuntimeService - const dispatcher = new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) - const response = await dispatcher.dispatch({ - id: 'copy-request', - authToken: 'synthetic-token', - method: 'aiVault.prepareSessionResume', - params: { - agent: 'opencode2', - sessionId: providerSessionId, - filePath: '/host-store/opencode.db', - codexHome: null, - executionHostId: 'runtime:client', - fork: true - } - }) - expect(response).toMatchObject({ - ok: false, - error: { message: expect.stringContaining('Remove --server') } - }) - expect(resolveLaunchArgs).toHaveBeenCalledOnce() - expect(connect).not.toHaveBeenCalled() - expect(prepareAiVaultSessionResume).not.toHaveBeenCalled() - expect(JSON.stringify(response)).not.toContain('synthetic-host-server') -}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts index d57d04d95044..81b4c1adb514 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts @@ -134,21 +134,6 @@ describe('Resume in New CLI launch', () => { } ) - it('leaves ordinary resumes on their configured server route', async () => { - const session = { ...owned, agent: 'opencode' as const, structuredSession: undefined } - mocks.getState.mockReturnValue({ - activeWorktreeId: 'worktree-1', - settings: { agentDefaultArgs: { opencode: '--server http://synthetic-server' } } - }) - mocks.prepareAiVaultSessionForResume.mockResolvedValue(session) - mocks.buildAiVaultResumeStartupForWorktree.mockReturnValue({ - command: 'opencode --server http://synthetic-server --session existing' - }) - renderActions().handleResume(session, 'worktree-2') - await waitFor(() => expect(mocks.launchAiVaultSessionInNewTab).toHaveBeenCalledTimes(1)) - expect(mocks.toastError).not.toHaveBeenCalled() - }) - it('opens the fork plan in the chosen workspace, never the plain resume plan', async () => { renderActions().handleResumeInNewCli(owned, 'worktree-2') @@ -168,63 +153,32 @@ describe('Resume in New CLI launch', () => { expect(mocks.toastError).not.toHaveBeenCalled() }) - it("opens the copy an OpenCode or OMP chat's host made with a plain resume", async () => { - const chat = { ...owned, agent: 'omp' as const, resumeCommand: 'omp --resume provider-1' } - const copy = { ...chat, sessionId: 'copy-1', filePath: '', resumeCommand: '' } - const RESUME_STARTUP = { command: "omp --resume 'copy-1'", cwd: '/repo' } - mocks.prepareAiVaultSessionForFork.mockResolvedValue({ session: copy, copied: true }) - mocks.buildAiVaultResumeStartupForWorktree.mockReturnValue(RESUME_STARTUP) - - renderActions().handleResumeInNewCli(chat, 'worktree-2') - - await waitFor(() => expect(mocks.launchAiVaultSessionInNewTab).toHaveBeenCalledTimes(1)) - expect(mocks.buildAiVaultResumeStartupForWorktree).toHaveBeenCalledWith( - expect.objectContaining({ worktreeId: 'worktree-2', session: copy }) - ) - expect(mocks.buildAiVaultForkStartupForWorktree).not.toHaveBeenCalled() - expect(mocks.launchAiVaultSessionInNewTab).toHaveBeenCalledWith({ - agent: 'omp', - worktreeId: 'worktree-2', - ...RESUME_STARTUP - }) - expect(mocks.toastError).not.toHaveBeenCalled() - }) - - it('launches an OpenCode 2 copy as its actual opencode provider', async () => { - const chat = { ...owned, agent: 'opencode2' as const } - const copy = { - ...chat, - agent: 'opencode' as const, - sessionId: 'copy-1', - filePath: '', - resumeCommand: '' - } - const launchConfig = { - agentArgs: '', - agentEnv: { XDG_DATA_HOME: '/account-a/data', XDG_STATE_HOME: '/account-a/state' } - } - mocks.prepareAiVaultSessionForFork.mockResolvedValue({ - session: copy, - copied: true - }) - mocks.buildAiVaultResumeStartupForWorktree.mockReturnValue({ - command: 'opencode --session copy-1', - launchConfig, - providerSession: { key: 'session_id', id: 'copy-1' } - }) - renderActions().handleResumeInNewCli(chat, 'worktree-2') - await waitFor(() => expect(mocks.launchAiVaultSessionInNewTab).toHaveBeenCalledTimes(1)) - expect(mocks.buildAiVaultResumeStartupForWorktree).toHaveBeenCalledWith( - expect.objectContaining({ session: copy }) - ) - expect(mocks.launchAiVaultSessionInNewTab).toHaveBeenCalledWith( - expect.objectContaining({ - agent: 'opencode', - launchConfig, + it.each(['omp', 'opencode2'] as const)( + 'opens the %s copy with the actual CLI in the chosen workspace', + async (agent) => { + const chat = { ...owned, agent } + const copy = { + ...chat, + agent: agent === 'omp' ? ('omp' as const) : ('opencode' as const), + sessionId: 'copy-1' + } + const startup = { + command: `${copy.agent} --resume copy-1`, providerSession: { key: 'session_id', id: 'copy-1' } + } + mocks.prepareAiVaultSessionForFork.mockResolvedValue({ session: copy, copied: true }) + mocks.buildAiVaultResumeStartupForWorktree.mockReturnValue(startup) + renderActions().handleResumeInNewCli(chat, 'worktree-2') + await waitFor(() => expect(mocks.launchAiVaultSessionInNewTab).toHaveBeenCalledTimes(1)) + expect(mocks.launchAiVaultSessionInNewTab).toHaveBeenCalledWith({ + agent: copy.agent, + worktreeId: 'worktree-2', + ...startup }) - ) - }) + expect(mocks.buildAiVaultForkStartupForWorktree).not.toHaveBeenCalled() + expect(mocks.toastError).not.toHaveBeenCalled() + } + ) it('shows one toast and opens nothing when the agent cannot fork', async () => { mocks.buildAiVaultForkStartupForWorktree.mockReturnValue(null) diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts index 2afb05bfd7c7..05d5e4ca774d 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts @@ -204,15 +204,6 @@ describe('resolveTabSessionHistorySubject', () => { } ) - it('skips a chat of an agent that cannot copy its conversation', () => { - expect( - resolveTabSessionHistorySubject(makeState(), { - tab: { id: 'unified-1', worktreeId: WORKTREE_ID }, - nativeChat: { sessionId: 'orca-chat-1', agent: 'gemini' } - }) - ).toBeNull() - }) - it('finds a terminal tab by the conversation its agent reported', () => { expect( resolveTabSessionHistorySubject(makeState(liveClaudeEntry), { diff --git a/src/renderer/src/lib/ai-vault-session-fork-startup.test.ts b/src/renderer/src/lib/ai-vault-session-fork-startup.test.ts index 055c32a58a58..14f21a2c8571 100644 --- a/src/renderer/src/lib/ai-vault-session-fork-startup.test.ts +++ b/src/renderer/src/lib/ai-vault-session-fork-startup.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store/types' -import { - buildAiVaultCopiedSessionStartupForWorktree, - buildAiVaultForkStartupForWorktree -} from './ai-vault-session-fork-startup' +import { buildAiVaultForkStartupForWorktree } from './ai-vault-session-fork-startup' vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'darwin' @@ -86,48 +83,3 @@ describe('AI Vault fork into the CLI', () => { ).toBeNull() }) }) - -describe('copied OpenCode 2 launch mode', () => { - const session = { - agent: 'opencode', - sessionId: 'ses_copied', - cwd: '/Users/ada/repo', - codexHome: null - } as const - - it('adds stock standalone mode once to the actual provider', () => { - const startup = buildAiVaultCopiedSessionStartupForWorktree( - { state: makeState(), session }, - 'opencode2' - ) - expect(startup.command).toBe("opencode '--standalone' '--session' 'ses_copied'") - expect(startup.launchConfig?.agentArgs).toBe("'--standalone'") - }) - - it.each(['argument', 'settings'])('lets an explicit %s executable control its mode', (source) => { - const state = makeState() - if (source === 'settings' && state.settings) { - state.settings.agentCmdOverrides = { opencode: '/synthetic/team-opencode' } - } - const startup = buildAiVaultCopiedSessionStartupForWorktree( - { - state, - session, - ...(source === 'argument' ? { commandOverride: '/synthetic/team-opencode' } : {}) - }, - 'opencode2' - ) - expect(startup.command).toBe("/synthetic/team-opencode '--session' 'ses_copied'") - expect(startup.launchConfig?.agentArgs).toBe('') - }) - - it('keeps already configured standalone without injecting it again', () => { - const state = makeState() - if (state.settings) { - state.settings.agentDefaultArgs = { opencode: '--standalone --port 4321' } - } - const startup = buildAiVaultCopiedSessionStartupForWorktree({ state, session }, 'opencode2') - expect(startup.command).toBe("opencode '--standalone' '--port' '4321' '--session' 'ses_copied'") - expect(startup.launchConfig?.agentArgs).toBe('--standalone --port 4321') - }) -}) diff --git a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts index cfccc4c72698..b4ae9067377c 100644 --- a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts @@ -166,51 +166,6 @@ describe('prepareAiVaultSessionForFork', () => { expect(prepareSessionResume).not.toHaveBeenCalled() }) - it.each(['opencode', 'opencode2', 'omp'] as const)( - "has the %s chat's host make the copy, and opens that copy, which no chat owns", - async (agent) => { - const prepareSessionResume = vi.fn().mockResolvedValue({ - useRealCodexHome: false, - forkedSession: { - sessionId: 'copy-1', - filePath: '/sessions/copy-1.jsonl', - agent: agent === 'opencode2' ? 'opencode' : agent - } - }) - stubPreparation(prepareSessionResume) - const owned = session({ - agent, - sessionId: 'chat-conversation', - filePath: '/tmp/chat-conversation.jsonl', - resumeCommand: `${agent} --resume chat-conversation`, - executionHostId: 'runtime:env-1', - structuredSession: { sessionId: 'session-1', workspaceId: 'worktree-1' } - }) - - const prepared = await prepareAiVaultSessionForFork(owned) - - expect(prepareSessionResume).toHaveBeenCalledWith({ - agent, - sessionId: 'chat-conversation', - filePath: '/tmp/chat-conversation.jsonl', - codexHome: null, - executionHostId: 'runtime:env-1', - fork: true - }) - expect(prepared.copied).toBe(true) - // An OpenCode 2.x chat's conversation lists as `opencode2`; its copy opens with `opencode`. - expect(prepared.session).toMatchObject({ - agent: agent === 'opencode2' ? 'opencode' : agent, - sessionId: 'copy-1', - filePath: '/sessions/copy-1.jsonl', - resumeCommand: '', - cwd: '/repo', - executionHostId: 'runtime:env-1' - }) - expect(prepared.session).not.toHaveProperty('structuredSession') - } - ) - it("refuses a host that made no copy rather than resuming the chat's own conversation", async () => { stubPreparation(vi.fn().mockResolvedValue({ useRealCodexHome: false })) const owned = session({ diff --git a/src/renderer/src/runtime/web-runtime-conversation-copy-resume.test.ts b/src/renderer/src/runtime/web-runtime-conversation-copy-resume.test.ts deleted file mode 100644 index 5b3451b96288..000000000000 --- a/src/renderer/src/runtime/web-runtime-conversation-copy-resume.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { buildAiVaultCopiedSessionStartupForWorktree } from '@/lib/ai-vault-session-fork-startup' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as TrackingDecisions from './web-session-tabs-sync/tracking-decisions' -import { createWebRuntimeSessionTerminal } from './web-runtime-terminal-creation' -import { resetWebSessionCloseIntentForTests } from './web-session-close-intent' -import { - ENVIRONMENT_ID, - FOCUS_LEAF_ID, - WORKTREE_ID, - makeSnapshot, - resetTerminalCreateEnvironment, - stubTerminalCreateEnvironment -} from './web-runtime-session-test-harness' -import { stubRuntimeEnvironmentBridge } from './runtime-environment-bridge-fixture' - -const mocks = vi.hoisted(() => ({ - getState: vi.fn(), - setState: vi.fn(), - subscribe: vi.fn(), - setActiveWorktree: vi.fn(), - createBrowserTab: vi.fn(), - closeEmptyGroup: vi.fn(), - moveUnifiedTabToGroup: vi.fn(), - setRemoteBrowserPageHandle: vi.fn(), - focusBrowserTabInWorktree: vi.fn(), - applyWebSessionTabsSnapshot: vi.fn(), - decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), - getWebSessionTabsTrackingGeneration: vi.fn(() => 0), - acceptReplayedWebSessionTabsSnapshot: vi.fn(), - resolveHostSessionTabIdForWebSessionTab: vi.fn(), - trackTerminalPaneSplit: vi.fn(), - deliverLaunchPromptToAgentTab: vi.fn(), - seedNativeChatLaunchDraftForAgentTab: vi.fn(), - getRuntimeEnvironmentIdForWorktree: vi.fn(), - hasMaterializedWebRuntimeBrowserPage: vi.fn() -})) - -vi.mock('../store', () => ({ - useAppStore: { - getState: mocks.getState, - setState: mocks.setState, - subscribe: mocks.subscribe - } -})) - -vi.mock('./web-session-tabs-sync/tracking-lifecycle', () => ({ - acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, - getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration -})) -vi.mock('./web-session-tabs-sync/snapshot-api', () => ({ - applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot -})) -vi.mock('./web-session-tabs-sync/tracking-decisions', async (importOriginal) => ({ - ...(await importOriginal()), - decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot -})) -vi.mock('./web-session-tabs-sync/store-patch', () => ({ - applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { - mocks.setState(buildPatch) - // The production caller invokes the returned settle receipt. - return () => {} - } -})) -vi.mock('./web-session-tabs-sync/tracking-mappings', () => ({ - resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab -})) - -vi.mock('@/lib/feature-education-telemetry', () => ({ - trackTerminalPaneSplit: mocks.trackTerminalPaneSplit -})) - -vi.mock('@/lib/worktree-runtime-owner', () => ({ - getRuntimeEnvironmentIdForWorktree: mocks.getRuntimeEnvironmentIdForWorktree -})) - -vi.mock('@/lib/agent-launch-prompt-delivery', () => ({ - deliverLaunchPromptToAgentTab: mocks.deliverLaunchPromptToAgentTab, - seedNativeChatLaunchDraftForAgentTab: mocks.seedNativeChatLaunchDraftForAgentTab -})) - -vi.mock('./web-runtime-browser-materialization', () => ({ - hasMaterializedWebRuntimeBrowserPage: mocks.hasMaterializedWebRuntimeBrowserPage -})) - -afterEach(() => resetWebSessionCloseIntentForTests()) - -describe('copied conversation ordinary host resume', () => { - beforeEach(() => stubTerminalCreateEnvironment(mocks)) - afterEach(() => resetTerminalCreateEnvironment()) - - it.each([ - { agent: 'opencode' as const, sourceAgent: 'opencode2' as const, filePath: undefined }, - { - agent: 'omp' as const, - sourceAgent: 'omp' as const, - filePath: '/pinned-omp/sessions/copy.jsonl' - } - ])( - 'sends the actual $agent provider, mode and copied file through the ordinary host request', - async ({ agent, sourceAgent, filePath }) => { - const state = mocks.getState() - Object.assign(state, { - settings: { - ...state.settings, - agentDefaultEnv: { - opencode: { OPENCODE_AUTH_CONTENT: 'synthetic-client-auth', HOME: '/client-home' } - } - } - }) - const startup = buildAiVaultCopiedSessionStartupForWorktree( - { - state, - worktreeId: WORKTREE_ID, - session: { - agent, - sessionId: 'copy-session', - filePath, - cwd: '/repo', - codexHome: null, - executionHostId: 'runtime:web-env-1', - executionHostPlatform: 'linux' - } - }, - sourceAgent - ) - const runtimeCall = vi.fn(async (request: { method: string }) => { - if (request.method === 'status.get') { - return { - id: 'status', - ok: true, - result: { - runtimeId: 'runtime-1', - graphStatus: 'ready', - runtimeProtocolVersion: 3, - minCompatibleRuntimeClientVersion: 2, - capabilities: ['agent-session.host-authority.v1', 'agent-session.omp-resume-path.v1'] - } - } - } - if (request.method === 'terminal.ensureAgentSession') { - return { - id: 'resume', - ok: true, - result: { - terminal: { - handle: 'term_copy', - worktreeId: WORKTREE_ID, - tabId: 'host-copy', - paneKey: `host-copy:${FOCUS_LEAF_ID}` - }, - disposition: 'created' - } - } - } - return { id: 'list', ok: true, result: makeSnapshot() } - }) - stubRuntimeEnvironmentBridge({ call: runtimeCall }) - await expect( - createWebRuntimeSessionTerminal({ - worktreeId: WORKTREE_ID, - environmentId: ENVIRONMENT_ID, - agentSessionKind: 'resume', - launchAgent: agent, - ...startup - }) - ).resolves.toEqual({ status: 'created' }) - const request = runtimeCall.mock.calls.find( - ([request]) => request.method === 'terminal.ensureAgentSession' - )?.[0] - expect(request).toMatchObject({ - params: { - agent, - providerSession: { key: 'session_id', id: 'copy-session' }, - ...(filePath - ? { ompResumeFilePath: filePath } - : { agentArgs: expect.stringContaining('--standalone') }) - } - }) - expect(request).not.toHaveProperty('params.commandPath') - expect(request).not.toHaveProperty('params.launchConfig') - expect(JSON.stringify(request)).not.toContain('synthetic-client-auth') - expect(JSON.stringify(request)).not.toContain('/client-home') - } - ) -}) diff --git a/src/shared/opencode-launch-command.test.ts b/src/shared/opencode-launch-command.test.ts index 57d2f252951a..dc30a5290031 100644 --- a/src/shared/opencode-launch-command.test.ts +++ b/src/shared/opencode-launch-command.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - assertOpenCodeCopyUsesLocalStore, - isOpenCode2LaunchCommand, - selectOpenCodeHookAgent -} from './opencode-launch-command' +import { isOpenCode2LaunchCommand, selectOpenCodeHookAgent } from './opencode-launch-command' describe('isOpenCode2LaunchCommand', () => { it.each(['opencode2', '/usr/local/bin/opencode2', 'opencode2.exe', 'opencode2.cmd'])( @@ -44,20 +40,3 @@ describe('selectOpenCodeHookAgent after a sleep prefix', () => { ).toBeNull() }) }) - -describe('OpenCode host-local copy launch', () => { - it.each( - [['--server', 'http://synthetic-server'], ['--server=http://synthetic-server']].map((args) => ({ - args - })) - )('refuses an explicit server route before a copy', ({ args }) => { - expect(() => assertOpenCodeCopyUsesLocalStore(args)).toThrow('Remove --server') - }) - it.each( - [[], ['--standalone'], ['--', '--server'], ['--prompt=explain --server']].map((args) => ({ - args - })) - )('keeps ordinary local and positional argument meanings', ({ args }) => { - expect(() => assertOpenCodeCopyUsesLocalStore(args)).not.toThrow() - }) -}) diff --git a/src/shared/tui-agent-fork-startup.test.ts b/src/shared/tui-agent-fork-startup.test.ts index 0b2cee6590d9..e56b5b0892b4 100644 --- a/src/shared/tui-agent-fork-startup.test.ts +++ b/src/shared/tui-agent-fork-startup.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - agentForksConversations, - agentForksThroughChat, - getAgentForkArgv -} from './agent-resume-argv' +import { agentForksConversations, getAgentForkArgv } from './agent-resume-argv' import { buildAgentResumeStartupPlan } from './tui-agent-startup' const SESSION = { key: 'session_id', id: 's1' } as const @@ -17,28 +13,11 @@ describe('agent fork startup', () => { '/s/a.jsonl' ]) expect(getAgentForkArgv('pi', SESSION)).toBeNull() + expect(getAgentForkArgv('opencode', SESSION)).toBeNull() + expect(getAgentForkArgv('omp', SESSION)).toBeNull() expect(agentForksConversations('constructor')).toBe(false) }) - it('forks OpenCode and OMP through their chat, never a resume of the original', () => { - for (const agent of ['opencode', 'opencode2', 'omp'] as const) { - expect(agentForksConversations(agent)).toBe(true) - expect(agentForksThroughChat(agent)).toBe(true) - expect(getAgentForkArgv(agent, SESSION)).toBeNull() - expect( - buildAgentResumeStartupPlan({ - agent, - providerSession: SESSION, - cmdOverrides: {}, - platform: 'darwin', - fork: true - }) - ).toBeNull() - } - expect(agentForksThroughChat('claude')).toBe(false) - expect(agentForksConversations('gemini')).toBe(false) - }) - it('forks Claude and Codex', () => { expect(getAgentForkArgv('claude', SESSION)).toEqual([ 'claude', From c83b293ce23cfd449368ca57ae5775e98d802c34 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:50:39 -0700 Subject: [PATCH 6/7] fix(chat): validate OpenCode copies on their execution host --- .../ai-vault-session-cli-fork.ts | 4 +-- .../ai-vault-session-launch-actions.test.ts | 7 +++-- .../ai-vault-session-launch-actions.ts | 30 +++---------------- .../ai-vault-session-launch-feedback.ts | 24 +++++++++++++++ .../src/lib/ai-vault-session-fork-startup.ts | 6 +++- 5 files changed, 39 insertions(+), 32 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts index b2a2d40c0f3e..f2cf3221488d 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts @@ -32,7 +32,5 @@ export function aiVaultSessionCliForkWorktreeId( export function describeAiVaultCliForkFailure(message: string): string { // The desktop preparation's refusal arrives inside Electron's IPC wrapper. const code = compactIpcErrorMessage(message) - return isAiVaultOwnershipRefusal(message) - ? aiVaultCliForkHostTooOldMessage() - : (code ?? message) + return isAiVaultOwnershipRefusal(message) ? aiVaultCliForkHostTooOldMessage() : (code ?? message) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts index 81b4c1adb514..36b9ba0dc510 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts @@ -156,7 +156,7 @@ describe('Resume in New CLI launch', () => { it.each(['omp', 'opencode2'] as const)( 'opens the %s copy with the actual CLI in the chosen workspace', async (agent) => { - const chat = { ...owned, agent } + const chat = { ...owned, agent, executionHostId: 'runtime:paired-host' as const } const copy = { ...chat, agent: agent === 'omp' ? ('omp' as const) : ('opencode' as const), @@ -168,7 +168,10 @@ describe('Resume in New CLI launch', () => { } mocks.prepareAiVaultSessionForFork.mockResolvedValue({ session: copy, copied: true }) mocks.buildAiVaultResumeStartupForWorktree.mockReturnValue(startup) - renderActions().handleResumeInNewCli(chat, 'worktree-2') + renderActions({ opencode: 'opencode --server http://synthetic-server' }).handleResumeInNewCli( + chat, + 'worktree-2' + ) await waitFor(() => expect(mocks.launchAiVaultSessionInNewTab).toHaveBeenCalledTimes(1)) expect(mocks.launchAiVaultSessionInNewTab).toHaveBeenCalledWith({ agent: copy.agent, 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 8baee0b389fc..d31800c40fa8 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,7 @@ import { notifyAiVaultSessionPreparationFailure, - notifyAiVaultSessionQueued + notifyAiVaultSessionQueued, + resolveAiVaultSessionLaunchTargetOrNotify } from './ai-vault-session-launch-feedback' import { useCallback, useState } from 'react' import { toast } from 'sonner' @@ -34,15 +35,12 @@ 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 } from './ai-vault-session-resume-in-chat-launch' -import { - aiVaultResumeUnsupportedMessage, - resolveAiVaultSessionLaunchTarget, - resolveAiVaultTargetWorkspacePath -} from './ai-vault-session-launch-target' +import { resolveAiVaultTargetWorkspacePath } from './ai-vault-session-launch-target' export function useAiVaultSessionLaunchActions({ activeWorktree, @@ -299,23 +297,3 @@ export function useAiVaultSessionLaunchActions({ handleContinuationDialogOpenChange } } - -function resolveAiVaultSessionLaunchTargetOrNotify( - args: Parameters[0] -): Extract, { status: 'ready' }> | null { - const target = resolveAiVaultSessionLaunchTarget(args) - if (target.status === 'missing') { - toast.error( - translate( - 'auto.components.right.sidebar.AiVaultPanel.openWorkspaceBeforeResuming', - 'Open a workspace before resuming a session.' - ) - ) - return null - } - if (target.status === 'unsupported') { - toast.error(aiVaultResumeUnsupportedMessage(target.targetStatus)) - return null - } - return target -} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-feedback.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-feedback.ts index 54e8851fe97b..832cec2a84b8 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-feedback.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-feedback.ts @@ -1,3 +1,7 @@ +import { + aiVaultResumeUnsupportedMessage, + resolveAiVaultSessionLaunchTarget +} from './ai-vault-session-launch-target' import { toast } from 'sonner' import { describeAiVaultResumeFailure } from '@/lib/ai-vault-resume-refusal' import { translate } from '@/i18n/i18n' @@ -22,3 +26,23 @@ export function notifyAiVaultSessionPreparationFailure( const described = error instanceof Error ? new Error(describeFailure(error.message)) : error toast.error(describeAiVaultResumeFailure(described, agent)) } + +export function resolveAiVaultSessionLaunchTargetOrNotify( + args: Parameters[0] +): Extract, { status: 'ready' }> | null { + const target = resolveAiVaultSessionLaunchTarget(args) + if (target.status === 'missing') { + toast.error( + translate( + 'auto.components.right.sidebar.AiVaultPanel.openWorkspaceBeforeResuming', + 'Open a workspace before resuming a session.' + ) + ) + return null + } + if (target.status === 'unsupported') { + toast.error(aiVaultResumeUnsupportedMessage(target.targetStatus)) + return null + } + return target +} diff --git a/src/renderer/src/lib/ai-vault-session-fork-startup.ts b/src/renderer/src/lib/ai-vault-session-fork-startup.ts index 34b8a5d93e4b..43774321b28f 100644 --- a/src/renderer/src/lib/ai-vault-session-fork-startup.ts +++ b/src/renderer/src/lib/ai-vault-session-fork-startup.ts @@ -1,3 +1,4 @@ +import { parseExecutionHostId } from '../../../shared/execution-host' import { assertOpenCodeCopyUsesLocalStore } from '../../../shared/opencode-launch-command' import { findOptionOccurrence } from '../../../shared/command-option-occurrence' import { @@ -128,11 +129,14 @@ export function buildAiVaultCopiedSessionStartupForWorktree( } } -/** Check effective client launch choices before asking the host to save a copy. */ +/** Runtime hosts validate their own launch settings when making the copy. */ export function assertAiVaultCopiedSessionLaunchAllowed(args: AiVaultResumeWorktreeArgs): void { if (args.session.agent !== 'opencode' && args.session.agent !== 'opencode2') { return } + if (parseExecutionHostId(args.session.executionHostId)?.kind === 'runtime') { + return + } const { platform, liveShell } = resolveAiVaultResumeHost(args) const command = args.commandOverride?.trim() || From 0b85f35ac4bc8ca4f31cc82955f288dfe9678aa8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:40:23 -0700 Subject: [PATCH 7/7] test(chat): expect copied OMP forks after main integration --- src/main/ipc/ai-vault-resume.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/ipc/ai-vault-resume.test.ts b/src/main/ipc/ai-vault-resume.test.ts index 89187a0593bc..de1952a298a7 100644 --- a/src/main/ipc/ai-vault-resume.test.ts +++ b/src/main/ipc/ai-vault-resume.test.ts @@ -1,7 +1,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { - installOwnership, + installCopyingChat, PROVIDER_SESSION } from '../ai-vault/structured-session-ownership.test-support' import { prepareAiVaultSessionResume } from './ai-vault-resume' @@ -10,7 +10,10 @@ 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 } }) + installCopyingChat( + { transport: 'acp', agent: 'omp', nativeId: PROVIDER_SESSION }, + vi.fn(async () => ({ sessionId: 'copy-1', filePath: '/sessions/copy-1.jsonl' })) + ) const local = { agent: 'omp' as const, sessionId: PROVIDER_SESSION, @@ -26,7 +29,8 @@ it('refuses the native owner while preserving forks and other execution hosts', ) expect(prepareSessionResume).not.toHaveBeenCalled() await expect(prepareAiVaultSessionResume({ ...local, fork: true }, options)).resolves.toEqual({ - useRealCodexHome: false + useRealCodexHome: false, + forkedSession: { agent: 'omp', sessionId: 'copy-1', filePath: '/sessions/copy-1.jsonl' } }) const paired = { ...local, executionHostId: 'runtime:paired' as const } await expect(prepareAiVaultSessionResume(paired, options)).resolves.toEqual({