diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index 5f13ad29e1c..3fe0331be2f 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -150,6 +150,8 @@ describe('Electron Vite output contract', () => { expect(external('zod', undefined, false)).toBe(false) expect(external('smol-toml', undefined, false)).toBe(false) expect(external('smol-toml/package.json', undefined, false)).toBe(false) + expect(external('@noble/hashes/blake3', undefined, false)).toBe(false) + expect(external('@noble/hashes/utils', undefined, false)).toBe(false) expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('tldts') expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod') expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('smol-toml') diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 3f814ada6f7..789e06084d9 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -27,10 +27,15 @@ import { loadOpenCodeVisualsSkill, type AcpVisualsSkillLoader } from './acp-visuals-skill' -import type { StructuredAgentSessionHistory } from '../native-chat/structured-agent-cli-conversations' +import type { + StructuredAgentSessionHistory, + StructuredAgentTranscriptImport +} from '../native-chat/structured-agent-cli-conversations' 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 { OMP_SESSION_HISTORY } from '../omp/omp-session-history' +import { OMP_TRANSCRIPT_IMPORT } from '../omp/omp-transcript-import' /** How an agent lists its models without a session: from its `initialize` answer (plus extension * requests), from a listing command, or not at all. Never `authenticate` or `session/new`. */ @@ -91,6 +96,8 @@ export type AcpLaunchSpec = { modelDiscovery: AcpModelDiscovery /** How a launch loads the inline-visuals skill; absent, the agent's chats have no visuals. */ visualsSkill?: AcpVisualsSkillLoader + /** How a chat adopts a conversation the agent's CLI recorded; null when it cannot. */ + transcriptImport: StructuredAgentTranscriptImport | null /** How the agent's chats own the Session History rows their conversations list as. */ sessionHistory: StructuredAgentSessionHistory } @@ -126,6 +133,7 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = { }, visualsSkill: loadGrokVisualsSkill, compaction: true, + transcriptImport: GROK_TRANSCRIPT_IMPORT, sessionHistory: GROK_SESSION_HISTORY } @@ -165,6 +173,7 @@ const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = { }, visualsSkill: loadOpenCodeVisualsSkill, compaction: true, + transcriptImport: null, sessionHistory: OPENCODE_SESSION_HISTORY } @@ -193,6 +202,7 @@ const OMP_LAUNCH_SPEC: AcpLaunchSpec = { }, visualsSkill: loadOmpVisualsSkill, compaction: true, + transcriptImport: OMP_TRANSCRIPT_IMPORT, sessionHistory: OMP_SESSION_HISTORY } diff --git a/src/main/grok/grok-transcript-import.ts b/src/main/grok/grok-transcript-import.ts new file mode 100644 index 00000000000..8aadcbbf467 --- /dev/null +++ b/src/main/grok/grok-transcript-import.ts @@ -0,0 +1,22 @@ +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' + +export const GROK_TRANSCRIPT_IMPORT: StructuredAgentTranscriptImport = { + // The chat's own home, then the one Session History lists Grok's conversations from. + accountHomeCandidates: ({ selectedAccountHomePath }) => [ + selectedAccountHomePath, + resolveGrokHomeDir() + ], + findTranscript: async ({ providerSessionId, accountHomePath, workspaceCwd }) => { + const sessionsDir = join(accountHomePath, 'sessions') + return ( + (await findGrokChatHistoryByCwd({ + sessionId: providerSessionId, + cwd: workspaceCwd, + sessionsDir + })) ?? resolveSessionFilePath('grok', providerSessionId, { grokSessionsDir: sessionsDir }) + ) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts index 6c5869c538c..7b98578888c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts @@ -30,11 +30,22 @@ function declaringNothing(definition: StructuredAgentDefinition): StructuredAgen } } -/** Claude and Codex declaring no capability and no resting options: what a host over a bare - * adapter double declares. */ -export const NO_STRUCTURED_AGENTS = new StructuredAgentRegistry([ - { definition: declaringNothing(CLAUDE_STRUCTURED_AGENT), adapter: NO_METHODS }, - { definition: declaringNothing(CODEX_STRUCTURED_AGENT), adapter: NO_METHODS } +/** These agents declaring no capability and no resting options: what a host over a bare adapter + * double declares. */ +export function structuredAgentsDeclaringNothing( + definitions: readonly StructuredAgentDefinition[] +): StructuredAgentRegistry { + return new StructuredAgentRegistry( + definitions.map((definition) => ({ + definition: declaringNothing(definition), + adapter: NO_METHODS + })) + ) +} + +export const NO_STRUCTURED_AGENTS = structuredAgentsDeclaringNothing([ + CLAUDE_STRUCTURED_AGENT, + CODEX_STRUCTURED_AGENT ]) /** This build's two agents over doubles. Each declares only what its double implements, so a 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 79d5c5d4492..d1116094d2d 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 @@ -21,7 +21,12 @@ import { StructuredAgentSessionHost } from './structured-agent-session-host' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { + NO_STRUCTURED_AGENTS, + structuredAgentsDeclaringNothing +} from './structured-agent-session-adapter-router-test-support' +import { acpStructuredAgentDefinition } from '../../acp/acp-structured-agent-definitions' +import { acpLaunchSpecFor } from '../../acp/acp-launch-specs' const NOW = 1_800_000_000_000 const SESSION = 'codex_adopting_session' @@ -59,7 +64,10 @@ async function writeCodexRollout(path: string, text: string): Promise { await writeFile(path, `${lines.join('\n')}\n`, 'utf8') } -function attachParams(transcriptPath?: string): AgentSessionAttachParams { +function attachParams( + transcriptPath?: string, + overrides: Partial = {} +): AgentSessionAttachParams { const params: AgentSessionAttachParams = { envelope: { sessionId: SESSION, @@ -78,9 +86,10 @@ function attachParams(transcriptPath?: string): AgentSessionAttachParams { accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, runtimeKind: 'native', adopt: { - providerHandle: { kind: 'codex', threadId: THREAD }, + providerHandle: codexProviderHandle(THREAD), ...(transcriptPath ? { transcriptPath } : {}) - } + }, + ...overrides } return { ...params, @@ -121,11 +130,13 @@ function adapter(): StructuredAgentSessionAdapter { async function attach( transcriptPath: string | undefined, sessionAdapter: StructuredAgentSessionAdapter, - onAttached: AttachFlowInput['onAttached'] = () => {} + onAttached: AttachFlowInput['onAttached'] = () => {}, + params: AgentSessionAttachParams = attachParams(transcriptPath), + agents = NO_STRUCTURED_AGENTS ) { store ??= await openTestAgentSessionRecordStore(root!) return performAttach({ - agents: NO_STRUCTURED_AGENTS, + agents, logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, @@ -138,7 +149,7 @@ async function attach( }, callerKey: 'client-1', optionRevision: () => 0, - params: attachParams(transcriptPath), + params, now: () => NOW, onAttached }) @@ -160,6 +171,50 @@ 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 + } + })) + + 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)]) + ) + + expect(result.ok && JSON.stringify(result.value.page.items)).toContain('ORCA-GROK-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-')) const transcriptPath = join(root, 'rollout.jsonl') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts index ebc91a4c00b..d3aabd2e52a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts @@ -5,7 +5,6 @@ import { type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { agentSessionProviderHandleFromWire } from '../../../shared/agent-session-provider-handle-encoding' import type { AgentSessionAttachParams, AttachedJournal } from './structured-agent-session-attach' import type { JournalReplacementItem } from '../agent-session-journal/journal-epoch-replacement' import { @@ -50,7 +49,7 @@ async function readAdoptedTranscript( } const prepared = await prepareLegacyTranscriptImport({ agent: params.agent, - sessionId: agentSessionProviderHandleFromWire(adopt.providerHandle).nativeId, + sessionId: adopt.providerHandle.nativeId, options: { filePath: adopt.transcriptPath } }) if (!prepared.ok) { @@ -110,7 +109,7 @@ async function applyAdoptedTranscript( const imported = await importLegacyTranscriptIntoJournal({ journal: attached.journal, agent: params.agent, - sessionId: agentSessionProviderHandleFromWire(adopt.providerHandle).nativeId, + sessionId: adopt.providerHandle.nativeId, fence: record.lease.runtimeFence, options: { filePath: adopt.transcriptPath } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 9138fa20f8b..54e7f61845d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -10,6 +10,7 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session- import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { StructuredAgentId, + AgentSessionProviderHandle, AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' import { @@ -17,8 +18,6 @@ import { agentSessionProviderHandleFromWire, type AgentSessionWireProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { claudeProviderHandleLink } from '../../claude/claude-structured-owner-identity' -import { codexProviderHandleLink } from '../../codex/codex-structured-owner-identity' import type { AgentSessionAccountHome, AgentSessionExecutionLocation, @@ -79,7 +78,7 @@ export type AgentSessionAttachParams = { * without adopting — presence of a handle must never be what triggers a resume. */ adopt?: { - providerHandle: AgentSessionWireProviderHandle + providerHandle: AgentSessionProviderHandle /** Omitted only when the exact committed operation replays an already-imported journal. */ transcriptPath?: string } @@ -282,25 +281,16 @@ async function reconcileAgainstProviderHistory(input: { const ADOPTED_HANDLE_FENCE = 1 function adoptedProviderHandleLink( - handle: AgentSessionWireProviderHandle, + handle: AgentSessionProviderHandle, observedAt: number ): AgentSessionProviderHandleLink { - return handle.kind === 'claude' - ? claudeProviderHandleLink({ - sessionId: handle.sessionId, - leafUuid: handle.leafUuid, - resumed: false, - origin: 'adopted', - fence: ADOPTED_HANDLE_FENCE, - observedAt - }) - : codexProviderHandleLink({ - threadId: handle.threadId, - resumed: false, - origin: 'adopted', - fence: ADOPTED_HANDLE_FENCE, - observedAt - }) + return { + linkId: `${handle.agent}-${ADOPTED_HANDLE_FENCE}-${handle.nativeId}`.slice(0, 128), + handle, + origin: 'adopted', + mintedAtFence: ADOPTED_HANDLE_FENCE, + observedAt + } } export function reserveRequestFor(input: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts index 0d87155f514..60a16037880 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts @@ -20,6 +20,7 @@ import { import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' let rig: RestTestRig beforeEach(async () => { @@ -47,7 +48,7 @@ async function adoptedParams(history = 'adopted history', threadId = THREAD) { ].join('\n') ) return createTestParams(undefined, { - adopt: { providerHandle: { kind: 'codex', threadId }, transcriptPath } + adopt: { providerHandle: codexProviderHandle(threadId), transcriptPath } }) } @@ -88,7 +89,7 @@ it('imports adoption at rest and resumes its original provider identity after it await rig.host.create(CALLER, { ...params, accountHome: { variable: 'CODEX_HOME', path: '/drifted/account' }, - adopt: { providerHandle: { kind: 'codex', threadId: THREAD } } + adopt: { providerHandle: codexProviderHandle(THREAD) } }) ).toMatchObject({ ok: true, replayed: true }) expect(await rig.host.send(CALLER, restTestSend('new work'))).toMatchObject({ ok: true }) 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 new file mode 100644 index 00000000000..ab6edfa0ae7 --- /dev/null +++ b/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts @@ -0,0 +1,245 @@ +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 { CLI_CONVERSATION_ADOPTING_AGENTS } from '../../shared/structured-agent-cli-conversation-adoption' +import { + clearGrokSessionPathLookupCacheForTests, + findGrokChatHistoryBySessionId, + grokEncodedCwdDirName, + GROK_SESSION_GROUP_SCAN_MAX_ENTRIES, + setGrokSessionDirectoryOpenerForTests +} from '../../shared/grok-session-paths' +import { + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + structuredAgentRuntimeRegistration +} from '../runtime/structured-agent-runtime-registrations' +import { resolveStructuredAgentSessionAdoptionForCreate } from '../runtime/structured-agent-session-create-adoption' +import { prepareLegacyTranscriptImport } from './agent-session-journal/journal-legacy-import' + +const SESSION_ID = '0198c2a4-7d1e-7c3a-9a4b-1f2e3d4c5b6a' +let root: string | null = null + +afterEach(async () => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + clearGrokSessionPathLookupCacheForTests() + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = null +}) + +/** Each agent's CLI conversation as its own CLI lays it out under an account home. */ +const RECORDED: Record<'grok' | 'omp', (home: string, text: string) => Promise> = { + grok: async (home, text) => { + const dir = join(home, 'sessions', encodeURIComponent('/repo/orca'), SESSION_ID) + await mkdir(dir, { recursive: true }) + const file = join(dir, 'chat_history.jsonl') + await writeFile( + file, + `${JSON.stringify({ type: 'user', content: [{ type: 'text', text }], timestamp: '2026-10-12T00:00:00.000Z' })}\n` + ) + return file + }, + omp: async (home, text) => { + const dir = join(home, 'sessions', '--repo-orca--') + await mkdir(dir, { recursive: true }) + const file = join(dir, `2026-10-12T00-00-00-000Z_${SESSION_ID}.jsonl`) + await writeFile( + file, + [ + { + type: 'session', + id: SESSION_ID, + cwd: '/repo/orca', + timestamp: '2026-10-12T00:00:00.000Z' + }, + { + type: 'message', + id: 'rec-1', + parentId: null, + timestamp: '2026-10-12T00:00:01.000Z', + message: { role: 'user', content: [{ type: 'text', text }] } + } + ] + .map((record) => JSON.stringify(record)) + .join('\n') + ) + return file + } +} + +function adopt( + agent: 'grok' | 'omp', + selectedAccountHomePath: string, + launchEnv: NodeJS.ProcessEnv = process.env, + workspaceCwd = '/repo/orca' +) { + // Grok also looks where Session History lists it from; keep that off the real home. + vi.stubEnv('GROK_HOME', selectedAccountHomePath) + const transcriptImport = structuredAgentRuntimeRegistration(agent)?.transcriptImport + if (!transcriptImport) { + throw new Error(`${agent} registers no transcript import`) + } + return resolveStructuredAgentSessionAdoptionForCreate({ + host: null, + settings: {}, + agent, + transcriptImport, + providerSessionId: SESSION_ID, + selfSessionId: 'chat-1', + selectedAccountHomePath, + launchEnv, + workspaceCwd + }) +} + +describe('which chats can take over a CLI conversation', () => { + it('lists exactly 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()) + }) +}) + +it.each([false, true])( + 'finds a Grok cwd beyond the generic cap (long path: %s)', + async (longPath) => { + root = await mkdtemp(join(tmpdir(), 'orca-grok-adopt-cwd-')) + const workspaceCwd = join(root, longPath ? '中'.repeat(80) : 'workspace') + const recorded = await RECORDED.grok(root, 'CURRENT-WORKSPACE') + const group = grokEncodedCwdDirName(workspaceCwd) + if (!group) { + throw new Error('Expected a CLI cwd group') + } + const cwdRecorded = join(root, 'sessions', group, SESSION_ID) + await mkdir(cwdRecorded, { recursive: true }) + const currentFile = join(cwdRecorded, 'chat_history.jsonl') + await writeFile(currentFile, await readFile(recorded)) + setGrokSessionDirectoryOpenerForTests(async () => ({ + async *[Symbol.asyncIterator]() { + for (let index = 0; index <= GROK_SESSION_GROUP_SCAN_MAX_ENTRIES; index += 1) { + yield { + name: `earlier-${index}`, + isDirectory: () => true, + isSymbolicLink: () => false + } + } + }, + close: async () => {} + })) + + expect(await findGrokChatHistoryBySessionId(join(root, 'sessions'), SESSION_ID)).toBeNull() + expect((await adopt('grok', root, process.env, workspaceCwd)).transcriptPath).toBe(currentFile) + } +) + +it('looks up OMP history under the configured launch XDG and profile root', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-omp-adopt-env-')) + const ambientData = join(root, 'ambient-data') + const launchData = join(root, 'launch-data') + const profile = 'work' + const ambientSessions = join(ambientData, 'omp', 'profiles', profile, 'sessions') + const launchSessions = join(launchData, 'omp', 'profiles', profile, 'sessions') + await mkdir(join(ambientData, 'omp', 'profiles', profile), { recursive: true }) + await mkdir(join(launchData, 'omp', 'profiles', profile), { recursive: true }) + const ambientFile = await RECORDED.omp(join(ambientData, 'omp', 'profiles', profile), 'AMBIENT') + const launchFile = await RECORDED.omp(join(launchData, 'omp', 'profiles', profile), 'LAUNCH') + vi.stubEnv('XDG_DATA_HOME', ambientData) + vi.stubEnv('OMP_PROFILE', profile) + const launchEnv = { + ...process.env, + HOME: root, + XDG_DATA_HOME: launchData, + OMP_PROFILE: profile, + OMP_CODING_AGENT_DIR: '' + } + const accountHome = join(root, '.omp', 'agent') + + expect(ambientFile.startsWith(ambientSessions)).toBe(true) + expect(launchFile.startsWith(launchSessions)).toBe(true) + expect((await adopt('omp', accountHome, launchEnv)).transcriptPath).toBe(launchFile) +}) + +it('uses the configured OMP profile when the ambient profile names another session', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-omp-adopt-profile-')) + const xdgData = join(root, 'data') + const ambientProfile = 'ambient' + const launchProfile = 'configured' + const ambientHome = join(xdgData, 'omp', 'profiles', ambientProfile) + const launchHome = join(xdgData, 'omp', 'profiles', launchProfile) + await mkdir(ambientHome, { recursive: true }) + await mkdir(launchHome, { recursive: true }) + await RECORDED.omp(ambientHome, 'AMBIENT-PROFILE') + const launchFile = await RECORDED.omp(launchHome, 'LAUNCH-PROFILE') + vi.stubEnv('XDG_DATA_HOME', xdgData) + vi.stubEnv('OMP_PROFILE', ambientProfile) + + const accountHome = join(root, '.omp', 'agent') + const launchEnv = { + ...process.env, + HOME: root, + OMP_PROFILE: launchProfile, + OMP_CODING_AGENT_DIR: '' + } + expect((await adopt('omp', accountHome, launchEnv)).transcriptPath).toBe(launchFile) +}) + +it.each(['win32', 'linux', 'darwin'] as const)( + 'follows the child home precedence for a named OMP profile on %s', + async (platform) => { + root = await mkdtemp(join(tmpdir(), 'orca-omp-adopt-home-')) + const home = join(root, 'home') + const userProfile = join(root, 'user-profile') + const profile = 'work' + const homeFile = await RECORDED.omp(join(home, '.omp', 'profiles', profile, 'agent'), 'HOME') + const userProfileFile = await RECORDED.omp( + join(userProfile, '.omp', 'profiles', profile, 'agent'), + 'USERPROFILE' + ) + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + + const adoption = await adopt('omp', join(root, 'account'), { + HOME: home, + USERPROFILE: userProfile, + OMP_PROFILE: profile + }) + + expect(adoption.transcriptPath).toBe(platform === 'win32' ? userProfileFile : homeFile) + } +) + +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({ + refusal: { code: 'agent_session_identity_required' } + }) +}) + +describe.each(['grok', 'omp'] as const)('adopting a %s CLI conversation', (agent) => { + it('finds the conversation under the chat account and imports its history', async () => { + root = await mkdtemp(join(tmpdir(), `orca-${agent}-adopt-`)) + const recorded = await RECORDED[agent](root, `ORCA-${agent}-ADOPT`) + + const adoption = await adopt(agent, root) + + expect(adoption).toEqual({ accountHomePath: root, transcriptPath: recorded }) + const imported = await prepareLegacyTranscriptImport({ + agent, + sessionId: SESSION_ID, + options: { filePath: adoption.transcriptPath } + }) + expect(imported.ok && JSON.stringify(imported.items)).toContain(`ORCA-${agent}-ADOPT`) + }) + + 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({ + refusal: { code: 'agent_session_identity_required' } + }) + }) +}) diff --git a/src/main/native-chat/structured-agent-cli-conversations.ts b/src/main/native-chat/structured-agent-cli-conversations.ts index 3c4dd8404c3..67e25bcf99d 100644 --- a/src/main/native-chat/structured-agent-cli-conversations.ts +++ b/src/main/native-chat/structured-agent-cli-conversations.ts @@ -21,6 +21,8 @@ export type StructuredAgentTranscriptImport = { findTranscript: (input: { providerSessionId: string accountHomePath: string + launchEnv: NodeJS.ProcessEnv + workspaceCwd: string }) => Promise } 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 0845d6570ab..069a462db07 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 @@ -117,7 +117,7 @@ describe('findCommittedStructuredAgentSessionAdoptionReplay', () => { it('returns the record-pinned account and adopted handle for the exact committed operation', () => { expect(committedReplay()).toMatchObject({ record: { accountHome: { path: '/home/dev/.codex-original' } }, - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }) }) @@ -156,11 +156,7 @@ describe('findCommittedStructuredAgentSessionAdoptionReplay', () => { ] }) ).toMatchObject({ - providerHandle: { - kind: 'claude', - sessionId: 'provider-session-alpha-1', - leafUuid: 'leaf-1' - } + providerHandle: claudeProviderHandle('provider-session-alpha-1', 'leaf-1') }) }) }) @@ -195,7 +191,9 @@ describe('resolveStructuredAgentSessionAdoption', () => { resolveStructuredAgentSessionAdoption({ providerSessionId: 'thread-1', candidateAccountHomes: ['/home/dev/.orca-codex', '/home/dev/.codex', '/never/probed'], - resolveTranscript + resolveTranscript, + launchEnv: {}, + workspaceCwd: '/repo' }) ).resolves.toEqual({ accountHomePath: '/home/dev/.codex', @@ -211,7 +209,9 @@ describe('resolveStructuredAgentSessionAdoption', () => { resolveStructuredAgentSessionAdoption({ providerSessionId: 'session-1', candidateAccountHomes: ['', ' ', '/home/dev/.claude', ' /home/dev/.claude ', ''], - resolveTranscript + resolveTranscript, + launchEnv: {}, + workspaceCwd: '/repo' }) ).rejects.toThrow('agent_session_identity_required') expect(resolveTranscript.mock.calls.map(([args]) => args.accountHomePath)).toEqual([ @@ -225,7 +225,9 @@ describe('resolveStructuredAgentSessionAdoption', () => { resolveStructuredAgentSessionAdoption({ providerSessionId: 'session-1', candidateAccountHomes: ['/home/dev/.claude-work', '/home/dev/.claude'], - resolveTranscript: async () => null + resolveTranscript: async () => null, + launchEnv: {}, + workspaceCwd: '/repo' }) ).rejects.toThrow('agent_session_identity_required') }) 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 429aa69a0f0..3d32e1ce9f4 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -7,11 +7,10 @@ import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' -import { - agentSessionWireProviderHandle, - type AgentSessionWireProviderHandle -} from '../../shared/agent-session-provider-handle-encoding' -import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' +import type { + AgentSessionProviderHandle, + StructuredAgentId +} from '../../shared/agent-session-provider-handle' import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease-adjudication' @@ -31,7 +30,7 @@ export type StructuredAgentSessionAdoption = { export type CommittedStructuredAgentSessionAdoptionReplay = { record: AgentSessionRecord - providerHandle: AgentSessionWireProviderHandle + providerHandle: AgentSessionProviderHandle } /** Exact committed-operation identity; attach still validates its fingerprint. */ @@ -63,12 +62,10 @@ export function findCommittedStructuredAgentSessionAdoptionReplay(input: { ) { return null } - // The replay re-sends the adoption as its create sent it, so it needs the wire's form. - const providerHandle = agentSessionWireProviderHandle(adopted.handle) - if (adopted.handle.nativeId !== input.providerSessionId || !providerHandle) { + if (adopted.handle.nativeId !== input.providerSessionId) { return null } - return { record, providerHandle } + return { record, providerHandle: adopted.handle } } /** @@ -127,7 +124,11 @@ export async function resolveStructuredAgentSessionAdoption(input: { resolveTranscript: (args: { providerSessionId: string accountHomePath: string + launchEnv: NodeJS.ProcessEnv + workspaceCwd: string }) => Promise + launchEnv: NodeJS.ProcessEnv + workspaceCwd: string }): Promise { const seen = new Set() for (const accountHomePath of input.candidateAccountHomes) { @@ -138,7 +139,9 @@ export async function resolveStructuredAgentSessionAdoption(input: { seen.add(trimmed) const transcriptPath = await input.resolveTranscript({ providerSessionId: input.providerSessionId, - accountHomePath: trimmed + accountHomePath: trimmed, + launchEnv: input.launchEnv, + workspaceCwd: input.workspaceCwd }) if (transcriptPath) { return { accountHomePath: trimmed, transcriptPath } diff --git a/src/main/native-chat/transcript-line-decoders-grok.ts b/src/main/native-chat/transcript-line-decoders-grok.ts index 76386453970..fe0a7d172f0 100644 --- a/src/main/native-chat/transcript-line-decoders-grok.ts +++ b/src/main/native-chat/transcript-line-decoders-grok.ts @@ -175,15 +175,22 @@ function isGrokBootstrapContext(content: unknown): boolean { if (userInfoEnd === -1) { return false } - const remainder = normalized.slice(userInfoEnd + ''.length).trim() - // Why: Grok 0.2.93 appends its git snapshot to the user-info bootstrap row; - // reject only that known envelope so real prompts mentioning either tag survive. - return ( - remainder.length === 0 || - (remainder.startsWith('') && remainder.endsWith('')) - ) + // Why: Grok 0.2.93 appends its git snapshot and 1.0 its rules to the user-info bootstrap row; + // reject only those known envelopes so real prompts mentioning the tags survive. + let remainder = normalized.slice(userInfoEnd + ''.length).trim() + while (remainder.length > 0) { + const section = GROK_BOOTSTRAP_SECTIONS.find((name) => remainder.startsWith(`<${name}>`)) + const sectionEnd = section ? remainder.indexOf(``) : -1 + if (!section || sectionEnd === -1) { + return false + } + remainder = remainder.slice(sectionEnd + ``.length).trim() + } + return true } +const GROK_BOOTSTRAP_SECTIONS = ['git_status', 'rules'] + function standaloneTextContent(content: unknown): string | null { if (typeof content === 'string') { return content diff --git a/src/main/native-chat/transcript-line-decoders.grok.test.ts b/src/main/native-chat/transcript-line-decoders.grok.test.ts index 13295dcfcdd..2adff4dff64 100644 --- a/src/main/native-chat/transcript-line-decoders.grok.test.ts +++ b/src/main/native-chat/transcript-line-decoders.grok.test.ts @@ -119,6 +119,16 @@ describe('decodeGrokTranscriptLine', () => { 'Working tree snapshot' ].join('\n\n') } + ], + // Grok 1.0 appends the user's rules after the runtime context. + [ + { + type: 'text', + text: [ + '\nOS Version: macos\nShell: /bin/zsh\n', + '\n\n\nBe brief.\n\n\n' + ].join('\n\n') + } ] ])('skips a standalone Grok user_info bootstrap row', (content) => { expect( @@ -126,6 +136,20 @@ describe('decodeGrokTranscriptLine', () => { ).toBeNull() }) + it('keeps a row whose text continues past the known bootstrap sections', () => { + const line = JSON.stringify({ + type: 'user', + content: [ + { + type: 'text', + text: 'Runtime context\n\nr\nFix the bug' + } + ] + }) + + expect(decodeGrokTranscriptLine(line, 'fb-user-info-tail')).toMatchObject({ role: 'user' }) + }) + it('keeps a real query that discusses the user_info tag', () => { const line = JSON.stringify({ type: 'user', diff --git a/src/main/omp/omp-session-cwd-lookup.test.ts b/src/main/omp/omp-session-cwd-lookup.test.ts new file mode 100644 index 00000000000..fd9dd3ef6d8 --- /dev/null +++ b/src/main/omp/omp-session-cwd-lookup.test.ts @@ -0,0 +1,174 @@ +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import type * as FsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const io = vi.hoisted(() => ({ open: vi.fn() })) +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + io.open.mockImplementation(actual.opendir) + return { ...actual, opendir: io.open } +}) + +import { OMP_TRANSCRIPT_IMPORT } from './omp-transcript-import' + +const ID = '0198c2a4-7d1e-7c3a-9a4b-1f2e3d4c5b6a' +const FILE = `2026-10-12T00-00-00-000Z_${ID}.jsonl` +let root = '' +let sessionsDir = '' + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'orca-omp-cwd-'))) + sessionsDir = join(root, 'sessions') + const actual = await vi.importActual('node:fs/promises') + io.open.mockReset().mockImplementation(actual.opendir) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +async function record(bucket: string, file = FILE): Promise { + const dir = join(sessionsDir, bucket) + await mkdir(dir, { recursive: true }) + const path = join(dir, file) + await writeFile(path, '{}\n') + return path +} + +function lookup(cwd = '/repo/orca', homeDir = join(root, 'home'), temp = join(root, 'temp')) { + return OMP_TRANSCRIPT_IMPORT.findTranscript({ + accountHomePath: join(root, 'account'), + providerSessionId: ID, + workspaceCwd: cwd, + launchEnv: { + OMP_CODING_AGENT_DIR: sessionsDir, + HOME: homeDir, + USERPROFILE: homeDir, + TMPDIR: temp, + TEMP: temp + } + }) +} + +describe('OMP known-workspace transcript lookup', () => { + it('opens only the known cwd group and stops its iterator at the first ID match', async () => { + const expected = await record('--repo-orca--') + await record('--unrelated-project--') + const visited: string[] = [] + let yielded = 0 + let closed = 0 + io.open.mockImplementation(async (path: string) => { + visited.push(path) + return { + async *[Symbol.asyncIterator]() { + try { + for (const name of ['older_other.jsonl', FILE, `later_${ID}.jsonl`]) { + yielded += 1 + yield { name, isFile: () => true, isSymbolicLink: () => false } + } + } finally { + closed += 1 + } + } + } + }) + + expect(await lookup()).toBe(expected) + expect(visited).toEqual([join(sessionsDir, '--repo-orca--')]) + expect(yielded).toBe(2) + expect(closed).toBe(1) + }) + + it('does not search unrelated workspaces when the known cwd has no transcript', async () => { + await record('--unrelated-project--') + expect(await lookup()).toBeNull() + expect(io.open).not.toHaveBeenCalled() + }) + + it('finds current home-relative buckets', async () => { + const home = join(root, 'home') + const cwd = join(home, 'My Project') + await mkdir(cwd, { recursive: true }) + const expected = await record('-My Project') + expect(await lookup(cwd, home)).toBe(expected) + expect(io.open).toHaveBeenCalledTimes(1) + }) + + it.each(['-tmp-workspace', '-temp-workspace'])( + 'finds the temp or shadowed home bucket %s', + async (bucket) => { + const temp = join(root, 'temp') + const cwd = join(temp, 'workspace') + await mkdir(cwd, { recursive: true }) + const expected = await record(bucket) + expect(await lookup(cwd, root, temp)).toBe(expected) + expect(io.open).toHaveBeenCalledTimes(1) + } + ) + + it('finds the reverted SHA256 bucket used by OMP 17.2.5–17.2.8', async () => { + const windows = process.platform === 'win32' + const digest = windows + ? '25910242104f20d3250db9cb42dca52b1ef2afc76aeebbb03cf50c958c3927e9' + : '5e800b892f87de7d83df9a448eb6a6f9e4c8ce34f082e23cc225a87e622553ac' + const expected = await record(`abs-orca-${digest}`) + expect(await lookup(windows ? 'C:\\repo\\orca' : '/repo/orca')).toBe(expected) + }) + + it('matches OMP historical absolute scope for a relative name beginning with two dots', async () => { + const home = join(root, 'home') + const cwd = join(home, '..project') + await mkdir(cwd, { recursive: true }) + const digest = createHash('sha256').update(cwd.replaceAll('\\', '/')).digest('hex') + const expected = await record(`abs-..project-${digest}`) + expect(await lookup(cwd, home)).toBe(expected) + }) + + it.skipIf(process.platform === 'win32')( + 'finds the canonical cwd bucket through a workspace alias', + async () => { + const home = join(root, 'home') + const cwd = join(home, 'workspace') + const alias = join(root, 'alias') + await mkdir(cwd, { recursive: true }) + await symlink(cwd, alias) + const expected = await record('-workspace') + expect(await lookup(alias, home)).toBe(expected) + } + ) + + it('never descends into session artifacts even when a child carries the wanted ID', async () => { + const dir = join(sessionsDir, '--repo-orca--') + const artifact = FILE.slice(0, -'.jsonl'.length) + await mkdir(join(dir, artifact), { recursive: true }) + await writeFile(join(dir, artifact, FILE), '{}\n') + expect(await lookup()).toBeNull() + expect(io.open.mock.calls.map(([path]) => path)).toEqual([dir]) + }) + + it('refuses unreadable groups without opening another workspace', async () => { + await record('--repo-orca--') + io.open.mockRejectedValue(new Error('EACCES')) + expect(await lookup()).toBeNull() + expect(io.open).toHaveBeenCalledTimes(1) + }) + + it.skipIf(process.platform === 'win32')('rejects symlinked groups and files', async () => { + const outside = join(root, 'outside') + await mkdir(outside, { recursive: true }) + await mkdir(sessionsDir, { recursive: true }) + await writeFile(join(outside, FILE), '{}\n') + const group = join(sessionsDir, '--repo-orca--') + await symlink(outside, group) + expect(await lookup()).toBeNull() + expect(io.open).not.toHaveBeenCalled() + await rm(group) + await mkdir(group) + await symlink(join(outside, FILE), join(group, FILE)) + expect(await lookup()).toBeNull() + }) +}) diff --git a/src/main/omp/omp-session-cwd-lookup.ts b/src/main/omp/omp-session-cwd-lookup.ts new file mode 100644 index 00000000000..023fe67292e --- /dev/null +++ b/src/main/omp/omp-session-cwd-lookup.ts @@ -0,0 +1,88 @@ +import { createHash } from 'node:crypto' +import { lstat, opendir, realpath } from 'node:fs/promises' +import { basename, extname, isAbsolute, join, relative, resolve } from 'node:path' +import { PI_CWD_BUCKET_LAYOUT } from '../ai-vault/session-cwd-bucket-layouts' +import { SESSION_FILE_ID_LAYOUTS } from '../native-chat/session-file-id-layouts' + +async function canonicalPath(path: string): Promise { + return realpath(path).catch(() => resolve(path)) +} + +function relativeBucket(root: string, cwd: string, prefix: string): string | null { + const path = relative(root, cwd) + if (path.startsWith('..') || isAbsolute(path)) { + return null + } + const encoded = path.replace(/[/\\:]/g, '-') + return encoded ? `${prefix}${prefix.endsWith('-') ? '' : '-'}${encoded}` : prefix +} + +function launchTempDir(env: NodeJS.ProcessEnv): string { + return process.platform === 'win32' + ? env.TEMP || env.TMP || join(env.SystemRoot || env.windir || 'C:\\Windows', 'temp') + : env.TMPDIR || env.TMP || env.TEMP || '/tmp' +} + +/** OMP's current and migrated cwd buckets, derived without enumerating other workspaces. */ +async function cwdBuckets(cwd: string, home: string, env: NodeJS.ProcessEnv): Promise { + const [canonicalCwd, canonicalHome, canonicalTemp] = await Promise.all([ + canonicalPath(cwd), + canonicalPath(home), + canonicalPath(launchTempDir(env)) + ]) + const homeBucket = relativeBucket(canonicalHome, canonicalCwd, '-') + const tempBucket = relativeBucket(canonicalTemp, canonicalCwd, '-tmp') + const legacy = [cwd, canonicalCwd].flatMap((path) => + PI_CWD_BUCKET_LAYOUT.encodeScopePrefixes(path).map((prefix) => `${prefix}--`) + ) + const scope = tempBucket !== null ? 'tmp' : homeBucket !== null ? 'home' : 'abs' + const readable = basename(canonicalCwd) + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(-80) + // OMP 17.2.5–17.2.8 used this name before restoring and migrating cwd buckets. + const digest = createHash('sha256').update(canonicalCwd.replaceAll('\\', '/')).digest('hex') + return [ + ...new Set([tempBucket, homeBucket, ...legacy, `${scope}-${readable || 'project'}-${digest}`]) + ].filter((name): name is string => name !== null) +} + +/** Import only a known workspace's direct session files, stopping at its first ID match. */ +export async function findOmpSessionByCwd(input: { + sessionsDir: string + sessionId: string + cwd: string + homeDir: string + env: NodeJS.ProcessEnv +}): Promise { + const sessionId = input.sessionId.trim() + if (!sessionId || !isAbsolute(input.cwd)) { + return null + } + for (const bucket of await cwdBuckets(input.cwd, input.homeDir, input.env)) { + const dirPath = join(input.sessionsDir, bucket) + try { + const stat = await lstat(dirPath) + if (!stat.isDirectory() || stat.isSymbolicLink()) { + continue + } + const directory = await opendir(dirPath) + for await (const entry of directory) { + if ( + !entry.isFile() || + entry.isSymbolicLink() || + extname(entry.name).toLowerCase() !== '.jsonl' + ) { + continue + } + const file = join(dirPath, entry.name) + if (SESSION_FILE_ID_LAYOUTS.omp.fileMatchesId(file, sessionId)) { + return file + } + } + } catch { + // A stale or unreadable bucket must not replace a missing history with a blank chat. + } + } + return null +} diff --git a/src/main/omp/omp-transcript-import.ts b/src/main/omp/omp-transcript-import.ts new file mode 100644 index 00000000000..3e9d67d6a88 --- /dev/null +++ b/src/main/omp/omp-transcript-import.ts @@ -0,0 +1,26 @@ +import { homedir } from 'node:os' +import { resolveOmpSessionsDir } from '../ai-vault/omp-session-root' +import type { StructuredAgentTranscriptImport } from '../native-chat/structured-agent-cli-conversations' +import { findOmpSessionByCwd } from './omp-session-cwd-lookup' + +export const OMP_TRANSCRIPT_IMPORT: StructuredAgentTranscriptImport = { + 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 }) => { + const homeDir = + (process.platform === 'win32' ? launchEnv.USERPROFILE : launchEnv.HOME) || homedir() + const sessionsDir = resolveOmpSessionsDir({ + env: { ...launchEnv, PI_CODING_AGENT_DIR: accountHomePath }, + homeDir + }) + return sessionsDir + ? findOmpSessionByCwd({ + sessionsDir, + sessionId: providerSessionId, + cwd: workspaceCwd, + homeDir, + env: launchEnv + }) + : 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 335727dada0..15f98405cd4 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 @@ -10,6 +10,7 @@ import { isNativeLocalLocation, LOCAL_EXECUTION_HOST_ID } from '../../shared/exe import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import { resolveHostAgentBaseEnvironment } from './structured-agent-shell-environment' import { resolveHostStructuredAgentCreateSupport } from './structured-agent-launch-support' import { structuredAgentRuntimeRegistration } from './structured-agent-runtime-registrations' import { structuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults' @@ -27,7 +28,6 @@ import { type AgentSessionAccountHome } from '../../shared/agent-session-account-home' import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' -import { agentSessionWireProviderHandle } from '../../shared/agent-session-provider-handle-encoding' export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithGetWorktreePs { async getStructuredAgentSessionCreateSupport( @@ -244,7 +244,9 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR transcriptImport, providerSessionId: input.resumeFrom.providerSessionId, selfSessionId: input.envelope.sessionId, - selectedAccountHomePath: selectedAccountHome.path + selectedAccountHomePath: selectedAccountHome.path, + launchEnv: { ...(await resolveHostAgentBaseEnvironment(settings)), ...launchEnv }, + workspaceCwd: (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path }) : null return { @@ -268,11 +270,11 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR // `providerHandle` alone must not: `agentSession.ensure` already passes one today // without adopting anything. adopt: { - providerHandle: agentSessionWireProviderHandle({ + providerHandle: { transport: definition.handleTransport, agent: definition.agent, nativeId: input.resumeFrom.providerSessionId - }), + }, transcriptPath: adoption.transcriptPath } } 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 efa3d531f1a..a0540854ea4 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 @@ -1,4 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const applyAgentWorkspaceTrust = vi.hoisted(() => vi.fn(async () => ({}))) vi.mock('../agent-workspace-trust', () => ({ applyAgentWorkspaceTrust })) @@ -11,6 +14,10 @@ beforeEach(() => { applyAgentWorkspaceTrust.mockClear() }) +afterEach(() => { + vi.unstubAllEnvs() +}) + function createCodexIntentRuntime( settings: Record, workspaceId = 'workspace-1', @@ -303,6 +310,79 @@ describe('structured agent-session create intent', () => { }) describe('adopting a conversation recorded outside Orca', () => { + it('resolves OMP history with the configured launch environment', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-omp-create-intent-')) + try { + const accountHome = join(root, 'account', 'agent') + const ambientData = join(root, 'ambient-data') + const launchData = join(root, 'launch-data') + const workspaceCwd = join(root, 'workspace') + const sessionId = '0198c2a4-7d1e-7c3a-9a4b-1f2e3d4c5b6a' + const sessionFile = join( + launchData, + 'omp', + 'profiles', + 'configured', + 'sessions', + '-workspace', + `2026-10-12T00-00-00-000Z_${sessionId}.jsonl` + ) + await mkdir(join(ambientData, 'omp', 'profiles', 'ambient'), { recursive: true }) + await mkdir(join(launchData, 'omp', 'profiles', 'configured'), { recursive: true }) + await mkdir(join(root, 'account'), { recursive: true }) + await mkdir(workspaceCwd, { recursive: true }) + await mkdir(dirname(sessionFile), { recursive: true }) + await writeFile( + sessionFile, + `${JSON.stringify({ type: 'session', id: sessionId, cwd: workspaceCwd })}\n` + ) + vi.stubEnv('XDG_DATA_HOME', ambientData) + vi.stubEnv('OMP_PROFILE', 'ambient') + const runtime = new OrcaRuntimeService( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create intent only reads getSettings from the store. + { + getSettings: () => ({ + agentDefaultEnv: { + omp: { + PI_CODING_AGENT_DIR: accountHome, + OMP_CODING_AGENT_DIR: '', + HOME: root, + XDG_DATA_HOME: launchData, + OMP_PROFILE: 'configured' + } + } + }) + } as never + ) + vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({ + supported: true + }) + Object.assign(runtime, { + resolveStructuredAgentSessionLocation: vi.fn(async () => ({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + })), + resolveRuntimeFileTarget: vi.fn(async () => ({ + worktree: { path: workspaceCwd } + })) + }) + + const intent = await runtime.resolveStructuredAgentSessionCreateIntent({ + envelope: { sessionId: 'chat-1', clientOperationId: 'operation-1' }, + worktree: 'id:workspace-1', + agent: 'omp', + resumeFrom: { providerSessionId: sessionId } + }) + + expect(intent.adopt?.transcriptPath).toBe(sessionFile) + expect(intent.accountHome).toEqual({ variable: 'PI_CODING_AGENT_DIR', path: accountHome }) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + it('refuses an agent registered with no transcript importer before any adoption work', async () => { const runtime = new OrcaRuntimeService( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create intent only reads getSettings from the store. diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 746f55af3f9..136f9b25975 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -221,7 +221,7 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio : {}), resolveAccountHome: ({ launchEnv }) => spec.account.resolve({ launchEnv }), modelCatalog: (context) => acpModelCatalogDiscovery(spec, context), - transcriptImport: null, + transcriptImport: spec.transcriptImport, sessionHistory: spec.sessionHistory, createAdapter: (context) => { const { deps, followUps } = context diff --git a/src/main/runtime/structured-agent-session-create-adoption.ts b/src/main/runtime/structured-agent-session-create-adoption.ts index 2a3abee4c5e..ec0878e2d37 100644 --- a/src/main/runtime/structured-agent-session-create-adoption.ts +++ b/src/main/runtime/structured-agent-session-create-adoption.ts @@ -65,6 +65,8 @@ export async function resolveStructuredAgentSessionAdoptionForCreate(input: { providerSessionId: string selfSessionId: string selectedAccountHomePath: string + launchEnv: NodeJS.ProcessEnv + workspaceCwd: string }) { const conflict = input.host ? findConflictingStructuredAdoption({ @@ -80,6 +82,8 @@ export async function resolveStructuredAgentSessionAdoptionForCreate(input: { return resolveStructuredAgentSessionAdoption({ providerSessionId: input.providerSessionId, candidateAccountHomes: input.transcriptImport.accountHomeCandidates(input), - resolveTranscript: input.transcriptImport.findTranscript + resolveTranscript: input.transcriptImport.findTranscript, + launchEnv: input.launchEnv, + workspaceCwd: input.workspaceCwd }) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index d2dd3dd7866..135a8d2ce21 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -23,7 +23,7 @@ import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume' import { prepareAiVaultSessionContinuation } from './ai-vault-session-continuation' import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation' import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session' -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { adoptsCliConversations } from '../../../../shared/structured-agent-cli-conversation-adoption' import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' import { activateAiVaultResumeWorkspace, @@ -197,7 +197,7 @@ export function useAiVaultSessionLaunchActions({ const handleResumeInNewChat = useCallback( (session: AiVaultSession, targetWorktreeId?: string): void => { - if (!isAgentSessionHandleProvider(session.agent)) { + if (!adoptsCliConversations(session.agent)) { return } const worktreeId = targetWorktreeId ?? activeWorktreeId ?? activeWorktree?.id ?? null 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 7ddd203177e..984b2b5497a 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,7 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import type { AgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import type { CliConversationAdoptingAgent } 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 +20,7 @@ export function activateAiVaultResumeWorkspace(workspaceId: string): boolean { * would hide the refusal the user needs to see. */ export async function resumeAiVaultSessionInNewChat( session: AiVaultSession, - agent: AgentSessionHandleProvider, + agent: CliConversationAdoptingAgent, worktreeId: string, requestId: AgentLaunchRequestId ): Promise { 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 16ed2c3a96d..e62f0b2a5d4 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,7 @@ 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 { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { adoptsCliConversations } 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 { @@ -44,7 +44,7 @@ export function resolveAiVaultSessionResumeInChatForWorkspace(args: { targetWorkspaceId, targetWorkspacePath, structuredRouteAvailable: - isAgentSessionHandleProvider(args.session.agent) && + adoptsCliConversations(args.session.agent) && targetWorkspaceId !== null && targetOwner !== null && targetOwner === normalizeExecutionHostId(args.session.executionHostId) && 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 f945cdaa95a..d2cfeb3fd42 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,8 +40,18 @@ describe('resolveAiVaultSessionResumeInChatEligibility', () => { expect(eligibility()).toEqual({ available: true, workspaceId: 'repo-1::/repo/orca' }) }) - it.each(['hermes', 'grok', 'opencode'] as AiVaultSession['agent'][])( - 'refuses %s, which has no structured lane', + it.each(['grok', 'omp'])( + 'offers the chat for a local %s row in its own workspace', + (agent) => { + expect(eligibility({ session: session({ agent }) })).toEqual({ + available: true, + workspaceId: 'repo-1::/repo/orca' + }) + } + ) + + it.each(['hermes', 'opencode', 'opencode2', 'pi'])( + 'refuses %s, whose chats cannot take over a CLI conversation', (agent) => { expect(eligibility({ session: session({ agent }) })).toEqual({ available: false, @@ -113,16 +123,19 @@ describe('resolveAiVaultSessionResumeInChatEligibility', () => { }) }) -describe('workspace matching, which only Claude is bound by', () => { - it('refuses a Claude row whose conversation was recorded in another workspace', () => { - // Claude's SDK keys transcripts by launch cwd, so resuming elsewhere silently finds nothing. - expect( - eligibility({ - session: session({ cwd: '/repo/other' }), - targetWorkspacePath: WORKSPACE_PATH - }) - ).toEqual({ available: false, reason: 'workspace' }) - }) +describe('workspace matching, which binds the agents that store conversations per folder', () => { + it.each(['claude', 'grok', 'omp'])( + 'refuses a %s row whose conversation was recorded in another workspace', + (agent) => { + // These key conversations by launch cwd, so resuming elsewhere silently finds nothing. + expect( + eligibility({ + session: session({ agent, cwd: '/repo/other' }), + targetWorkspacePath: WORKSPACE_PATH + }) + ).toEqual({ available: false, reason: 'workspace' }) + } + ) it('refuses a Claude row that recorded no cwd', () => { expect(eligibility({ session: session({ cwd: null }) })).toEqual({ 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 ec18262ed09..695d38b5cf3 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 @@ -8,7 +8,7 @@ import { isAntigravityReferenceSession } from '../../../../shared/antigravity-se import { isWslStoredAiVaultSessionFile } from '@/lib/ai-vault-resume-target' import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { adoptsCliConversations } from '../../../../shared/structured-agent-cli-conversation-adoption' import { isAiVaultSessionResumableContent, type AiVaultSession @@ -26,17 +26,17 @@ export type AiVaultResumeInChatEligibility = | { available: false; reason: AiVaultResumeInChatBlockedReason } /** - * Claude and Codex do not have the same freedom about *where* a conversation may be resumed. + * Agents do not have the same freedom about *where* a conversation may be resumed. * - * Codex is handed the rollout file and a cwd, so it can resume into any workspace. Claude's SDK - * stores transcripts under a project key derived from the launch cwd, so resuming from a workspace - * other than the one the conversation was recorded in looks in a directory the transcript is not in. + * 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. * 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' + return agent === 'claude' || agent === 'grok' || agent === 'omp' } /** @@ -71,7 +71,7 @@ export function resolveAiVaultSessionResumeInChatEligibility(args: { structuredRouteAvailable: boolean }): AiVaultResumeInChatEligibility { const { session } = args - if (isAntigravityReferenceSession(session) || !isAgentSessionHandleProvider(session.agent)) { + if (isAntigravityReferenceSession(session) || !adoptsCliConversations(session.agent)) { return { available: false, reason: 'agent' } } // An already-adopted row reopens its own chat instead; offering a second resume of it would ask 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 bcb2c770f51..650672deb79 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 @@ -6,7 +6,10 @@ import { makeAgentStatusEntry } from '@/runtime/sync-runtime-graph-test-harness' import { collectAiVaultTitleRequests } from '@/lib/ai-vault-tab-title-requests' import type { AiVaultListArgs, AiVaultSession } from '../../../../shared/ai-vault-types' import { getDefaultSettings } from '../../../../shared/constants' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import { claimAiVaultForcedRescan, resetAiVaultForcedRescanThrottleForTest @@ -35,7 +38,15 @@ const mocks = vi.hoisted(() => { // Staged host answers: its capabilities arrive over IPC at boot, and the platform from preload. vi.mock('@/runtime/local-runtime-capabilities', async (importOriginal) => ({ ...(await importOriginal>()), - readLocalRuntimeCapabilitiesOrUnknown: () => [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + readLocalRuntimeCapabilitiesOrUnknown: () => [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ] +})) +vi.mock('@/runtime/host-structured-agents', async (importOriginal) => ({ + ...(await importOriginal>()), + readHostStructuredAgents: () => + ['claude', 'codex', 'grok', 'omp'].map((agent) => ({ agent, capabilities: {} })) })) vi.mock('@/lib/renderer-app-platform', () => ({ getRendererAppPlatform: () => mocks.platform })) @@ -276,6 +287,42 @@ describe('resolveTabSessionHistorySubject', () => { ).toEqual(cliSubject) }) + it.each(['grok', 'omp'] as const)( + 'finds a %s terminal tab, whose conversation a chat can take over', + (agentType) => { + const state = makeState({ + 'term-1:leaf-1': makeAgentStatusEntry({ + agentType, + paneKey: 'term-1:leaf-1', + tabId: 'term-1', + worktreeId: WORKTREE_ID, + providerSession: { key: 'session_id', id: `${agentType}-session-1` } + }) + }) + expect( + resolveTabSessionHistorySubject(state, { tab: { id: 'term-1', worktreeId: WORKTREE_ID } }) + ).toEqual({ ...cliSubject, agent: agentType, providerSessionId: `${agentType}-session-1` }) + } + ) + + it.each(['opencode', 'pi'] as const)( + 'skips a %s terminal tab, whose conversation no chat can take over', + (agentType) => { + const state = makeState({ + 'term-1:leaf-1': makeAgentStatusEntry({ + agentType, + paneKey: 'term-1:leaf-1', + tabId: 'term-1', + worktreeId: WORKTREE_ID, + providerSession: { key: 'session_id', id: `${agentType}-session-1` } + }) + }) + expect( + resolveTabSessionHistorySubject(state, { tab: { id: 'term-1', worktreeId: WORKTREE_ID } }) + ).toBeNull() + } + ) + it('looks nothing up over SSH, where neither move is ever offered', () => { const state = makeState(liveClaudeEntry, { connectionId: 'dev-box' }) expect( @@ -322,16 +369,16 @@ describe('resolveTabSessionHistorySubject', () => { describe('one pane of a terminal tab', () => { const claudePane = 'term-1:leaf-1' - const grokPane = 'term-1:leaf-2' + const geminiPane = 'term-1:leaf-2' const splitTab = (activeLeafId: string): AppState => { makeState({ [claudePane]: liveClaudeEntry['term-1:leaf-1'], - [grokPane]: makeAgentStatusEntry({ - agentType: 'grok', - paneKey: grokPane, + [geminiPane]: makeAgentStatusEntry({ + agentType: 'gemini', + paneKey: geminiPane, tabId: 'term-1', worktreeId: WORKTREE_ID, - providerSession: { key: 'session_id', id: 'grok-session-1' } + providerSession: { key: 'session_id', id: 'gemini-session-1' } }) }) useAppStore.setState({ @@ -349,12 +396,31 @@ describe('one pane of a terminal tab', () => { it("never stands in a sibling pane's conversation for a pane with no move", () => { const state = splitTab('leaf-2') - // The tab as a whole maps to the Claude pane; the Grok pane on its own has nothing. + // The tab as a whole maps to the Claude pane; the Gemini pane on its own has nothing. expect( resolveTabSessionHistorySubject(state, { tab: { id: 'term-1', worktreeId: WORKTREE_ID } }) ).toEqual(cliSubject) - expect(paneSubject(state, grokPane)).toBeNull() - expect(paneGate(state, grokPane)).toBe(false) + expect(paneSubject(state, geminiPane)).toBeNull() + 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` } + }) + }) + 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", () => { 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 72f996eff4e..33b75762483 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 @@ -4,8 +4,8 @@ import type { AppState } from '@/store/types' import { getIndexedAllWorktrees } from '@/store/worktree-repo-index' import { - collectAiVaultTitleRequests, - resolvePaneAiVaultTitleRequest + collectAgentPaneConversations, + resolvePaneAgentConversation } from '@/lib/ai-vault-tab-title-requests' import { isAiVaultSessionResumableContent, @@ -18,7 +18,10 @@ import { parseExecutionHostId, type ExecutionHostScope } from '../../../../shared/execution-host' -import type { AgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { + adoptsCliConversations, + type CliConversationAdoptingAgent +} from '../../../../shared/structured-agent-cli-conversation-adoption' import { agentForksConversations } from '../../../../shared/agent-resume-argv' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { AgentType } from '../../../../shared/agent-status-types' @@ -50,7 +53,7 @@ export type TabSessionHistorySubject = { | { kind: 'chat'; sessionId: string } | { kind: 'cli' - agent: AgentSessionHandleProvider + agent: CliConversationAdoptingAgent providerSessionId: string } ) @@ -79,22 +82,22 @@ export function canPaneOfferResumeInNewChat( state: AppState, pane: { worktreeId: string; paneKey: string } ): boolean { - const titleRequest = resolvePaneAiVaultTitleRequest(state, pane) - if (!titleRequest) { + const conversation = resolvePaneAgentConversation(state, pane, adoptsCliConversations) + if (!conversation) { return false } const hostScope = resolveAiVaultHostScopeDefaults( - getAiVaultResumeWorkspaceExecutionHostId(state, titleRequest.worktreeId), - titleRequest.worktreeId + getAiVaultResumeWorkspaceExecutionHostId(state, conversation.worktreeId), + conversation.worktreeId ).defaultExecutionHostScope // The row gate's launch-route half, so a project the chat route refuses (WSL, repair) hides it. return ( canHostOfferTabSessionMove('cli', hostScope) && structuredAgentSessionLaunchFeasible(state, { - agent: titleRequest.agent, + agent: conversation.agent, workspace: { - kind: workspaceKindForWorktreeId(titleRequest.worktreeId), - worktreeId: titleRequest.worktreeId + kind: workspaceKindForWorktreeId(conversation.worktreeId), + worktreeId: conversation.worktreeId }, settings: state.settings }) @@ -134,23 +137,25 @@ export function resolveTabSessionHistorySubject( : null return target ? { ...target, kind: 'chat', sessionId: args.nativeChat.sessionId } : null } - // The pane-to-conversation mapping tab titles use, limited to agents still running: once the agent - // exits, the tab title keeps its conversation but the move is no longer offered. - const titleRequest = args.paneKey - ? resolvePaneAiVaultTitleRequest(state, { - worktreeId: args.tab.worktreeId, - paneKey: args.paneKey - }) - : (collectAiVaultTitleRequests(state, { runningAgentsOnly: true }).find( - (candidate) => candidate.tabId === args.tab.id - ) ?? null) - const target = titleRequest ? workspace(titleRequest.worktreeId, 'cli') : null - return titleRequest && target + // The pane-to-conversation mapping tab titles use, for the agents a chat can take over, limited to + // agents still running: once the agent exits, the tab title keeps its conversation but the move is + // no longer offered. + const conversation = args.paneKey + ? resolvePaneAgentConversation( + state, + { worktreeId: args.tab.worktreeId, paneKey: args.paneKey }, + adoptsCliConversations + ) + : (collectAgentPaneConversations(state, adoptsCliConversations, { + runningAgentsOnly: true + }).find((candidate) => candidate.tabId === args.tab.id) ?? null) + const target = conversation ? workspace(conversation.worktreeId, 'cli') : null + return conversation && target ? { ...target, kind: 'cli', - agent: titleRequest.agent, - providerSessionId: titleRequest.providerSession.id + agent: conversation.agent, + providerSessionId: conversation.providerSession.id } : null } diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.test.tsx index dd6cf0b9e26..a5acd9341ff 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.test.tsx @@ -127,6 +127,15 @@ describe('TerminalPaneResumeInChatButton', () => { expect(mocks.toastError).not.toHaveBeenCalled() }) + it('resumes a Grok conversation, which a chat can take over too', async () => { + const grokRow = { ...CLI_ROW, agent: 'grok' as const, sessionId: 'grok-session-1' } + mocks.subject = { ...CLI_SUBJECT, agent: 'grok', providerSessionId: 'grok-session-1' } + mocks.move = { action: 'resume-in-new-chat', worktreeId: 'wt-1' } + mocks.listSessions.mockResolvedValue({ sessions: [grokRow], issues: [], scannedAt: 'now' }) + await clickResume() + expect(mocks.resumeInNewChat).toHaveBeenCalledWith(grokRow, 'grok', 'wt-1', expect.any(String)) + }) + it('opens the native chat that already holds the conversation', async () => { const owned = { ...CLI_ROW, structuredSession: { sessionId: 'chat-1', workspaceId: 'wt-1' } } mocks.listSessions.mockResolvedValue({ sessions: [owned], issues: [], scannedAt: 'now' }) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.tsx index 8ad537b89fd..fb804778f3b 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.tsx @@ -10,7 +10,7 @@ import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-struct import { useAppStore } from '@/store' import { isAiVaultSessionResumableContent } from '../../../../shared/ai-vault-types' import { agentLabel } from '../../../../shared/ai-vault-session-filters' -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { adoptsCliConversations } from '../../../../shared/structured-agent-cli-conversation-adoption' import { resumeAiVaultSessionInNewChat } from '../right-sidebar/ai-vault-session-resume-in-chat-launch' import { lookupTabSessionHistoryRow, @@ -85,7 +85,7 @@ export function TerminalPaneResumeInChatButton({ return } const move = resolveTabSessionSwitch(useAppStore.getState(), row, subject) - if (move?.action === 'resume-in-new-chat' && isAgentSessionHandleProvider(row.agent)) { + if (move?.action === 'resume-in-new-chat' && adoptsCliConversations(row.agent)) { await resumeAiVaultSessionInNewChat( row, row.agent, diff --git a/src/renderer/src/lib/ai-vault-tab-title-requests.ts b/src/renderer/src/lib/ai-vault-tab-title-requests.ts index 2b1ecba2ab8..3c9da0063b0 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-requests.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-requests.ts @@ -8,8 +8,9 @@ import type { TerminalTab } from '../../../shared/terminal-tab-types' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import type { AppState } from '@/store/types' -export type AiVaultTitleRequest = { - agent: AiVaultSessionTitle['agent'] +/** The conversation a tab's pane runs, for an agent `isAgent` accepts. */ +export type AgentPaneConversation = { + agent: Agent executionHostId: ExecutionHostId providerSession: AgentProviderSessionMetadata refresh: boolean @@ -17,7 +18,11 @@ export type AiVaultTitleRequest = { worktreeId: string } -type RequestCandidate = AiVaultTitleRequest & { priority: number } +export type AiVaultTitleRequest = AgentPaneConversation + +type RequestCandidate = AgentPaneConversation & { + priority: number +} function tabIdFromPaneKey(paneKey: string, tabId?: string): string | null { return tabId?.trim() || parsePaneKey(paneKey)?.tabId || null @@ -28,10 +33,11 @@ function activePaneKey(state: AppState, tabId: string): string | null { return activeLeafId ? `${tabId}:${activeLeafId}` : null } -function registerCandidate( +function registerCandidate( state: AppState, tabsById: ReadonlyMap, - candidates: Map, + candidates: Map>, + isAgent: (agent: AgentType | null | undefined) => agent is Agent, args: { agent: AgentType | null | undefined paneKey: string @@ -42,7 +48,7 @@ function registerCandidate( worktreeId?: string } ): void { - if (!isAiVaultTitleAgent(args.agent) || !args.providerSession?.id) { + if (!isAgent(args.agent) || !args.providerSession?.id) { return } const tabId = tabIdFromPaneKey(args.paneKey, args.tabId) @@ -67,7 +73,7 @@ function registerCandidate( }) } -function liveCandidate(entry: AgentStatusEntry): Parameters[3] { +function liveCandidate(entry: AgentStatusEntry): Parameters[4] { return { agent: entry.agentType, paneKey: entry.paneKey, @@ -79,26 +85,32 @@ function liveCandidate(entry: AgentStatusEntry): Parameters( state: AppState, + isAgent: (agent: AgentType | null | undefined) => agent is Agent, options: { /** Skip retained and sleeping records: those remember a conversation after its agent exited. */ runningAgentsOnly?: boolean } = {} -): AiVaultTitleRequest[] { +): AgentPaneConversation[] { const tabsById = new Map( Object.values(state.tabsByWorktree) .flat() .map((tab) => [tab.id, tab] as const) ) - const candidates = new Map() + const candidates = new Map>() const retained = options.runningAgentsOnly ? [] : Object.values(state.retainedAgentsByPaneKey) const sleeping = options.runningAgentsOnly ? [] : Object.values(state.sleepingAgentSessionsByPaneKey) for (const entry of retained) { - registerCandidate(state, tabsById, candidates, { + registerCandidate(state, tabsById, candidates, isAgent, { agent: entry.agentType, paneKey: entry.entry.paneKey, priority: 10, @@ -109,7 +121,7 @@ export function collectAiVaultTitleRequests( }) } for (const record of sleeping) { - registerCandidate(state, tabsById, candidates, { + registerCandidate(state, tabsById, candidates, isAgent, { agent: record.agent, paneKey: record.paneKey, priority: 20, @@ -120,17 +132,18 @@ export function collectAiVaultTitleRequests( }) } for (const entry of Object.values(state.agentStatusByPaneKey)) { - registerCandidate(state, tabsById, candidates, liveCandidate(entry)) + registerCandidate(state, tabsById, candidates, isAgent, liveCandidate(entry)) } return [...candidates.values()].map(({ priority: _priority, ...request }) => request) } /** One pane's running agent, mapped as above, so a pane's action finds that pane's own conversation. */ -export function resolvePaneAiVaultTitleRequest( +export function resolvePaneAgentConversation( state: AppState, - pane: { worktreeId: string; paneKey: string } -): AiVaultTitleRequest | null { + pane: { worktreeId: string; paneKey: string }, + isAgent: (agent: AgentType | null | undefined) => agent is Agent +): AgentPaneConversation | null { const live = state.agentStatusByPaneKey[pane.paneKey] if (live?.paneKey !== pane.paneKey) { return null @@ -138,8 +151,8 @@ export function resolvePaneAiVaultTitleRequest( const tabsById = new Map( (state.tabsByWorktree[pane.worktreeId] ?? []).map((tab) => [tab.id, tab] as const) ) - const candidates = new Map() - registerCandidate(state, tabsById, candidates, liveCandidate(live)) + const candidates = new Map>() + registerCandidate(state, tabsById, candidates, isAgent, liveCandidate(live)) const [candidate] = candidates.values() if (!candidate) { return null diff --git a/src/shared/grok-session-paths.test.ts b/src/shared/grok-session-paths.test.ts index f4c9cbaca1b..2607ef7b9f1 100644 --- a/src/shared/grok-session-paths.test.ts +++ b/src/shared/grok-session-paths.test.ts @@ -72,16 +72,36 @@ describe('grok-session-paths', () => { } ) - it('refuses to invent encodeURIComponent names longer than 255 bytes', () => { + it('uses the CLI slug and BLAKE3 name for long cwd paths', () => { const longCwd = `/${'a'.repeat(200)}/${'b'.repeat(200)}` - expect(grokEncodedCwdDirName(longCwd)).toBeNull() + const group = `${'b'.repeat(40)}-e22e6487078c7674` + expect(grokEncodedCwdDirName(longCwd)).toBe(group) expect( buildGrokChatHistoryPathCandidates({ sessionId: 'sess-1', cwd: longCwd, sessionsDir: '/tmp/sessions' }) - ).toEqual([]) + ).toEqual([join('/tmp/sessions', group, 'sess-1', 'chat_history.jsonl')]) + }) + + it.each([ + [`/Users/test/${'中'.repeat(30)}`, 'workspace-43f12fb9a5ee1037'], + [ + '/Users/dev/Documents/開発プロジェクト/機能追加/テスト環境/ソースコード/main-branch', + 'main-branch-6aaeefdde2a621aa' + ], + [`/${'a'.repeat(260)}/My Workspace `, 'my-workspace-821ab64a500ab620'], + [`/${'a'.repeat(260)}/İProject`, 'i-project-a676b9c62bbe86c8'], + [`/${'a'.repeat(260)}/${'A'.repeat(39)} !! end`, `${'a'.repeat(39)}--6306ef8d209ddda1`] + ])('matches the raw UTF8 cwd vector %s', (cwd, expected) => { + expect(grokEncodedCwdDirName(cwd)).toBe(expected) + }) + + it('matches the CLI URL encoding and switches layouts only beyond 255 bytes', () => { + expect(grokEncodedCwdDirName("/repo/!'()*")).toBe('%2Frepo%2F%21%27%28%29%2A') + expect(grokEncodedCwdDirName(`/${'a'.repeat(252)}`)).toBe(`%2F${'a'.repeat(252)}`) + expect(grokEncodedCwdDirName(`/${'a'.repeat(253)}`)).toMatch(/^a{40}-[a-f0-9]{16}$/) }) it('rejects unsafe session ids and path-special cwd components', async () => { @@ -123,15 +143,15 @@ describe('grok-session-paths', () => { ).toBe(history) }) - it('does not synchronously discover a long-cwd slug group', () => { + it('resolves the CLI long-cwd slug group without scanning other groups', () => { const root = makeRoot() const sessionsDir = join(root, 'sessions') const sessionId = 'sess-long' // Simulate Grok's slug+hash group directory (not encodeURIComponent of cwd). - const slugGroup = 'long-path-ab12cd34' + const slugGroup = `${'x'.repeat(40)}-bc763004a2f1ee5a` const history = join(sessionsDir, slugGroup, sessionId, 'chat_history.jsonl') mkdirSync(dirname(history), { recursive: true }) - writeFileSync(join(sessionsDir, slugGroup, '.cwd'), '/very/long/path\n') + writeFileSync(join(sessionsDir, slugGroup, '.cwd'), `/${'x'.repeat(300)}\n`) writeFileSync(history, '{"type":"assistant","content":"hi"}\n') expect( @@ -140,7 +160,7 @@ describe('grok-session-paths', () => { cwd: `/${'x'.repeat(300)}`, sessionsDir }) - ).toBeNull() + ).toBe(history) }) it('finds the documented group/session layout asynchronously', async () => { diff --git a/src/shared/grok-session-paths.ts b/src/shared/grok-session-paths.ts index 39f8bfc4a94..0f3287d50c1 100644 --- a/src/shared/grok-session-paths.ts +++ b/src/shared/grok-session-paths.ts @@ -1,7 +1,9 @@ import { lstatSync } from 'node:fs' import { lstat, opendir } from 'node:fs/promises' import { homedir } from 'node:os' -import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { blake3 } from '@noble/hashes/blake3' +import { bytesToHex } from '@noble/hashes/utils' import { resolveAbsoluteDirOverride } from './absolute-dir-override' import { GrokSessionPathLookupQueue, @@ -52,15 +54,17 @@ export function resolveGrokSessionsDir( return join(resolveGrokHomeDir(env, homeDir), 'sessions') } -/** Return Grok's safe cwd-group component, or null for slug/invalid layouts. */ +/** Return the CLI's encoded cwd or long-path slug and BLAKE3 group component. */ export function grokEncodedCwdDirName(cwd: string): string | null { - const trimmed = cwd.trim() - if (!trimmed) { + if (!cwd.trim()) { return null } let encoded: string try { - encoded = encodeURIComponent(trimmed) + encoded = encodeURIComponent(cwd).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}` + ) } catch { return null } @@ -68,7 +72,16 @@ export function grokEncodedCwdDirName(cwd: string): string | null { if (encoded === '.' || encoded === '..' || encoded.includes('/') || encoded.includes('\\')) { return null } - return Buffer.byteLength(encoded, 'utf8') <= GROK_ENCODED_CWD_DIR_MAX_BYTES ? encoded : null + if (Buffer.byteLength(encoded, 'utf8') <= GROK_ENCODED_CWD_DIR_MAX_BYTES) { + return encoded + } + const slug = basename(cwd) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) + const hash = bytesToHex(blake3(Buffer.from(cwd, 'utf8'))).slice(0, 16) + return `${slug || 'workspace'}-${hash}` } /** Fast-path candidates when both session id and cwd are known. */ @@ -81,8 +94,8 @@ export function buildGrokChatHistoryPathCandidates(args: { if (!isSafeGrokSessionId(sessionId)) { return [] } - const cwd = args.cwd?.trim() - if (!cwd) { + const cwd = args.cwd + if (!cwd?.trim()) { return [] } const encoded = grokEncodedCwdDirName(cwd) @@ -120,6 +133,20 @@ export function resolveGrokChatHistoryPathSync(args: { return null } +/** Async exact-cwd lookup for host-owned workspace paths before the bounded ID scan. */ +export async function findGrokChatHistoryByCwd(args: { + sessionId: string + cwd: string + sessionsDir: string +}): Promise { + for (const candidate of buildGrokChatHistoryPathCandidates(args)) { + if (await isSafeChatHistoryFile(args.sessionsDir, candidate)) { + return candidate + } + } + return null +} + type GrokSessionDirectoryEntry = { name: string isDirectory(): boolean diff --git a/src/shared/structured-agent-cli-conversation-adoption.ts b/src/shared/structured-agent-cli-conversation-adoption.ts new file mode 100644 index 00000000000..a5d71b0db79 --- /dev/null +++ b/src/shared/structured-agent-cli-conversation-adoption.ts @@ -0,0 +1,13 @@ +/** + * 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. + */ +export const CLI_CONVERSATION_ADOPTING_AGENTS = ['claude', 'codex', 'grok', 'omp'] as const + +export type CliConversationAdoptingAgent = (typeof CLI_CONVERSATION_ADOPTING_AGENTS)[number] + +export function adoptsCliConversations(agent: unknown): agent is CliConversationAdoptingAgent { + return CLI_CONVERSATION_ADOPTING_AGENTS.some((adopting) => adopting === agent) +}