From 76d1c68ab5c8b90eab7bfdf027f54e23df239461 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:25:08 -0700 Subject: [PATCH] Resume in New Native Chat for OpenCode (#28072) * feat(chat): Resume in New Native Chat for OpenCode terminal conversations An OpenCode conversation started in the terminal now offers "Resume in New Native Chat" in its Session History row menu and its tab's right-click menu, as Claude, Codex, Grok and OMP do: a new OpenCode chat takes it over, showing its history and continuing it. OpenCode 2.x rows (`opencode2`) and 1.x rows both open an OpenCode chat. Two pieces kept OpenCode off the shared adoption path. Its account is a locator, not a folder, so the transcript import now works over the account the chat pins, whatever its kind; the folder agents wrap their lookups in directoryTranscriptImport, unchanged. Its conversations live in a database, so the journal import reads the found database with the reader the OpenCode transcript view already uses. OpenCode refuses to reopen a session from another folder, so the workspace rule covers it too. * test(chat): Pi is now the agent with no transcript import * fix(chat): find an OpenCode 2.x terminal tab's Session History row The pane reports `opencode` while Session History lists the conversation as `opencode2`, so the pane header's and tab menu's Resume in New Native Chat found no row. Match by the chat agent that takes the conversation over. * Fix OpenCode chat adoption environment and pane eligibility * test(chat): adapt parent adoption fixtures to account identities * test: keep only the tests that pin user-visible behavior * refactor(chat): map a Session History row to its chat agent where the chat starts Both Resume in New Native Chat callers repeated the row-to-chat agent lookup. The launch now takes the row agent and maps it once, so the Session History actions file stays unchanged. --- .../vitest-sqlite-database-runtime-files.mjs | 1 + src/main/acp/acp-launch-specs.ts | 3 +- src/main/claude/claude-cli-conversations.ts | 8 +- src/main/codex/codex-cli-conversations.ts | 8 +- src/main/grok/grok-transcript-import.ts | 6 +- .../journal-legacy-import.ts | 45 ++++- ...tured-agent-session-adopted-import.test.ts | 117 ++++++++---- ...ed-agent-cli-conversation-adoption.test.ts | 171 +++++++++++++++--- .../structured-agent-cli-conversations.ts | 40 ++++ ...red-agent-session-history-adoption.test.ts | 45 +---- ...ructured-agent-session-history-adoption.ts | 25 +-- src/main/omp/omp-session-cwd-lookup.test.ts | 2 +- src/main/omp/omp-transcript-import.ts | 6 +- .../opencode-structured-account-home.test.ts | 2 +- .../opencode-structured-account-home.ts | 2 +- .../opencode/opencode-transcript-import.ts | 39 ++++ ...structured-agent-session-create-support.ts | 27 +-- ...ctured-agent-session-create-intent.test.ts | 2 +- .../structured-agent-launch-support.ts | 19 +- ...tructured-agent-session-create-adoption.ts | 3 +- ...ault-session-resume-in-chat-launch.test.ts | 8 + .../ai-vault-session-resume-in-chat-launch.ts | 9 +- ...-vault-session-resume-in-chat-workspace.ts | 7 +- .../ai-vault-session-resume-in-chat.test.ts | 8 +- .../ai-vault-session-resume-in-chat.ts | 11 +- .../tab-session-history-switch.test.ts | 58 ++++-- .../tab-bar/tab-session-history-switch.ts | 15 +- ...uctured-agent-cli-conversation-adoption.ts | 30 ++- 28 files changed, 510 insertions(+), 207 deletions(-) create mode 100644 src/main/opencode/opencode-transcript-import.ts diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index 200042b293a..43701691d05 100644 --- a/config/scripts/vitest-sqlite-database-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-database-runtime-files.mjs @@ -15,6 +15,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/codex/codex-index-heal-binary-contract.test.ts', 'src/main/codex/codex-state-db.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts', + 'src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts', 'src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts', 'src/main/native-chat/transcript-opencode-sqlite-query.test.ts', 'src/main/native-chat/transcript-opencode-subscribe.test.ts', diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 789e06084d9..abd489e8633 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -34,6 +34,7 @@ import type { import { GROK_SESSION_HISTORY } from '../grok/grok-session-history' import { GROK_TRANSCRIPT_IMPORT } from '../grok/grok-transcript-import' import { OPENCODE_SESSION_HISTORY } from '../opencode/opencode-session-history' +import { OPENCODE_TRANSCRIPT_IMPORT } from '../opencode/opencode-transcript-import' import { OMP_SESSION_HISTORY } from '../omp/omp-session-history' import { OMP_TRANSCRIPT_IMPORT } from '../omp/omp-transcript-import' @@ -173,7 +174,7 @@ const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = { }, visualsSkill: loadOpenCodeVisualsSkill, compaction: true, - transcriptImport: null, + transcriptImport: OPENCODE_TRANSCRIPT_IMPORT, sessionHistory: OPENCODE_SESSION_HISTORY } diff --git a/src/main/claude/claude-cli-conversations.ts b/src/main/claude/claude-cli-conversations.ts index db47cedaeca..47e44e00fc5 100644 --- a/src/main/claude/claude-cli-conversations.ts +++ b/src/main/claude/claude-cli-conversations.ts @@ -3,14 +3,14 @@ import { join } from 'node:path' import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { + directoryTranscriptImport, resumeInvocationFromOptions, type StructuredAgentResumeInvocation, type StructuredAgentCliConversations, - type StructuredAgentSessionHistory, - type StructuredAgentTranscriptImport + type StructuredAgentSessionHistory } from '../native-chat/structured-agent-cli-conversations' -const CLAUDE_TRANSCRIPT_IMPORT: StructuredAgentTranscriptImport = { +const CLAUDE_TRANSCRIPT_IMPORT = directoryTranscriptImport({ accountHomeCandidates: ({ selectedAccountHomePath }) => [ selectedAccountHomePath, join(homedir(), '.claude'), @@ -20,7 +20,7 @@ const CLAUDE_TRANSCRIPT_IMPORT: StructuredAgentTranscriptImport = { resolveSessionFilePath('claude', providerSessionId, { claudeProjectsDir: join(accountHomePath, 'projects') }) -} +}) const CLAUDE_SESSION_HISTORY: StructuredAgentSessionHistory = { rowAgents: ['claude'], diff --git a/src/main/codex/codex-cli-conversations.ts b/src/main/codex/codex-cli-conversations.ts index cb24cf8c492..49f1e0c6f6e 100644 --- a/src/main/codex/codex-cli-conversations.ts +++ b/src/main/codex/codex-cli-conversations.ts @@ -2,14 +2,14 @@ import { join } from 'node:path' import { configuredAdditionalCodexHomePaths } from '../ai-vault/cached-session-list' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { + directoryTranscriptImport, resumeInvocationAfterMarker, type StructuredAgentCliConversations, - type StructuredAgentSessionHistory, - type StructuredAgentTranscriptImport + type StructuredAgentSessionHistory } from '../native-chat/structured-agent-cli-conversations' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' -const CODEX_TRANSCRIPT_IMPORT: StructuredAgentTranscriptImport = { +const CODEX_TRANSCRIPT_IMPORT = directoryTranscriptImport({ accountHomeCandidates: ({ settings, selectedAccountHomePath }) => [ selectedAccountHomePath, ...(settings.codexManagedAccounts ?? []).map((account) => account.managedHomePath), @@ -21,7 +21,7 @@ const CODEX_TRANSCRIPT_IMPORT: StructuredAgentTranscriptImport = { resolveSessionFilePath('codex', providerSessionId, { codexSessionsDirs: [join(accountHomePath, 'sessions')] }) -} +}) const CODEX_SESSION_HISTORY: StructuredAgentSessionHistory = { rowAgents: ['codex'], diff --git a/src/main/grok/grok-transcript-import.ts b/src/main/grok/grok-transcript-import.ts index 8aadcbbf467..7590bef9dc3 100644 --- a/src/main/grok/grok-transcript-import.ts +++ b/src/main/grok/grok-transcript-import.ts @@ -1,9 +1,9 @@ import { join } from 'node:path' import { findGrokChatHistoryByCwd, resolveGrokHomeDir } from '../../shared/grok-session-paths' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' -import type { StructuredAgentTranscriptImport } from '../native-chat/structured-agent-cli-conversations' +import { directoryTranscriptImport } from '../native-chat/structured-agent-cli-conversations' -export const GROK_TRANSCRIPT_IMPORT: StructuredAgentTranscriptImport = { +export const GROK_TRANSCRIPT_IMPORT = directoryTranscriptImport({ // The chat's own home, then the one Session History lists Grok's conversations from. accountHomeCandidates: ({ selectedAccountHomePath }) => [ selectedAccountHomePath, @@ -19,4 +19,4 @@ export const GROK_TRANSCRIPT_IMPORT: StructuredAgentTranscriptImport = { })) ?? resolveSessionFilePath('grok', providerSessionId, { grokSessionsDir: sessionsDir }) ) } -} +}) diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index 1e6171e94aa..f9fc7cfab80 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -28,6 +28,7 @@ import { decodeOmpTranscriptLine } from '../transcript-line-decoders' import { decodeTranscriptStream } from '../transcript-stream-lines' +import { readOpenCodeNativeChatTranscriptFull } from '../transcript-opencode' import { boundSubagentEntryId } from '../subagent-entry-id-bounds' import { createLegacyIdentityTracker } from './journal-legacy-identity' import type { JournalReplacementItem } from './journal-epoch-replacement' @@ -89,9 +90,12 @@ export async function prepareLegacyTranscriptImport(input: { const options = input.options ?? {} const limits = options.limits ?? DEFAULT_JOURNAL_PAYLOAD_LIMITS const transcriptAgent = resolveNativeChatTranscriptAgent(input.agent) - if (!transcriptAgent || transcriptAgent === 'opencode') { + if (!transcriptAgent) { return { ok: false, error: `Unsupported agent for journal import: ${input.agent}` } } + if (transcriptAgent === 'opencode') { + return prepareOpenCodeImport({ ...input, limits }) + } const filePath = options.filePath ?? (await resolveSessionFilePath(input.agent, input.sessionId, options)) if (!filePath) { @@ -129,9 +133,42 @@ export async function prepareLegacyTranscriptImport(input: { if (decoded.identities.length !== decoded.messages.length) { return { ok: false, error: 'Legacy transcript identity coverage is incomplete' } } + return { ok: true, items: replacementItems(decoded.messages, decoded.identities, limits) } +} + +/** OpenCode keeps conversations in a database (`filePath` names it), read by the reader its + * transcript view uses; its message ids are stable, so they are the identities. */ +async function prepareOpenCodeImport(input: { + agent: AgentType + sessionId: string + options?: LegacyImportOptions + limits: JournalPayloadLimits +}): Promise<{ ok: true; items: JournalReplacementItem[] } | { ok: false; error: string }> { + const dbPath = input.options?.filePath + const read = await readOpenCodeNativeChatTranscriptFull( + input.sessionId, + dbPath ? { resolveDbPath: async () => dbPath } : {} + ) + if ('error' in read) { + return { ok: false, error: read.error } + } + const identities = read.messages.map((message): AgentJournalItemIdentity => ({ + provider: 'legacy', + agent: input.agent, + sessionId: input.sessionId, + recordId: message.id + })) + return { ok: true, items: replacementItems(read.messages, identities, input.limits) } +} + +function replacementItems( + messages: readonly NativeChatMessage[], + identities: readonly AgentJournalItemIdentity[], + limits: JournalPayloadLimits +): JournalReplacementItem[] { const replacement: JournalReplacementItem[] = [] - for (const [index, message] of decoded.messages.entries()) { - const identity = decoded.identities[index] + for (const [index, message] of messages.entries()) { + const identity = identities[index] if (!identity) { continue } @@ -141,7 +178,7 @@ export async function prepareLegacyTranscriptImport(input: { observedAt: message.timestamp ?? undefined }) } - return { ok: true, items: replacement } + return replacement } const TRANSCRIPT_DECODERS = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index d1116094d2d..cd9206afd5a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -27,6 +27,20 @@ import { } from './structured-agent-session-adapter-router-test-support' import { acpStructuredAgentDefinition } from '../../acp/acp-structured-agent-definitions' import { acpLaunchSpecFor } from '../../acp/acp-launch-specs' +import { writeOpenCodeSqliteDatabase } from '../../ai-vault/session-scanner-opencode-sqlite-fixture' + +// The OpenCode reader's worker, run in process. +vi.mock('../../ai-vault/session-scanner-opencode-sqlite-worker-spawn', async (importOriginal) => { + const query = await import('../transcript-opencode-sqlite-query') + return { + ...(await importOriginal()), + readOpenCodeTranscriptPageViaWorker: async ( + args: Parameters[0] + ) => query.readOpenCodeTranscriptPage(args), + readOpenCodeTranscriptSignalViaWorker: async (args: { dbPath: string; sessionId: string }) => + query.readOpenCodeTranscriptSignal(args.dbPath, args.sessionId) + } +}) const NOW = 1_800_000_000_000 const SESSION = 'codex_adopting_session' @@ -171,49 +185,72 @@ describe('adopting a provider conversation on create', () => { expect(JSON.stringify(page.items)).toContain('ORCA-ADOPT-1') }) - it("seeds an ACP agent's chain with the session it adopts and imports that CLI's history", async () => { - root = await mkdtemp(join(tmpdir(), 'orca-adopt-acp-')) - const transcriptPath = join(root, 'chat_history.jsonl') - await writeFile( - transcriptPath, - `${JSON.stringify({ type: 'user', content: [{ type: 'text', text: 'ORCA-GROK-ADOPT' }] })}\n` - ) - const grok = acpLaunchSpecFor('grok') - if (!grok) { - throw new Error('this build registers no Grok chats') - } - const handle = { transport: 'acp', agent: 'grok', nativeId: THREAD } - const sessionAdapter = adapter() - vi.mocked(sessionAdapter.acquire).mockImplementation(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, - link: { - linkId: 'resumed-link', - handle, - origin: 'resumed', - mintedAtFence: fence, - observedAt: NOW + it.each([ + { + agent: 'grok', + accountHome: { variable: 'GROK_HOME', path: '/home/dev/.grok' }, + record: async (dir: string, text: string) => { + const path = join(dir, 'chat_history.jsonl') + await writeFile( + path, + `${JSON.stringify({ type: 'user', content: [{ type: 'text', text }] })}\n` + ) + return path } - })) + }, + { + agent: 'opencode', + accountHome: { kind: 'opencode', locator: { kind: 'unmanaged' } }, + record: async (dir: string, text: string) => { + const path = join(dir, 'opencode.db') + writeOpenCodeSqliteDatabase(path, [ + { id: THREAD, turns: [{ role: 'user', parts: [text] }] } + ]) + return path + } + } + ] as const)( + "seeds a $agent chat's chain with the session it adopts and imports that CLI's history", + async ({ agent, accountHome, record }) => { + root = await mkdtemp(join(tmpdir(), `orca-adopt-${agent}-`)) + const transcriptPath = await record(root, `ORCA-${agent}-ADOPT`) + const spec = acpLaunchSpecFor(agent) + if (!spec) { + throw new Error(`this build registers no ${agent} chats`) + } + const handle = { transport: 'acp', agent, nativeId: THREAD } + const sessionAdapter = adapter() + vi.mocked(sessionAdapter.acquire).mockImplementation(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, + link: { + linkId: 'resumed-link', + handle, + origin: 'resumed', + mintedAtFence: fence, + observedAt: NOW + } + })) - const result = await attach( - transcriptPath, - sessionAdapter, - () => {}, - attachParams(transcriptPath, { - provider: 'grok', - agent: 'grok', - accountHome: { variable: 'GROK_HOME', path: '/home/dev/.grok' }, - adopt: { providerHandle: handle, transcriptPath } - }), - structuredAgentsDeclaringNothing([acpStructuredAgentDefinition(grok)]) - ) + const result = await attach( + transcriptPath, + sessionAdapter, + () => {}, + attachParams(transcriptPath, { + provider: agent, + agent, + accountHome, + adopt: { providerHandle: handle, transcriptPath } + }), + structuredAgentsDeclaringNothing([acpStructuredAgentDefinition(spec)]) + ) - expect(result.ok && JSON.stringify(result.value.page.items)).toContain('ORCA-GROK-ADOPT') - expect(store?.getRecord(SESSION)?.providerHandleChain[0]).toMatchObject({ - origin: 'adopted', - handle - }) - }) + expect(result.ok && JSON.stringify(result.value.page.items)).toContain(`ORCA-${agent}-ADOPT`) + expect(store?.getRecord(SESSION)?.providerHandleChain[0]).toMatchObject({ + origin: 'adopted', + handle + }) + } + ) it('replays create without replacing journal-only messages or rereading the source', async () => { root = await mkdtemp(join(tmpdir(), 'orca-adopt-replay-')) diff --git a/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts b/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts index ab6edfa0ae7..720189aa7a6 100644 --- a/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts +++ b/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts @@ -1,7 +1,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CLI_CONVERSATION_ADOPTING_AGENTS } from '../../shared/structured-agent-cli-conversation-adoption' import { clearGrokSessionPathLookupCacheForTests, @@ -16,10 +16,33 @@ import { } from '../runtime/structured-agent-runtime-registrations' import { resolveStructuredAgentSessionAdoptionForCreate } from '../runtime/structured-agent-session-create-adoption' import { prepareLegacyTranscriptImport } from './agent-session-journal/journal-legacy-import' +import type { AgentSessionAccountHome } from '../../shared/agent-session-account-home' +import { writeOpenCodeSqliteDatabase } from '../ai-vault/session-scanner-opencode-sqlite-fixture' +import { resolveAcpLaunchInvocation } from '../acp/acp-structured-launch-resolution' +import { acpLaunchSpecFor } from '../acp/acp-launch-specs' +import { resolveOpenCodeDatabasePath } from '../opencode/opencode-data-directory' + +// The OpenCode reader's worker, run in process. +vi.mock('../ai-vault/session-scanner-opencode-sqlite-worker-spawn', async (importOriginal) => { + const query = await import('./transcript-opencode-sqlite-query') + return { + ...(await importOriginal()), + readOpenCodeTranscriptPageViaWorker: async ( + args: Parameters[0] + ) => query.readOpenCodeTranscriptPage(args), + readOpenCodeTranscriptSignalViaWorker: async (args: { dbPath: string; sessionId: string }) => + query.readOpenCodeTranscriptSignal(args.dbPath, args.sessionId) + } +}) const SESSION_ID = '0198c2a4-7d1e-7c3a-9a4b-1f2e3d4c5b6a' let root: string | null = null +beforeEach(() => { + // Grok also looks where Session History lists it from; keep that off the real home. + vi.stubEnv('GROK_HOME', join(tmpdir(), 'orca-adopt-no-grok-home')) +}) + afterEach(async () => { vi.unstubAllEnvs() vi.restoreAllMocks() @@ -71,13 +94,12 @@ const RECORDED: Record<'grok' | 'omp', (home: string, text: string) => Promise { - it('lists exactly the agents the host registers a transcript import for', () => { + it('maps exactly the row agents of the agents the host registers a transcript import for', () => { expect( - STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.filter((registration) => registration.transcriptImport) - .map((registration) => registration.definition.agent) - .toSorted() - ).toEqual([...CLI_CONVERSATION_ADOPTING_AGENTS].toSorted()) + Object.fromEntries( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.filter( + (registration) => registration.transcriptImport + ).flatMap((registration) => + registration.sessionHistory.rowAgents.map((row) => [row, registration.definition.agent]) + ) + ) + ).toEqual(CLI_CONVERSATION_ADOPTING_AGENTS) }) }) @@ -133,7 +159,17 @@ it.each([false, true])( })) expect(await findGrokChatHistoryBySessionId(join(root, 'sessions'), SESSION_ID)).toBeNull() - expect((await adopt('grok', root, process.env, workspaceCwd)).transcriptPath).toBe(currentFile) + expect( + ( + await adopt( + 'grok', + { variable: 'GROK_HOME', path: root }, + SESSION_ID, + process.env, + workspaceCwd + ) + ).transcriptPath + ).toBe(currentFile) } ) @@ -161,7 +197,16 @@ it('looks up OMP history under the configured launch XDG and profile root', asyn expect(ambientFile.startsWith(ambientSessions)).toBe(true) expect(launchFile.startsWith(launchSessions)).toBe(true) - expect((await adopt('omp', accountHome, launchEnv)).transcriptPath).toBe(launchFile) + expect( + ( + await adopt( + 'omp', + { variable: 'PI_CODING_AGENT_DIR', path: accountHome }, + SESSION_ID, + launchEnv + ) + ).transcriptPath + ).toBe(launchFile) }) it('uses the configured OMP profile when the ambient profile names another session', async () => { @@ -185,7 +230,16 @@ it('uses the configured OMP profile when the ambient profile names another sessi OMP_PROFILE: launchProfile, OMP_CODING_AGENT_DIR: '' } - expect((await adopt('omp', accountHome, launchEnv)).transcriptPath).toBe(launchFile) + expect( + ( + await adopt( + 'omp', + { variable: 'PI_CODING_AGENT_DIR', path: accountHome }, + SESSION_ID, + launchEnv + ) + ).transcriptPath + ).toBe(launchFile) }) it.each(['win32', 'linux', 'darwin'] as const)( @@ -202,11 +256,16 @@ it.each(['win32', 'linux', 'darwin'] as const)( ) vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) - const adoption = await adopt('omp', join(root, 'account'), { - HOME: home, - USERPROFILE: userProfile, - OMP_PROFILE: profile - }) + const adoption = await adopt( + 'omp', + { variable: 'PI_CODING_AGENT_DIR', path: join(root, 'account') }, + SESSION_ID, + { + HOME: home, + USERPROFILE: userProfile, + OMP_PROFILE: profile + } + ) expect(adoption.transcriptPath).toBe(platform === 'win32' ? userProfileFile : homeFile) } @@ -215,7 +274,15 @@ it.each(['win32', 'linux', 'darwin'] as const)( it('refuses OMP history from another workspace without creating a blank chat', async () => { root = await mkdtemp(join(tmpdir(), 'orca-omp-adopt-other-cwd-')) await RECORDED.omp(root, 'OTHER-WORKSPACE') - await expect(adopt('omp', root, process.env, '/another/workspace')).rejects.toMatchObject({ + await expect( + adopt( + 'omp', + { variable: 'PI_CODING_AGENT_DIR', path: root }, + SESSION_ID, + process.env, + '/another/workspace' + ) + ).rejects.toMatchObject({ refusal: { code: 'agent_session_identity_required' } }) }) @@ -225,9 +292,13 @@ describe.each(['grok', 'omp'] as const)('adopting a %s CLI conversation', (agent root = await mkdtemp(join(tmpdir(), `orca-${agent}-adopt-`)) const recorded = await RECORDED[agent](root, `ORCA-${agent}-ADOPT`) - const adoption = await adopt(agent, root) + const accountHome = { + variable: agent === 'grok' ? 'GROK_HOME' : 'PI_CODING_AGENT_DIR', + path: root + } + const adoption = await adopt(agent, accountHome) - expect(adoption).toEqual({ accountHomePath: root, transcriptPath: recorded }) + expect(adoption).toEqual({ accountHome, transcriptPath: recorded }) const imported = await prepareLegacyTranscriptImport({ agent, sessionId: SESSION_ID, @@ -238,8 +309,60 @@ describe.each(['grok', 'omp'] as const)('adopting a %s CLI conversation', (agent it('refuses a conversation the account does not hold, rather than opening a blank chat', async () => { root = await mkdtemp(join(tmpdir(), `orca-${agent}-adopt-missing-`)) - await expect(adopt(agent, root)).rejects.toMatchObject({ + await expect( + adopt(agent, { variable: agent === 'grok' ? 'GROK_HOME' : 'PI_CODING_AGENT_DIR', path: root }) + ).rejects.toMatchObject({ refusal: { code: 'agent_session_identity_required' } }) }) }) + +describe('adopting an OpenCode CLI conversation', () => { + const OPENCODE_SESSION = 'ses_orca_adopt' + const unmanaged: AgentSessionAccountHome = { kind: 'opencode', locator: { kind: 'unmanaged' } } + + function openCodeData(home: string): string { + vi.stubEnv('HOME', home) + vi.stubEnv('USERPROFILE', home) + vi.stubEnv('XDG_DATA_HOME', join(home, 'data')) + vi.stubEnv('OPENCODE_DB', '') + return join(home, 'data', 'opencode', 'opencode.db') + } + + it('imports from the configured database the resumed OpenCode child opens', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-opencode-configured-adopt-')) + const defaultDb = openCodeData(root) + const configuredDb = join(root, 'data', 'opencode', 'work.db') + writeOpenCodeSqliteDatabase(defaultDb, [ + { id: OPENCODE_SESSION, turns: [{ role: 'user', parts: ['wrong database'] }] } + ]) + writeOpenCodeSqliteDatabase(configuredDb, [ + { id: OPENCODE_SESSION, turns: [{ role: 'user', parts: ['configured database'] }] } + ]) + const baseEnvironment = { HOME: root, XDG_DATA_HOME: join(root, 'data') } + const launchEnv = { OPENCODE_DB: 'work.db' } + const adoption = await adopt('opencode', unmanaged, OPENCODE_SESSION, { + ...baseEnvironment, + ...launchEnv + }) + const spec = acpLaunchSpecFor('opencode') + if (!spec) { + throw new Error('OpenCode launch spec missing') + } + const launch = await resolveAcpLaunchInvocation(spec, unmanaged, { + resolveEnvironment: async () => baseEnvironment, + resolveLaunchEnv: () => launchEnv, + resolveCommand: () => '/test/opencode' + }) + + expect(adoption.transcriptPath).toBe(configuredDb) + expect(resolveOpenCodeDatabasePath(launch.env, launch.env.HOME)).toBe(configuredDb) + const imported = await prepareLegacyTranscriptImport({ + agent: 'opencode', + sessionId: OPENCODE_SESSION, + options: { filePath: adoption.transcriptPath } + }) + expect(imported.ok && JSON.stringify(imported.items)).toContain('configured database') + expect(imported.ok && JSON.stringify(imported.items)).not.toContain('wrong database') + }) +}) diff --git a/src/main/native-chat/structured-agent-cli-conversations.ts b/src/main/native-chat/structured-agent-cli-conversations.ts index 67e25bcf99d..f4ed02ecda4 100644 --- a/src/main/native-chat/structured-agent-cli-conversations.ts +++ b/src/main/native-chat/structured-agent-cli-conversations.ts @@ -1,6 +1,10 @@ // How a structured agent's chats meet the conversations its own CLI records: adopting one into a new // chat, and owning the Session History rows a chat's conversation shows as. +import { + isLegacyAgentSessionAccountHome, + type AgentSessionAccountHome +} from '../../shared/agent-session-account-home' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import type { AiVaultAgent } from '../../shared/ai-vault-types' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' @@ -14,6 +18,22 @@ export type StructuredAgentTranscriptImportSettings = { /** Finds a conversation the agent's CLI recorded, so a new chat can adopt it. */ export type StructuredAgentTranscriptImport = { /** Recognised account homes, most-preferred first; the first that holds the transcript wins. */ + accountHomeCandidates: (input: { + settings: StructuredAgentTranscriptImportSettings + selectedAccountHome: AgentSessionAccountHome + }) => AgentSessionAccountHome[] + /** Where the transcript is (its file, or OpenCode's database); null when the home lacks it. */ + findTranscript: (input: { + providerSessionId: string + accountHome: AgentSessionAccountHome + /** The execution host's effective agent environment before account binding. */ + launchEnv: NodeJS.ProcessEnv + workspaceCwd: string + }) => Promise +} + +/** The import of an agent whose account is one directory, named by the chat's own variable. */ +export function directoryTranscriptImport(directories: { accountHomeCandidates: (input: { settings: StructuredAgentTranscriptImportSettings selectedAccountHomePath: string @@ -24,6 +44,26 @@ export type StructuredAgentTranscriptImport = { launchEnv: NodeJS.ProcessEnv workspaceCwd: string }) => Promise +}): StructuredAgentTranscriptImport { + return { + accountHomeCandidates: ({ settings, selectedAccountHome }) => + isLegacyAgentSessionAccountHome(selectedAccountHome) + ? directories + .accountHomeCandidates({ settings, selectedAccountHomePath: selectedAccountHome.path }) + .map((path) => path.trim()) + .filter((path) => path.length > 0) + .map((path) => ({ variable: selectedAccountHome.variable, path })) + : [], + findTranscript: async ({ providerSessionId, accountHome, launchEnv, workspaceCwd }) => + isLegacyAgentSessionAccountHome(accountHome) + ? directories.findTranscript({ + providerSessionId, + accountHomePath: accountHome.path, + launchEnv, + workspaceCwd + }) + : null + } } /** A terminal resume's target: null when it may pick any conversation (`--continue`, a bare flag). */ diff --git a/src/main/native-chat/structured-agent-session-history-adoption.test.ts b/src/main/native-chat/structured-agent-session-history-adoption.test.ts index 069a462db07..f2fa0348d9a 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.test.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { agentSessionLeaseFixture, agentSessionRecordFixture @@ -181,50 +181,15 @@ describe('structuredAdoptionConflictError', () => { }) describe('resolveStructuredAgentSessionAdoption', () => { - it('takes the first candidate home that holds the transcript and probes no further', async () => { - const resolveTranscript = vi - .fn() - .mockResolvedValueOnce(null) - .mockResolvedValueOnce('/home/dev/.codex/sessions/thread-1.jsonl') - - await expect( - resolveStructuredAgentSessionAdoption({ - providerSessionId: 'thread-1', - candidateAccountHomes: ['/home/dev/.orca-codex', '/home/dev/.codex', '/never/probed'], - resolveTranscript, - launchEnv: {}, - workspaceCwd: '/repo' - }) - ).resolves.toEqual({ - accountHomePath: '/home/dev/.codex', - transcriptPath: '/home/dev/.codex/sessions/thread-1.jsonl' - }) - expect(resolveTranscript).toHaveBeenCalledTimes(2) - }) - - it('skips blank and repeated candidates instead of probing them again', async () => { - const resolveTranscript = vi.fn().mockResolvedValue(null) - - await expect( - resolveStructuredAgentSessionAdoption({ - providerSessionId: 'session-1', - candidateAccountHomes: ['', ' ', '/home/dev/.claude', ' /home/dev/.claude ', ''], - resolveTranscript, - launchEnv: {}, - workspaceCwd: '/repo' - }) - ).rejects.toThrow('agent_session_identity_required') - expect(resolveTranscript.mock.calls.map(([args]) => args.accountHomePath)).toEqual([ - '/home/dev/.claude' - ]) - }) - it('refuses rather than falling back to a home that does not hold the conversation', async () => { // A resume under the wrong home lands in a blank chat wearing the old chat's name. await expect( resolveStructuredAgentSessionAdoption({ providerSessionId: 'session-1', - candidateAccountHomes: ['/home/dev/.claude-work', '/home/dev/.claude'], + candidateAccountHomes: [ + { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, + { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' } + ], resolveTranscript: async () => null, launchEnv: {}, workspaceCwd: '/repo' diff --git a/src/main/native-chat/structured-agent-session-history-adoption.ts b/src/main/native-chat/structured-agent-session-history-adoption.ts index 3d32e1ce9f4..06581ae1858 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -13,6 +13,10 @@ import type { } from '../../shared/agent-session-provider-handle' import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease-adjudication' +import { + agentSessionAccountHomesEqual, + type AgentSessionAccountHome +} from '../../shared/agent-session-account-home' export type StructuredAgentSessionAdoptionOwnership = { sessionId: string @@ -23,8 +27,8 @@ export type StructuredAgentSessionAdoptionOwnership = { } export type StructuredAgentSessionAdoption = { - /** The account home the transcript was actually found under — never a client-supplied path. */ - accountHomePath: string + /** The account home the transcript was actually found under — never a client-supplied one. */ + accountHome: AgentSessionAccountHome transcriptPath: string } @@ -120,31 +124,30 @@ export function structuredAdoptionConflictError( */ export async function resolveStructuredAgentSessionAdoption(input: { providerSessionId: string - candidateAccountHomes: readonly string[] + candidateAccountHomes: readonly AgentSessionAccountHome[] resolveTranscript: (args: { providerSessionId: string - accountHomePath: string + accountHome: AgentSessionAccountHome launchEnv: NodeJS.ProcessEnv workspaceCwd: string }) => Promise launchEnv: NodeJS.ProcessEnv workspaceCwd: string }): Promise { - const seen = new Set() - for (const accountHomePath of input.candidateAccountHomes) { - const trimmed = accountHomePath.trim() - if (!trimmed || seen.has(trimmed)) { + const seen: AgentSessionAccountHome[] = [] + for (const accountHome of input.candidateAccountHomes) { + if (seen.some((home) => agentSessionAccountHomesEqual(home, accountHome))) { continue } - seen.add(trimmed) + seen.push(accountHome) const transcriptPath = await input.resolveTranscript({ providerSessionId: input.providerSessionId, - accountHomePath: trimmed, + accountHome, launchEnv: input.launchEnv, workspaceCwd: input.workspaceCwd }) if (transcriptPath) { - return { accountHomePath: trimmed, transcriptPath } + return { accountHome, transcriptPath } } } // Refuse rather than fall back to the default home. Resuming under a home that does not hold the diff --git a/src/main/omp/omp-session-cwd-lookup.test.ts b/src/main/omp/omp-session-cwd-lookup.test.ts index fd9dd3ef6d8..44a2f72ff39 100644 --- a/src/main/omp/omp-session-cwd-lookup.test.ts +++ b/src/main/omp/omp-session-cwd-lookup.test.ts @@ -41,7 +41,7 @@ async function record(bucket: string, file = FILE): Promise { function lookup(cwd = '/repo/orca', homeDir = join(root, 'home'), temp = join(root, 'temp')) { return OMP_TRANSCRIPT_IMPORT.findTranscript({ - accountHomePath: join(root, 'account'), + accountHome: { variable: 'PI_CODING_AGENT_DIR', path: join(root, 'account') }, providerSessionId: ID, workspaceCwd: cwd, launchEnv: { diff --git a/src/main/omp/omp-transcript-import.ts b/src/main/omp/omp-transcript-import.ts index 3e9d67d6a88..eb0c4ca1cd3 100644 --- a/src/main/omp/omp-transcript-import.ts +++ b/src/main/omp/omp-transcript-import.ts @@ -1,9 +1,9 @@ import { homedir } from 'node:os' import { resolveOmpSessionsDir } from '../ai-vault/omp-session-root' -import type { StructuredAgentTranscriptImport } from '../native-chat/structured-agent-cli-conversations' +import { directoryTranscriptImport } from '../native-chat/structured-agent-cli-conversations' import { findOmpSessionByCwd } from './omp-session-cwd-lookup' -export const OMP_TRANSCRIPT_IMPORT: StructuredAgentTranscriptImport = { +export const OMP_TRANSCRIPT_IMPORT = directoryTranscriptImport({ accountHomeCandidates: ({ selectedAccountHomePath }) => [selectedAccountHomePath], // Where OMP itself keeps the account's sessions, which is not always under it (XDG_DATA_HOME). findTranscript: async ({ providerSessionId, accountHomePath, launchEnv, workspaceCwd }) => { @@ -23,4 +23,4 @@ export const OMP_TRANSCRIPT_IMPORT: StructuredAgentTranscriptImport = { }) : null } -} +}) diff --git a/src/main/opencode/opencode-structured-account-home.test.ts b/src/main/opencode/opencode-structured-account-home.test.ts index 34300a3fed4..58e52b0c18e 100644 --- a/src/main/opencode/opencode-structured-account-home.test.ts +++ b/src/main/opencode/opencode-structured-account-home.test.ts @@ -55,7 +55,7 @@ describe('structured OpenCode account binding', () => { accounts.select(second) const environment = environmentForStructuredOpenCodeAccountHome(binding, { managedAccounts: accounts, - baseEnvironment: { PATH: '/bin' } + baseEnvironment: { PATH: '/bin', XDG_DATA_HOME: '/settings/data', OPENCODE_DB: 'work.db' } }) expect(environment.XDG_DATA_HOME).toBe(`/profiles/${first}/data`) expect(environment.OPENCODE_DB).toBe('opencode.db') diff --git a/src/main/opencode/opencode-structured-account-home.ts b/src/main/opencode/opencode-structured-account-home.ts index 57279a8aa05..9f65e0b26b3 100644 --- a/src/main/opencode/opencode-structured-account-home.ts +++ b/src/main/opencode/opencode-structured-account-home.ts @@ -55,7 +55,7 @@ export function environmentForStructuredOpenCodeAccountHome( binding: OpenCodeAgentSessionAccountHome, input: { managedAccounts: Pick - baseEnvironment: Record + baseEnvironment: NodeJS.ProcessEnv } ): Record { const environment = restoredEnvironment(input.baseEnvironment, input.managedAccounts) diff --git a/src/main/opencode/opencode-transcript-import.ts b/src/main/opencode/opencode-transcript-import.ts new file mode 100644 index 00000000000..359cef811c1 --- /dev/null +++ b/src/main/opencode/opencode-transcript-import.ts @@ -0,0 +1,39 @@ +import { existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { isLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' +import { getManagedDataAccountService } from '../managed-data-accounts/service' +import type { StructuredAgentTranscriptImport } from '../native-chat/structured-agent-cli-conversations' +import { resolveOpenCodeDatabasePath } from './opencode-data-directory' +import { environmentForStructuredOpenCodeAccountHome } from './opencode-structured-account-home' + +export const OPENCODE_TRANSCRIPT_IMPORT: StructuredAgentTranscriptImport = { + // The selected account, then the unmanaged environment this chat would launch with. + accountHomeCandidates: ({ selectedAccountHome }) => [ + selectedAccountHome, + { kind: 'opencode', locator: { kind: 'unmanaged' } } + ], + // The database the account's OpenCode opens, which is the only one its chat can load from. + findTranscript: async ({ providerSessionId, accountHome, launchEnv }) => { + if (isLegacyAgentSessionAccountHome(accountHome)) { + return null + } + const env = environmentForStructuredOpenCodeAccountHome(accountHome, { + managedAccounts: getManagedDataAccountService(), + baseEnvironment: launchEnv + }) + const dbPath = resolveOpenCodeDatabasePath( + env, + (process.platform === 'win32' ? env.USERPROFILE : env.HOME) || homedir() + ) + if (!dbPath || !existsSync(dbPath)) { + return null + } + const { readOpenCodeTranscriptSignalViaWorker } = + await import('../ai-vault/session-scanner-opencode-sqlite-worker-spawn') + const signal = await readOpenCodeTranscriptSignalViaWorker({ + dbPath, + sessionId: providerSessionId + }) + return signal ? dbPath : null + } +} diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 8f9a9c50c77..42593b34fc8 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -9,8 +9,10 @@ import { import { isNativeLocalLocation, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' -import { resolveHostAgentBaseEnvironment } from './structured-agent-shell-environment' -import { resolveHostStructuredAgentCreateSupport } from './structured-agent-launch-support' +import { + resolveHostStructuredAgentCreateSupport, + resolveHostStructuredAgentLaunchEnvironment +} from './structured-agent-launch-support' import { structuredAgentRuntimeRegistration } from './structured-agent-runtime-registrations' import { structuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults' import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime' @@ -22,10 +24,7 @@ import { isFloatingWorkspaceSelector } from '../../shared/floating-workspace-worktree' import { parseWorkspaceKey } from '../../shared/workspace-scope' -import { - isLegacyAgentSessionAccountHome, - type AgentSessionAccountHome -} from '../../shared/agent-session-account-home' +import type { AgentSessionAccountHome } from '../../shared/agent-session-account-home' import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithGetWorktreePs { @@ -222,17 +221,12 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR return committedReplay } const selectedAccountHome = await resolveAccountHome({ launchEnv, location }) - if (input.resumeFrom && !isLegacyAgentSessionAccountHome(selectedAccountHome)) { - throw agentSessionRefusalError('structured_agent_session_unsupported', { - reason: 'hostUnsupported' - }) - } // Adopting pins the account home to wherever the conversation actually lives, which is not // necessarily the one a fresh create would pick: Codex resolves its rollout under // `accountHome.path`, and Claude reads its transcript under `/projects`. Resuming under // the wrong home finds nothing and lands the user in a blank chat wearing the old chat's name. const adoption = - input.resumeFrom && transcriptImport && isLegacyAgentSessionAccountHome(selectedAccountHome) + input.resumeFrom && transcriptImport ? await resolveStructuredAgentSessionAdoptionForCreate({ host, settings, @@ -240,8 +234,8 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR transcriptImport, providerSessionId: input.resumeFrom.providerSessionId, selfSessionId: input.envelope.sessionId, - selectedAccountHomePath: selectedAccountHome.path, - launchEnv: { ...(await resolveHostAgentBaseEnvironment(settings)), ...launchEnv }, + selectedAccountHome, + launchEnv: await resolveHostStructuredAgentLaunchEnvironment(input.agent, settings), workspaceCwd: (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path }) : null @@ -255,10 +249,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR location, provider: input.agent, agent: input.agent, - accountHome: - adoption && isLegacyAgentSessionAccountHome(selectedAccountHome) - ? { variable: selectedAccountHome.variable, path: adoption.accountHomePath } - : selectedAccountHome, + accountHome: adoption ? adoption.accountHome : selectedAccountHome, ...(options ? { options } : {}), ...(input.resumeFrom && adoption ? { diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index a0540854ea4..27a986a51f7 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -398,7 +398,7 @@ describe('adopting a conversation recorded outside Orca', () => { runtime.resolveStructuredAgentSessionCreateIntent({ envelope: { sessionId: 'session-1', clientOperationId: 'operation-1' }, worktree: 'id:workspace-1', - agent: 'opencode', + agent: 'pi', resumeFrom: { providerSessionId: 'provider-1' } }) ).rejects.toMatchObject({ diff --git a/src/main/runtime/structured-agent-launch-support.ts b/src/main/runtime/structured-agent-launch-support.ts index f49a1e07ff1..c864cacf472 100644 --- a/src/main/runtime/structured-agent-launch-support.ts +++ b/src/main/runtime/structured-agent-launch-support.ts @@ -52,13 +52,7 @@ export async function structuredAgentSupportsLaunch( // was last read counts and a found one never waits. launch = await resolveAgainAfterRefreshOnMissingProgram( async () => - check.resolveLaunch( - { - ...(await resolveHostAgentBaseEnvironment(settings)), - ...(isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv) : {}) - }, - settings - ), + check.resolveLaunch(await resolveHostStructuredAgentLaunchEnvironment(agent, settings), settings), refreshHostAgentBaseEnvironmentAfterMiss, { env: ({ env }) => env, cwd } ) @@ -72,6 +66,17 @@ export async function structuredAgentSupportsLaunch( return check.supportsVersion({ ...launch, cwd: await cwd() }) } +/** The environment a new local chat launch starts from, before its account binding. */ +export async function resolveHostStructuredAgentLaunchEnvironment( + agent: string, + settings: LaunchEnvironmentSettings +): Promise> { + return { + ...(await resolveHostAgentBaseEnvironment(settings)), + ...(isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv) : {}) + } +} + /** `agentSession.createSupport` on this host: every refusal it can know before spawning. The * agent's location rule and installed-agent check from its registration, without installing the * host. A refusal logs which check said no. */ diff --git a/src/main/runtime/structured-agent-session-create-adoption.ts b/src/main/runtime/structured-agent-session-create-adoption.ts index ec0878e2d37..80ed28fd402 100644 --- a/src/main/runtime/structured-agent-session-create-adoption.ts +++ b/src/main/runtime/structured-agent-session-create-adoption.ts @@ -1,3 +1,4 @@ +import type { AgentSessionAccountHome } from '../../shared/agent-session-account-home' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' import { agentSessionExecutionLocationsEqual } from '../../shared/agent-session-record' @@ -64,7 +65,7 @@ export async function resolveStructuredAgentSessionAdoptionForCreate(input: { transcriptImport: StructuredAgentTranscriptImport providerSessionId: string selfSessionId: string - selectedAccountHomePath: string + selectedAccountHome: AgentSessionAccountHome launchEnv: NodeJS.ProcessEnv workspaceCwd: string }) { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts index c8fc77e7137..fee3133f0ae 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts @@ -94,6 +94,14 @@ describe('resumeAiVaultSessionInNewChat', () => { settle({ kind: 'structured', sessionId: 'session-1' }) }) + it('opens an OpenCode 2 row in an OpenCode chat', async () => { + await resumeAiVaultSessionInNewChat(session, 'opencode2', 'worktree-1', 'resume-click') + + expect(mocks.beginStructuredAgentSessionProvisionalLaunch).toHaveBeenCalledWith( + expect.objectContaining({ plan: expect.objectContaining({ agent: 'opencode' }) }) + ) + }) + it('toasts a conflict reported by the eventual settlement', async () => { const error = Object.assign(new Error('held'), { code: 'agent_session_conflict' }) mocks.beginStructuredAgentSessionProvisionalLaunch.mockReturnValue({ diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts index 984b2b5497a..9ed8845851e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts @@ -2,7 +2,10 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import type { CliConversationAdoptingAgent } from '../../../../shared/structured-agent-cli-conversation-adoption' +import { + CLI_CONVERSATION_ADOPTING_AGENTS, + type CliConversationRowAgent +} from '../../../../shared/structured-agent-cli-conversation-adoption' import { hasRuntimeRpcErrorCode } from '../../../../shared/runtime-rpc-error-code' import { prepareAiVaultSessionForResume } from '@/lib/ai-vault-session-resume-preparation' import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' @@ -20,7 +23,7 @@ export function activateAiVaultResumeWorkspace(workspaceId: string): boolean { * would hide the refusal the user needs to see. */ export async function resumeAiVaultSessionInNewChat( session: AiVaultSession, - agent: CliConversationAdoptingAgent, + rowAgent: CliConversationRowAgent, worktreeId: string, requestId: AgentLaunchRequestId ): Promise { @@ -31,7 +34,7 @@ export async function resumeAiVaultSessionInNewChat( const plan = adoptAgentSessionLaunchVerdict({ route: 'structured-native-chat', requestId, - agent, + agent: CLI_CONVERSATION_ADOPTING_AGENTS[rowAgent], worktreeId, resumeFrom: { providerSessionId: preparedSession.sessionId } }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts index e62f0b2a5d4..4ea361da9b4 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts @@ -7,7 +7,10 @@ import { resolveStructuredAgentSessionOwner } from '@/runtime/structured-agent-s import { useAppStore } from '@/store' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { normalizeExecutionHostId } from '../../../../shared/execution-host' -import { adoptsCliConversations } from '../../../../shared/structured-agent-cli-conversation-adoption' +import { + adoptsCliConversations, + CLI_CONVERSATION_ADOPTING_AGENTS +} from '../../../../shared/structured-agent-cli-conversation-adoption' import { STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { resolveAiVaultTargetWorkspacePath } from './ai-vault-session-launch-target' import { @@ -49,7 +52,7 @@ export function resolveAiVaultSessionResumeInChatForWorkspace(args: { targetOwner !== null && targetOwner === normalizeExecutionHostId(args.session.executionHostId) && structuredAgentSessionLaunchFeasible(state, { - agent: args.session.agent, + agent: CLI_CONVERSATION_ADOPTING_AGENTS[args.session.agent], workspace: { kind: workspaceKindForWorktreeId(targetWorkspaceId), worktreeId: targetWorkspaceId diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts index d2cfeb3fd42..4a5584f53c6 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts @@ -40,7 +40,7 @@ describe('resolveAiVaultSessionResumeInChatEligibility', () => { expect(eligibility()).toEqual({ available: true, workspaceId: 'repo-1::/repo/orca' }) }) - it.each(['grok', 'omp'])( + it.each(['grok', 'omp', 'opencode', 'opencode2'])( 'offers the chat for a local %s row in its own workspace', (agent) => { expect(eligibility({ session: session({ agent }) })).toEqual({ @@ -50,7 +50,7 @@ describe('resolveAiVaultSessionResumeInChatEligibility', () => { } ) - it.each(['hermes', 'opencode', 'opencode2', 'pi'])( + it.each(['hermes', 'pi'])( 'refuses %s, whose chats cannot take over a CLI conversation', (agent) => { expect(eligibility({ session: session({ agent }) })).toEqual({ @@ -124,10 +124,10 @@ describe('resolveAiVaultSessionResumeInChatEligibility', () => { }) describe('workspace matching, which binds the agents that store conversations per folder', () => { - it.each(['claude', 'grok', 'omp'])( + it.each(['claude', 'grok', 'omp', 'opencode', 'opencode2'])( 'refuses a %s row whose conversation was recorded in another workspace', (agent) => { - // These key conversations by launch cwd, so resuming elsewhere silently finds nothing. + // These key conversations by launch cwd, so resuming elsewhere finds or loads nothing. expect( eligibility({ session: session({ agent, cwd: '/repo/other' }), diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts index 695d38b5cf3..5c060999cf4 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts @@ -30,13 +30,20 @@ export type AiVaultResumeInChatEligibility = * * Codex is handed the rollout file and a cwd, so it can resume into any workspace. Claude, Grok and * OMP store conversations under a folder derived from the launch cwd, so resuming from a workspace - * other than the one the conversation was recorded in looks in a folder the conversation is not in. + * other than the one the conversation was recorded in looks in a folder the conversation is not in, + * and OpenCode refuses a conversation that does not belong to the launch cwd. * That is a resume that silently yields nothing, which is worse than a disabled affordance. */ export function aiVaultSessionResumeInChatWorkspaceMatters( agent: AiVaultSession['agent'] ): boolean { - return agent === 'claude' || agent === 'grok' || agent === 'omp' + return ( + agent === 'claude' || + agent === 'grok' || + agent === 'omp' || + agent === 'opencode' || + agent === 'opencode2' + ) } /** 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 650672deb79..ea3483d956e 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 @@ -46,7 +46,7 @@ vi.mock('@/runtime/local-runtime-capabilities', async (importOriginal) => ({ vi.mock('@/runtime/host-structured-agents', async (importOriginal) => ({ ...(await importOriginal>()), readHostStructuredAgents: () => - ['claude', 'codex', 'grok', 'omp'].map((agent) => ({ agent, capabilities: {} })) + ['claude', 'codex', 'grok', 'omp', 'opencode'].map((agent) => ({ agent, capabilities: {} })) })) vi.mock('@/lib/renderer-app-platform', () => ({ getRendererAppPlatform: () => mocks.platform })) @@ -287,7 +287,7 @@ describe('resolveTabSessionHistorySubject', () => { ).toEqual(cliSubject) }) - it.each(['grok', 'omp'] as const)( + it.each(['grok', 'omp', 'opencode', 'opencode2'] as const)( 'finds a %s terminal tab, whose conversation a chat can take over', (agentType) => { const state = makeState({ @@ -305,7 +305,7 @@ describe('resolveTabSessionHistorySubject', () => { } ) - it.each(['opencode', 'pi'] as const)( + it.each(['pi'] as const)( 'skips a %s terminal tab, whose conversation no chat can take over', (agentType) => { const state = makeState({ @@ -404,24 +404,27 @@ describe('one pane of a terminal tab', () => { expect(paneGate(state, geminiPane)).toBe(false) }) - it.each(['grok', 'omp'] as const)("finds a %s pane's own conversation", (agentType) => { - const paneKey = 'term-1:leaf-1' - const state = makeState({ - [paneKey]: makeAgentStatusEntry({ - agentType, - paneKey, - tabId: 'term-1', - worktreeId: WORKTREE_ID, - providerSession: { key: 'session_id', id: `${agentType}-session-1` } + it.each(['grok', 'omp', 'opencode', 'opencode2'] as const)( + "finds a %s pane's own conversation", + (agentType) => { + const paneKey = 'term-1:leaf-1' + const state = makeState({ + [paneKey]: makeAgentStatusEntry({ + agentType, + paneKey, + tabId: 'term-1', + worktreeId: WORKTREE_ID, + providerSession: { key: 'session_id', id: `${agentType}-session-1` } + }) }) - }) - expect(paneSubject(state, paneKey)).toEqual({ - ...cliSubject, - agent: agentType, - providerSessionId: `${agentType}-session-1` - }) - expect(paneGate(state, paneKey)).toBe(true) - }) + expect(paneSubject(state, paneKey)).toEqual({ + ...cliSubject, + agent: agentType, + providerSessionId: `${agentType}-session-1` + }) + expect(paneGate(state, paneKey)).toBe(true) + } + ) it("finds the pane's own conversation", () => { const state = splitTab('leaf-1') @@ -485,6 +488,21 @@ describe('findTabSessionHistoryRow', () => { }) expect(findTabSessionHistoryRow([ownedRow], cliSubject)?.id).toBe('owned-row') expect(findTabSessionHistoryRow([row({ agent: 'codex' })], cliSubject)).toBeNull() + // An OpenCode 2.x pane reports `opencode`; Session History lists its conversation as `opencode2`. + const openCodeSubject = { + ...cliSubject, + agent: 'opencode' as const, + providerSessionId: 'ses_1' + } + expect( + findTabSessionHistoryRow( + [row({ id: 'oc2-row', agent: 'opencode2', sessionId: 'ses_1' })], + openCodeSubject + )?.id + ).toBe('oc2-row') + expect( + findTabSessionHistoryRow([row({ agent: 'grok', sessionId: 'ses_1' })], openCodeSubject) + ).toBeNull() expect( findTabSessionHistoryRow( [ diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.ts index 33b75762483..d3e74a9af3e 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.ts @@ -20,7 +20,8 @@ import { } from '../../../../shared/execution-host' import { adoptsCliConversations, - type CliConversationAdoptingAgent + CLI_CONVERSATION_ADOPTING_AGENTS, + type CliConversationRowAgent } from '../../../../shared/structured-agent-cli-conversation-adoption' import { agentForksConversations } from '../../../../shared/agent-resume-argv' import type { TerminalTab } from '../../../../shared/terminal-tab-types' @@ -53,7 +54,7 @@ export type TabSessionHistorySubject = { | { kind: 'chat'; sessionId: string } | { kind: 'cli' - agent: CliConversationAdoptingAgent + agent: CliConversationRowAgent providerSessionId: string } ) @@ -94,7 +95,7 @@ export function canPaneOfferResumeInNewChat( return ( canHostOfferTabSessionMove('cli', hostScope) && structuredAgentSessionLaunchFeasible(state, { - agent: conversation.agent, + agent: CLI_CONVERSATION_ADOPTING_AGENTS[conversation.agent], workspace: { kind: workspaceKindForWorktreeId(conversation.worktreeId), worktreeId: conversation.worktreeId @@ -173,7 +174,13 @@ export function findTabSessionHistoryRow( return session.structuredSession?.sessionId === subject.sessionId } // A chat-owned row still matches: the move's gate refuses it, and the caller can open that chat. - return session.agent === subject.agent && session.sessionId === subject.providerSessionId + // By the chat agent that takes it over: an OpenCode 2.x pane reports `opencode`, its row `opencode2`. + return ( + adoptsCliConversations(session.agent) && + CLI_CONVERSATION_ADOPTING_AGENTS[session.agent] === + CLI_CONVERSATION_ADOPTING_AGENTS[subject.agent] && + session.sessionId === subject.providerSessionId + ) }) ?? null ) } diff --git a/src/shared/structured-agent-cli-conversation-adoption.ts b/src/shared/structured-agent-cli-conversation-adoption.ts index a5d71b0db79..63fdc0e9bd4 100644 --- a/src/shared/structured-agent-cli-conversation-adoption.ts +++ b/src/shared/structured-agent-cli-conversation-adoption.ts @@ -1,13 +1,27 @@ /** - * The agents whose chats can take over a conversation their own CLI recorded: those the host - * registers a transcript import for, which a registration test keeps equal to this list. Only a - * conversation recorded on this machine is offered, and this machine's host is this build, so the - * list cannot disagree with the host that adopts. + * The agents whose chats can take over a conversation their own CLI recorded, by the agent its + * Session History row lists under: those the host registers a transcript import for, whose row + * agents a registration test keeps equal to this map. Only a conversation recorded on this machine + * is offered, and this machine's host is this build, so the map cannot disagree with the host + * that adopts. */ -export const CLI_CONVERSATION_ADOPTING_AGENTS = ['claude', 'codex', 'grok', 'omp'] as const +export const CLI_CONVERSATION_ADOPTING_AGENTS = { + claude: 'claude', + codex: 'codex', + grok: 'grok', + omp: 'omp', + opencode: 'opencode', + // OpenCode 2.x lists its conversations as `opencode2` rows; an OpenCode chat runs either line. + opencode2: 'opencode' +} as const -export type CliConversationAdoptingAgent = (typeof CLI_CONVERSATION_ADOPTING_AGENTS)[number] +/** A Session History row agent whose conversations a chat can take over. */ +export type CliConversationRowAgent = keyof typeof CLI_CONVERSATION_ADOPTING_AGENTS -export function adoptsCliConversations(agent: unknown): agent is CliConversationAdoptingAgent { - return CLI_CONVERSATION_ADOPTING_AGENTS.some((adopting) => adopting === agent) +/** The chat agent that takes a row agent's conversations over. */ +export type CliConversationAdoptingAgent = + (typeof CLI_CONVERSATION_ADOPTING_AGENTS)[CliConversationRowAgent] + +export function adoptsCliConversations(agent: unknown): agent is CliConversationRowAgent { + return typeof agent === 'string' && Object.hasOwn(CLI_CONVERSATION_ADOPTING_AGENTS, agent) }