From 8a0f1ee4b6241dc59cbc8efc83e4fd31c994c915 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:41:46 -0700 Subject: [PATCH] Resume in New Native Chat for Grok and OMP terminal conversations (#28049) * feat(chat): Resume in New Native Chat for Grok and OMP terminal conversations A Grok or OMP conversation started in the terminal now offers "Resume in New Native Chat" in its Session History row menu and its tab's right-click menu, like Claude and Codex: a new chat of that agent takes the conversation over, showing its history and continuing it. The host finds the conversation through the agent's transcript import and fills the chat from it, the same adoption path Claude and Codex use; the adopted handle is now any agent's, not only theirs. Both agents keep conversations per folder, so the action shows only in the workspace the conversation was recorded in, as for Claude. * fix(chat): hide Grok 1.0's runtime context and rules in a chat that takes over its conversation Grok 1.0 appends the user's rules to its runtime-context row; the decoder only knew the older shape, so a Grok chat taking over a terminal conversation showed that row as the first message. * Fix OMP and Grok transcript lookup for chat adoption * Fix scoped Grok and OMP chat history lookup --- .../electron-vite-output-contract.test.ts | 2 + src/main/acp/acp-launch-specs.ts | 12 +- src/main/grok/grok-transcript-import.ts | 22 ++ ...ent-session-adapter-router-test-support.ts | 21 +- ...tured-agent-session-adopted-import.test.ts | 69 ++++- ...structured-agent-session-adopted-import.ts | 5 +- .../structured-agent-session-attach.ts | 30 +-- ...ured-agent-session-create-adoption.test.ts | 5 +- ...ed-agent-cli-conversation-adoption.test.ts | 245 ++++++++++++++++++ .../structured-agent-cli-conversations.ts | 2 + ...red-agent-session-history-adoption.test.ts | 20 +- ...ructured-agent-session-history-adoption.ts | 25 +- .../transcript-line-decoders-grok.ts | 21 +- .../transcript-line-decoders.grok.test.ts | 24 ++ src/main/omp/omp-session-cwd-lookup.test.ts | 174 +++++++++++++ src/main/omp/omp-session-cwd-lookup.ts | 88 +++++++ src/main/omp/omp-transcript-import.ts | 26 ++ ...structured-agent-session-create-support.ts | 10 +- ...ctured-agent-session-create-intent.test.ts | 82 +++++- .../structured-agent-runtime-registrations.ts | 2 +- ...tructured-agent-session-create-adoption.ts | 6 +- .../ai-vault-session-launch-actions.ts | 4 +- .../ai-vault-session-resume-in-chat-launch.ts | 4 +- ...-vault-session-resume-in-chat-workspace.ts | 4 +- .../ai-vault-session-resume-in-chat.test.ts | 37 ++- .../ai-vault-session-resume-in-chat.ts | 14 +- .../tab-session-history-switch.test.ts | 86 +++++- .../tab-bar/tab-session-history-switch.ts | 55 ++-- .../TerminalPaneResumeInChatButton.test.tsx | 9 + .../TerminalPaneResumeInChatButton.tsx | 4 +- .../src/lib/ai-vault-tab-title-requests.ts | 49 ++-- src/shared/grok-session-paths.test.ts | 34 ++- src/shared/grok-session-paths.ts | 43 ++- ...uctured-agent-cli-conversation-adoption.ts | 13 + 34 files changed, 1080 insertions(+), 167 deletions(-) create mode 100644 src/main/grok/grok-transcript-import.ts create mode 100644 src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts create mode 100644 src/main/omp/omp-session-cwd-lookup.test.ts create mode 100644 src/main/omp/omp-session-cwd-lookup.ts create mode 100644 src/main/omp/omp-transcript-import.ts create mode 100644 src/shared/structured-agent-cli-conversation-adoption.ts 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) +}