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) }