diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 5353d2b91b5..3f814ada6f7 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -27,6 +27,10 @@ import { loadOpenCodeVisualsSkill, type AcpVisualsSkillLoader } from './acp-visuals-skill' +import type { StructuredAgentSessionHistory } from '../native-chat/structured-agent-cli-conversations' +import { GROK_SESSION_HISTORY } from '../grok/grok-session-history' +import { OPENCODE_SESSION_HISTORY } from '../opencode/opencode-session-history' +import { OMP_SESSION_HISTORY } from '../omp/omp-session-history' /** 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`. */ @@ -87,6 +91,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 the agent's chats own the Session History rows their conversations list as. */ + sessionHistory: StructuredAgentSessionHistory } const GROK_LAUNCH_SPEC: AcpLaunchSpec = { @@ -119,7 +125,8 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = { listingNamesConfiguredModel: false }, visualsSkill: loadGrokVisualsSkill, - compaction: true + compaction: true, + sessionHistory: GROK_SESSION_HISTORY } // `opencode acp` on 1.x serves in-process; on 2.x it starts a private `opencode serve --stdio` child @@ -157,7 +164,8 @@ const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = { listingNamesConfiguredModel: false }, visualsSkill: loadOpenCodeVisualsSkill, - compaction: true + compaction: true, + sessionHistory: OPENCODE_SESSION_HISTORY } // OMP serves ACP through `omp acp`; its environment reaches it as the user set it. @@ -184,7 +192,8 @@ const OMP_LAUNCH_SPEC: AcpLaunchSpec = { reason: 'omp has no session-free listing that matches what its chats offer' }, visualsSkill: loadOmpVisualsSkill, - compaction: true + compaction: true, + sessionHistory: OMP_SESSION_HISTORY } export const ACP_LAUNCH_SPECS: readonly AcpLaunchSpec[] = [ diff --git a/src/main/ai-vault/shell-command-words.test.ts b/src/main/ai-vault/shell-command-words.test.ts new file mode 100644 index 00000000000..97920230c9d --- /dev/null +++ b/src/main/ai-vault/shell-command-words.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { commandProgramIndex, shellCommands } from './shell-command-words' + +const commands = (text: string) => [...shellCommands(text)] + +describe('shellCommands', () => { + it('splits at separators glued to words, outside quotes only', () => { + expect(commands(`cd /x&&a -s 1;b|c||d&e`)).toEqual([ + ['cd', '/x'], + ['a', '-s', '1'], + ['b'], + ['c'], + ['d'], + ['e'] + ]) + expect(commands(`a "x;y" 'p|q&&r' z`)).toEqual([['a', 'x;y', 'p|q&&r', 'z']]) + }) + + it('joins quoted runs into one word', () => { + expect(commands(`a --session="s 1" -r='x'y 'it'"'"'s'`)).toEqual([ + ['a', '--session=s 1', '-r=xy', "it's"] + ]) + expect(commands(`a "say \\"hi\\"" b`)).toEqual([['a', 'say \\"hi\\"', 'b']]) + }) + + it('keeps redirections inside the command', () => { + expect(commands(`a 2>&1 >log b&>/dev/null c`)).toEqual([ + ['a', '2>&1', '>log', 'b&>/dev/null', 'c'] + ]) + }) + + it('reads an unclosed quote as a character', () => { + expect(commands(`a "b c; d`)).toEqual([['a', '"b', 'c'], ['d']]) + }) + + it('continues a line after a continuation mark', () => { + expect(commands('a \\\n -r 1\r\nb x`\r\ny')).toEqual([ + ['a', '-r', '1'], + ['b', 'x', 'y'] + ]) + }) + + it('stays linear on long and unbalanced texts', () => { + const size = 2 * 1024 * 1024 + expect(commands('a'.repeat(size))[0]![0]).toHaveLength(size) + expect(commands(`"${' \\"'.repeat(size / 3)}`)).toHaveLength(1) + expect(commands(`'${" '".repeat(size / 2)}`)).toHaveLength(1) + }) +}) + +describe('commandProgramIndex', () => { + it.each([ + [['claude', '-r', 'x'], 0], + [['A=1', 'B=2', 'claude'], 2], + [['env', '-i', 'A=1', 'claude'], 3], + [['sudo', '-E', 'claude'], 2], + [['pnpm', 'dlx', 'claude'], 2], + [['nohup', 'claude'], 1], + [['time', 'codex', 'resume', 'x'], 1], + [['wsl.exe', '--', 'claude'], 2], + [['/usr/bin/env', 'claude'], 1], + [['A=1'], -1] + ])('finds the program of %j at %i', (words, index) => { + expect(commandProgramIndex(words)).toBe(index) + }) +}) diff --git a/src/main/ai-vault/shell-command-words.ts b/src/main/ai-vault/shell-command-words.ts new file mode 100644 index 00000000000..fca639b8624 --- /dev/null +++ b/src/main/ai-vault/shell-command-words.ts @@ -0,0 +1,137 @@ +// Reads a command line as a shell would, only as far as the resume guard needs: into commands, +// each a list of words with their quotes removed. One pass, so a long terminal text stays linear. + +const BLANKS = /[^\S\r\n]+/y +// Unquoted text, or a redirection: `>&`, `<&` and `&>` do not end a command. +const WORD_PIECE = /[^\s&|;"'<>]+|[<>]&|&>|[<>]/y +const SEPARATOR = /&&|\|\||[&|;]|\r\n|\r|\n/y +const LINE_BREAKS = new Set(['\r\n', '\r', '\n']) +// POSIX `\`, PowerShell `` ` `` and cmd `^` before a line break continue the command. +const CONTINUATION_MARKS = new Set(['\\', '`', '^']) + +type ShellToken = { word: string; continuesLine: boolean } | { separator: string } + +/** Each command of `text`, as its words. Outside quotes, `&&`, `||`, `;`, `|`, `&` and a line + * break end a command, even glued to a word (`cd /x&&claude`). */ +export function* shellCommands(text: string): Generator { + let words: string[] = [] + let continuesLine = false + for (const token of shellTokens(text)) { + if ('word' in token) { + words.push(token.word) + continuesLine = token.continuesLine + continue + } + if (continuesLine && LINE_BREAKS.has(token.separator)) { + const continued = words.pop()!.slice(0, -1) + if (continued) { + words.push(continued) + } + } else if (words.length > 0) { + yield words + words = [] + } + continuesLine = false + } + if (words.length > 0) { + yield words + } +} + +function* shellTokens(text: string): Generator { + // Once a quote of a kind has no closing quote, no later one of that kind has either. + const unclosedFrom = { '"': text.length, "'": text.length } + let index = 0 + while (index < text.length) { + BLANKS.lastIndex = index + if (BLANKS.test(text)) { + index = BLANKS.lastIndex + continue + } + // A word joins unquoted text and quoted runs: `--session="a b"` is one word. + const pieces: string[] = [] + let continuesLine = false + while (index < text.length) { + WORD_PIECE.lastIndex = index + const quote = text[index] + if (WORD_PIECE.test(text)) { + pieces.push(text.slice(index, WORD_PIECE.lastIndex)) + index = WORD_PIECE.lastIndex + continuesLine = CONTINUATION_MARKS.has(text[index - 1]!) + } else if (quote === '"' || quote === "'") { + const close = index < unclosedFrom[quote] ? closingQuote(text, index) : -1 + if (close === -1) { + unclosedFrom[quote] = index + } + pieces.push(close === -1 ? quote : text.slice(index + 1, close)) + continuesLine = false + index = close === -1 ? index + 1 : close + 1 + } else { + break + } + } + if (pieces.length > 0) { + yield { word: pieces.length === 1 ? pieces[0]! : pieces.join(''), continuesLine } + continue + } + SEPARATOR.lastIndex = index + const separator = SEPARATOR.exec(text)![0] + yield { separator } + index += separator.length + } +} + +/** The index of the quote closing the one at `open`; a double quote escaped by `\` does not. */ +function closingQuote(text: string, open: number): number { + const quote = text[open]! + for (let at = text.indexOf(quote, open + 1); at !== -1; at = text.indexOf(quote, at + 1)) { + let backslashes = 0 + while (quote === '"' && text[at - 1 - backslashes] === '\\') { + backslashes += 1 + } + if (backslashes % 2 === 0) { + return at + } + } + return -1 +} + +/** The name a word runs as: its path's last name, lower-case. */ +export function commandName(word: string): string { + return word.slice(Math.max(word.lastIndexOf('/'), word.lastIndexOf('\\')) + 1).toLowerCase() +} + +const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/ +const LAUNCHERS = new Set([ + 'env', + 'exec', + 'command', + 'sudo', + 'nohup', + 'time', + 'npx', + 'bunx', + 'wsl', + 'wsl.exe' +]) + +/** The index of the program a command runs: its first word past `VAR=x` prefixes and launchers + * (`env`, `exec`, `command`, `sudo`, `nohup`, `time`, `npx`, `bunx`, `pnpm dlx`, `wsl --`) with their + * options; -1 if none. */ +export function commandProgramIndex(words: readonly string[]): number { + let launched = false + for (let index = 0; index < words.length; index += 1) { + const word = words[index]! + if (ASSIGNMENT.test(word) || (launched && word.startsWith('-'))) { + continue + } + const name = commandName(word) + if (name === 'pnpm' && words[index + 1] === 'dlx') { + index += 1 + } else if (!LAUNCHERS.has(name)) { + return index + } + launched = true + } + return -1 +} diff --git a/src/main/ai-vault/structured-session-ownership-acp-and-pi.test.ts b/src/main/ai-vault/structured-session-ownership-acp-and-pi.test.ts new file mode 100644 index 00000000000..5538d237bd4 --- /dev/null +++ b/src/main/ai-vault/structured-session-ownership-acp-and-pi.test.ts @@ -0,0 +1,358 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionProviderHandle } from '../../shared/agent-session-provider-handle' +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { + assertLegacyAiVaultResumeAllowed, + assertLegacyAiVaultResumeCommandAllowed, + OPENS_EVERY_STRUCTURED_CHAT, + projectStructuredAiVaultSessions +} from './structured-session-ownership' +import { installOwnership, listResult } from './structured-session-ownership.test-support' +import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from '../runtime/structured-agent-runtime-registrations' + +const OPENCODE_ID = 'ses_2f9aB3cD4eF5gH6iJ7kL8mN9oP' +const GROK_ID = '019fd600-1a2b-7c3d-8e4f-5a6b7c8d9e0f' +const OMP_ID = '019fd611-2b3c-7d4e-9f50-6b7c8d9e0f1a' +const PI_ID = '019fd622-3c4d-7e5f-a061-7c8d9e0f1a2b' +const OTHER_UUID = '019fd633-4d5e-7f60-b172-8d9e0f1a2b3c' +const OMP_FILE = `/Users/me/.omp/agent/sessions/--Users-me-repo--/2026-10-09T10-00-00-000Z_${OMP_ID}.jsonl` +const PI_FILE = `/Users/me/.pi/agent/sessions/--Users-me-repo--/2026-10-09T10-00-00-000Z_${PI_ID}.jsonl` + +const acp = (agent: 'opencode' | 'grok' | 'omp', nativeId: string): AgentSessionProviderHandle => ({ + transport: 'acp', + agent, + nativeId +}) +const PI_HANDLE: AgentSessionProviderHandle = { + transport: 'jsonl-rpc', + agent: 'pi', + nativeId: PI_FILE +} + +const allowed = (command: string) => + expect( + assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined) + ).resolves.toBeUndefined() +const refused = (command: string) => + expect(assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined)).rejects.toThrow( + 'agent_session_conflict' + ) + +function ownedRow(agent: AiVaultAgent, sessionId: string, filePath: string) { + const result = listResult() + result.sessions = [ + { + ...result.sessions[0]!, + agent, + sessionId, + filePath, + title: 'First prompt' + } + ] + return projectStructuredAiVaultSessions(result, OPENS_EVERY_STRUCTURED_CHAT).sessions[0] +} + +afterEach(() => setStructuredAgentSessionHost(null)) + +describe('Session History rows owned by OpenCode, Grok, OMP and Pi chats', () => { + it.each([ + { + handle: acp('opencode', OPENCODE_ID), + rowAgent: 'opencode' as const, + sessionId: OPENCODE_ID, + filePath: `/Users/me/.local/share/opencode/opencode.db#${OPENCODE_ID}` + }, + { + handle: acp('opencode', OPENCODE_ID), + rowAgent: 'opencode2' as const, + sessionId: OPENCODE_ID, + filePath: `/Users/me/.local/share/opencode/opencode.db#${OPENCODE_ID}` + }, + { + handle: acp('grok', GROK_ID), + rowAgent: 'grok' as const, + sessionId: GROK_ID, + filePath: `/Users/me/.grok/sessions/%2FUsers%2Fme%2Frepo/${GROK_ID}/summary.json` + }, + { + handle: acp('omp', OMP_ID), + rowAgent: 'omp' as const, + sessionId: OMP_ID, + filePath: OMP_FILE + }, + { + handle: PI_HANDLE, + rowAgent: 'pi' as const, + sessionId: PI_ID, + filePath: PI_FILE + } + ])( + 'owns the $rowAgent row of a $handle.agent chat under its CLI title and refuses preparing its resume', + ({ handle, rowAgent, sessionId, filePath }) => { + installOwnership({ handle, conversationName: 'Quokka harbor chat' }) + expect(ownedRow(rowAgent, sessionId, filePath)).toMatchObject({ + title: 'First prompt', + structuredSession: { + sessionId: 'session-alpha', + workspaceId: 'workspace-1' + } + }) + expect( + ownedRow(rowAgent, OTHER_UUID, filePath.replace(sessionId, OTHER_UUID)) + ).not.toHaveProperty('structuredSession') + expect(() => + assertLegacyAiVaultResumeAllowed({ + agent: rowAgent, + sessionId, + filePath, + codexHome: null, + executionHostId: 'local' + }) + ).toThrow('agent_session_conflict') + } + ) + + it('does not own a row of one agent for a chat of another with the same id', () => { + installOwnership({ handle: acp('omp', OMP_ID) }) + expect(ownedRow('pi', OMP_ID, OMP_FILE)).not.toHaveProperty('structuredSession') + }) + + // A row agent's owner is the first registration claiming it, so a second claim would be ignored. + it('gives each history row agent one owning registration', () => { + const rowAgents = STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.flatMap( + ({ sessionHistory }) => sessionHistory.rowAgents + ) + expect(new Set(rowAgents).size).toBe(rowAgents.length) + }) +}) + +describe('terminal resumes of a conversation an OpenCode chat owns', () => { + it.each([ + `opencode --session ${OPENCODE_ID}`, + `opencode -s ${OPENCODE_ID}`, + `opencode --session=${OPENCODE_ID}`, + `opencode2 --standalone --session '${OPENCODE_ID}'`, + `cd '/Users/me/repo' && opencode run -s ${OPENCODE_ID} "keep going"`, + `opencode -c`, + `opencode --continue`, + `opencode --session`, + // Ids compare in any case. + `opencode --session ${OPENCODE_ID.toLowerCase()}`, + // After `--` the flag is prompt text, so the session is resumed, not forked. + `opencode -s ${OPENCODE_ID} -- --fork` + ])('refuses %s', async (command) => { + installOwnership({ handle: acp('opencode', OPENCODE_ID) }) + await refused(command) + }) + + it.each([ + `opencode --session ses_otherSession123`, + `opencode --session ${OPENCODE_ID} --fork`, + `opencode -c --fork`, + `opencode`, + `opencode export ${OPENCODE_ID}`, + // The `-c` belongs to bash, not to OpenCode. + `cd /Users/me/src/opencode && bash -c 'make'` + ])('allows %s', async (command) => { + installOwnership({ handle: acp('opencode', OPENCODE_ID) }) + await allowed(command) + }) +}) + +describe('terminal resumes of a conversation a Grok chat owns', () => { + it.each([ + `grok --resume ${GROK_ID}`, + `grok -r ${GROK_ID.toUpperCase()}`, + `grok --resume=${GROK_ID}`, + `grok --load=${GROK_ID}`, + `grok -p "continue" --resume "${GROK_ID}"`, + `grok --resume`, + `grok -r --minimal`, + `grok -c`, + `grok --continue`, + // A non-UUID value is a title, which may name the owned session. + `grok --resume "auth refactor"`, + `grok --resume ${GROK_ID} -- --fork-session` + ])('refuses %s', async (command) => { + installOwnership({ handle: acp('grok', GROK_ID) }) + await refused(command) + }) + + it.each([ + `grok --resume ${OTHER_UUID}`, + `grok --resume ${GROK_ID} --fork-session`, + `grok -c --fork-session`, + `grok --session-id ${OTHER_UUID}`, + `grok -- "fix the bug"` + ])('allows %s', async (command) => { + installOwnership({ handle: acp('grok', GROK_ID) }) + await allowed(command) + }) +}) + +describe('terminal resumes of a conversation an OMP chat owns', () => { + it.each([ + `omp --resume ${OMP_ID}`, + `omp --resume '${OMP_FILE}'`, + `omp -r ${OMP_ID.slice(0, 8)}`, + `omp --session=${OMP_ID.slice(0, 13)}`, + `omp --session ./2026-10-09T10-00-00-000Z_${OMP_ID}.jsonl`, + `omp --resume`, + `omp -c`, + `omp --continue "what next"` + ])('refuses %s', async (command) => { + installOwnership({ handle: acp('omp', OMP_ID) }) + await refused(command) + }) + + it.each([ + `omp --resume ${OTHER_UUID}`, + `omp --resume ${OTHER_UUID.slice(0, 8)}`, + `omp --resume /Users/me/.omp/agent/sessions/--Users-me-repo--/2026-10-09T10-00-00-000Z_${OTHER_UUID}.jsonl`, + `omp --fork ${OMP_ID}`, + `omp "hello"` + ])('allows %s', async (command) => { + installOwnership({ handle: acp('omp', OMP_ID) }) + await allowed(command) + }) +}) + +describe('terminal resumes of a conversation a Pi chat owns', () => { + it.each([ + `pi --session ${PI_ID}`, + `pi --session '${PI_FILE}'`, + `pi --session=${PI_FILE}`, + `pi --session ${PI_ID.slice(0, 8)}`, + `pi --session-id ${PI_ID}`, + `pi --session-id=${PI_ID}`, + `pi -c`, + `pi --continue`, + // `--resume` opens a picker; the next token is a prompt, not a target. + `pi --resume ${OTHER_UUID}`, + `pi -r` + ])('refuses %s', async (command) => { + installOwnership({ handle: PI_HANDLE }) + await refused(command) + }) + + it.each([ + `pi --session ${OTHER_UUID}`, + `pi --session-id ${OTHER_UUID}`, + `pi --session /Users/me/.pi/agent/sessions/--Users-me-repo--/2026-10-09T10-00-00-000Z_${OTHER_UUID}.jsonl`, + `pi --fork '${PI_FILE}'`, + `pi "hello"`, + `cd /Users/me/pi && bash -c 'make'` + ])('allows %s', async (command) => { + installOwnership({ handle: PI_HANDLE }) + await allowed(command) + }) +}) + +// Their CLIs read flags case-sensitively, unlike Claude's and Codex's folded flags. +describe('flags in another case', () => { + it.each([ + { handle: PI_HANDLE, command: `rg foo src/main/pi -C 3` }, + { handle: PI_HANDLE, command: `pi --SESSION ${PI_ID}` }, + { handle: acp('opencode', OPENCODE_ID), command: `opencode -C` }, + { handle: acp('grok', GROK_ID), command: `grok -R ${GROK_ID}` }, + { handle: acp('omp', OMP_ID), command: `omp --Continue` } + ])("are not the agent's resume flags: $command", async ({ handle, command }) => { + installOwnership({ handle }) + await allowed(command) + }) + + it('still fold for Claude', async () => { + installOwnership({ provider: 'claude', providerSessionId: PI_ID }) + await refused(`claude -C`) + }) +}) + +describe('the agent named again inside its own command', () => { + it.each([ + { + handle: acp('opencode', OPENCODE_ID), + command: `opencode -s ${OPENCODE_ID} ~/src/opencode --fork` + }, + { + handle: acp('grok', GROK_ID), + command: `grok -r ${GROK_ID} --cwd ~/src/grok --fork-session` + }, + { handle: PI_HANDLE, command: `pi --session pi` } + ])('is an argument: $command', async ({ handle, command }) => { + installOwnership({ handle }) + await allowed(command) + }) + + it.each(['\r', '\r\n'])('ends at a %j line break, as a PTY Enter sends', async (lineBreak) => { + installOwnership({ handle: PI_HANDLE }) + await refused(`pi --session ${OTHER_UUID}${lineBreak}pi --session ${PI_ID}${lineBreak}`) + await allowed(`pi --session ${OTHER_UUID}${lineBreak}echo -c${lineBreak}`) + installOwnership({ handle: acp('opencode', OPENCODE_ID) }) + await refused(`opencode -s ses_other${lineBreak}opencode -s ${OPENCODE_ID}${lineBreak}`) + await allowed(`opencode -s ses_other${lineBreak}echo -c${lineBreak}`) + }) + + it('ends at a newline, which starts another command', async () => { + installOwnership({ handle: PI_HANDLE }) + await allowed(`pi --session ${OTHER_UUID}\necho -c`) + await refused(`echo hi\npi --session ${PI_ID}`) + }) +}) + +describe('another agent named in a command', () => { + it.each([ + `echo pi && claude --resume ${PI_ID}`, + `echo grok && claude --resume ${PI_ID}`, + `cd /Users/me/omp && codex resume ${PI_ID}` + ])('reads a Claude or Codex resume as theirs, not a Pi one: %s', async (command) => { + installOwnership({ handle: PI_HANDLE }) + await allowed(command) + }) + + it('still reads a Pi resume that follows a Claude one', async () => { + installOwnership({ handle: PI_HANDLE }) + await refused(`claude --resume ${OTHER_UUID}; pi --session ${PI_ID}`) + }) +}) + +// The guard runs on every terminal.send text, so a long text naming agents often must stay linear. +describe('parsing cost', () => { + afterEach(() => vi.restoreAllMocks()) + + it('reads each token once per agent however often agents are named', async () => { + installOwnership({ handle: PI_HANDLE }) + const parsedArgs = STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ sessionHistory }) => + vi.spyOn(sessionHistory, 'parseResumeArgs') + ) + const line = 'the claude agent said codex and pi and grok are fine; omp wrote opencode output\n' + const text = line.repeat(2_000) + const tokenCount = text.split(/\s+/).filter(Boolean).length + + await allowed(text) + const argsRead = parsedArgs + .flatMap((spy) => spy.mock.calls) + .reduce((sum, [args]) => sum + args.length, 0) + expect(argsRead).toBeLessThanOrEqual(tokenCount * parsedArgs.length) + }) + + it('reads one long command once per agent', async () => { + installOwnership({ handle: PI_HANDLE }) + const parsedArgs = STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ sessionHistory }) => + vi.spyOn(sessionHistory, 'parseResumeArgs') + ) + const text = `claude ${'pi codex grok ~/src/claude '.repeat(20_000)}` + const tokenCount = text.split(/\s+/).filter(Boolean).length + + await allowed(text) + const argsRead = parsedArgs + .flatMap((spy) => spy.mock.calls) + .reduce((sum, [args]) => sum + args.length, 0) + expect(argsRead).toBeLessThanOrEqual(tokenCount * parsedArgs.length) + }) + + it('still refuses an owned resume after many mentions', async () => { + installOwnership({ handle: PI_HANDLE }) + await refused(`${'echo pi; '.repeat(5_000)}pi --session ${PI_ID}`) + }) +}) diff --git a/src/main/ai-vault/structured-session-ownership-client-audience.test.ts b/src/main/ai-vault/structured-session-ownership-client-audience.test.ts new file mode 100644 index 00000000000..830bc8b2520 --- /dev/null +++ b/src/main/ai-vault/structured-session-ownership-client-audience.test.ts @@ -0,0 +1,238 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentSessionProviderHandle } from '../../shared/agent-session-provider-handle' +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import { + buildAiVaultResumeCommand, + buildAiVaultResumeShellCommand +} from '../../shared/ai-vault-resume-command' +import { buildAgentResumeStartupPlan } from '../../shared/tui-agent-resume-startup' +import { agentForksConversations } from '../../shared/agent-resume-argv' +import { isResumableTuiAgent } from '../../shared/agent-session-resume' +import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../shared/agent-session-provider-handle-encoding' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' +import { + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../shared/protocol-version' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { clientOpensStructuredChatFromHistory } from '../runtime/rpc/methods/structured-agent-session-policy' +import { + assertLegacyAiVaultResumeCommandAllowed, + projectStructuredAiVaultSessions +} from './structured-session-ownership' +import { installOwnership, listResult } from './structured-session-ownership.test-support' + +const UUID = '019fd622-3c4d-7e5f-a061-7c8d9e0f1a2b' +const OPENCODE_ID = 'ses_2f9aB3cD4eF5gH6iJ7kL8mN9oP' +const SESSION_FILE = (root: string) => + `/Users/me/${root}/agent/sessions/--Users-me-repo--/2026-10-09T10-00-00-000Z_${UUID}.jsonl` + +type OwnedConversation = { + rowAgent: AiVaultAgent + sessionId: string + filePath: string + handle: AgentSessionProviderHandle +} + +const CONVERSATIONS: OwnedConversation[] = [ + { + rowAgent: 'opencode', + sessionId: OPENCODE_ID, + filePath: `/Users/me/.local/share/opencode/opencode.db#${OPENCODE_ID}`, + handle: { transport: 'acp', agent: 'opencode', nativeId: OPENCODE_ID } + }, + { + rowAgent: 'opencode2', + sessionId: OPENCODE_ID, + filePath: `/Users/me/.local/share/opencode/opencode.db#${OPENCODE_ID}`, + handle: { transport: 'acp', agent: 'opencode', nativeId: OPENCODE_ID } + }, + { + rowAgent: 'grok', + sessionId: UUID, + filePath: `/Users/me/.grok/sessions/%2FUsers%2Fme%2Frepo/${UUID}/summary.json`, + handle: { transport: 'acp', agent: 'grok', nativeId: UUID } + }, + { + rowAgent: 'omp', + sessionId: UUID, + filePath: SESSION_FILE('.omp'), + handle: { transport: 'acp', agent: 'omp', nativeId: UUID } + }, + { + rowAgent: 'pi', + sessionId: UUID, + filePath: SESSION_FILE('.pi'), + handle: { + transport: 'jsonl-rpc', + agent: 'pi', + nativeId: SESSION_FILE('.pi') + } + } +] + +const OLD_MOBILE: RuntimeCapability[] = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +] +const NO_STRUCTURED_SESSIONS: RuntimeCapability[] = [] +const CLIENTS = { + 'a client that renders only Claude and Codex chats (current mobile)': OLD_MOBILE, + 'a desktop paired to this host': [...ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES], + 'a client that reads no structured sessions': NO_STRUCTURED_SESSIONS +} + +function projectFor(conversation: OwnedConversation, clientCapabilities: RuntimeCapability[]) { + const result = listResult() + result.sessions = [ + { ...result.sessions[0]!, agent: conversation.rowAgent, ...conversation }, + { ...result.sessions[0]!, sessionId: 'unowned' } + ] + return projectStructuredAiVaultSessions(result, (agent) => + clientOpensStructuredChatFromHistory({ clientKind: 'mobile', clientCapabilities }, agent) + ).sessions.map((row) => row.sessionId) +} + +afterEach(() => setStructuredAgentSessionHost(null)) + +describe('Session History rows a paired client cannot open as a chat', () => { + it.each(CONVERSATIONS)( + 'shows the $rowAgent row only to a client that renders its chat', + (conversation) => { + installOwnership({ handle: conversation.handle }) + const [oldMobile, desktop, legacy] = Object.values(CLIENTS) + expect(projectFor(conversation, oldMobile!)).toEqual(['unowned']) + expect(projectFor(conversation, desktop!)).toEqual([conversation.sessionId, 'unowned']) + expect(projectFor(conversation, legacy!)).toEqual(['unowned']) + } + ) + + it.each(Object.entries(CLIENTS))('keeps the Claude and Codex rule for %s', (_client, caps) => { + for (const provider of ['claude', 'codex'] as const) { + installOwnership({ provider }) + const result = listResult() + result.sessions = result.sessions.map((row) => ({ + ...row, + agent: provider + })) + const owned = projectStructuredAiVaultSessions(result, (agent) => + clientOpensStructuredChatFromHistory( + { clientKind: 'mobile', clientCapabilities: caps }, + agent + ) + ) + // As before: every client that reads structured sessions gets the owned row. + expect(owned.sessions.map((row) => row.structuredSession?.sessionId)).toEqual( + caps.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ? ['session-alpha'] : [] + ) + } + }) +}) + +// Orca's own resume builders and the guard's parser must agree, or a builder change lets a resume +// of a chat's conversation through. Mobile composes these same shared builders. +describe('resume commands Orca builds for an owned row', () => { + const shells: { platform: NodeJS.Platform; shell: AgentStartupShell }[] = [ + { platform: 'darwin', shell: 'posix' }, + { platform: 'win32', shell: 'powershell' }, + { platform: 'win32', shell: 'cmd' } + ] + // A user's command override is the launch command's program, verbatim. + const overrides = (binary: string): Record => ({ + posix: [undefined, `FOO=1 /opt/bin/${binary}`, `env -i FOO=1 /opt/bin/${binary}`], + powershell: [undefined, `& 'C:\\Program Files\\${binary}\\${binary}.exe'`], + cmd: [undefined, `"C:\\Program Files\\${binary}\\${binary}.exe"`] + }) + + function builtCommands(conversation: OwnedConversation, fork = false): string[] { + const agent = conversation.rowAgent + if (!isResumableTuiAgent(agent)) { + return [] + } + return shells.flatMap(({ platform, shell }) => { + const vault = fork + ? null + : buildAiVaultResumeCommand({ + agent, + sessionId: conversation.sessionId, + cwd: '/Users/me/repo', + platform, + shell, + resumeFilePath: conversation.filePath + }) + const tabs = overrides(agent)[shell].map((override) => { + const plan = buildAgentResumeStartupPlan({ + agent, + providerSession: { + key: 'session_id', + id: conversation.sessionId, + transcriptPath: conversation.filePath + }, + cmdOverrides: override ? { [agent]: override } : {}, + platform, + shell, + ompResumeFilePath: conversation.filePath, + fork + }) + return plan + ? buildAiVaultResumeShellCommand({ + resumeCommand: plan.launchCommand, + cwd: '/Users/me/repo', + platform, + shell + }) + : null + }) + return [vault, ...tabs].filter((command): command is string => command !== null) + }) + } + + const everyAgent: OwnedConversation[] = [ + ...CONVERSATIONS, + { + rowAgent: 'claude', + sessionId: UUID, + filePath: `/Users/me/.claude/projects/-Users-me-repo/${UUID}.jsonl`, + handle: claudeProviderHandle(UUID, null) + }, + { + rowAgent: 'codex', + sessionId: UUID, + filePath: `/Users/me/.codex/sessions/2026/10/09/rollout-2026-10-09T10-00-00-${UUID}.jsonl`, + handle: codexProviderHandle(UUID) + } + ] + + it.each(everyAgent)('refuses each one for the $rowAgent row', async (conversation) => { + installOwnership({ handle: conversation.handle }) + const commands = builtCommands(conversation) + expect(commands.length).toBe(10) + for (const command of commands) { + await expect( + assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined), + command + ).rejects.toThrow('agent_session_conflict') + } + }) + + // "Resume in New CLI" opens a copy, so it is no second writer of the chat's conversation. + it.each(everyAgent.filter(({ rowAgent }) => agentForksConversations(rowAgent)))( + 'allows each copy it opens for the $rowAgent row', + async (conversation) => { + installOwnership({ handle: conversation.handle }) + const commands = builtCommands(conversation, true) + expect(commands.length).toBe(7) + for (const command of commands) { + await expect( + assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined), + command + ).resolves.toBeUndefined() + } + } + ) +}) diff --git a/src/main/ai-vault/structured-session-ownership-shell-commands.test.ts b/src/main/ai-vault/structured-session-ownership-shell-commands.test.ts new file mode 100644 index 00000000000..abf37928946 --- /dev/null +++ b/src/main/ai-vault/structured-session-ownership-shell-commands.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentSessionProviderHandle } from '../../shared/agent-session-provider-handle' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { assertLegacyAiVaultResumeCommandAllowed } from './structured-session-ownership' +import { installOwnership, PROVIDER_SESSION } from './structured-session-ownership.test-support' + +const OPENCODE_ID = 'ses_2f9aB3cD4eF5gH6iJ7kL8mN9oP' +const UUID = '019fd622-3c4d-7e5f-a061-7c8d9e0f1a2b' +const OTHER_UUID = '019fd633-4d5e-7f60-b172-8d9e0f1a2b3c' +const PI_FILE = `/Users/me/.pi/agent/sessions/--Users-me-repo--/2026-10-09T10-00-00-000Z_${UUID}.jsonl` + +type Agent = { + name: string + own: () => void + /** A resume of the owned conversation, without its program. */ + resumeArgs: string + /** `--flag=` forms, each completed by a quoted target. */ + inlineFlags: string[] + otherId: string +} + +const acp = (agent: 'opencode' | 'grok' | 'omp', nativeId: string): AgentSessionProviderHandle => ({ + transport: 'acp', + agent, + nativeId +}) + +const AGENTS: Agent[] = [ + { + name: 'claude', + own: () => installOwnership({ provider: 'claude' }), + resumeArgs: `-r ${PROVIDER_SESSION}`, + inlineFlags: ['--resume=', '-r='], + otherId: OTHER_UUID + }, + { + name: 'codex', + own: () => installOwnership(), + resumeArgs: `resume ${PROVIDER_SESSION}`, + inlineFlags: [], + otherId: OTHER_UUID + }, + { + name: 'opencode', + own: () => installOwnership({ handle: acp('opencode', OPENCODE_ID) }), + resumeArgs: `-s ${OPENCODE_ID}`, + inlineFlags: ['--session=', '-s='], + otherId: 'ses_other' + }, + { + name: 'grok', + own: () => installOwnership({ handle: acp('grok', UUID) }), + resumeArgs: `-r ${UUID}`, + inlineFlags: ['--resume=', '-r=', '--load='], + otherId: OTHER_UUID + }, + { + name: 'omp', + own: () => installOwnership({ handle: acp('omp', UUID) }), + resumeArgs: `--resume ${UUID}`, + inlineFlags: ['--resume=', '-r=', '--session='], + otherId: OTHER_UUID + }, + { + name: 'pi', + own: () => + installOwnership({ handle: { transport: 'jsonl-rpc', agent: 'pi', nativeId: PI_FILE } }), + resumeArgs: `--session ${UUID}`, + inlineFlags: ['--session='], + otherId: OTHER_UUID + } +] + +const allowed = (command: string) => + expect( + assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined), + command + ).resolves.toBeUndefined() +const refused = (command: string) => + expect( + assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined), + command + ).rejects.toThrow('agent_session_conflict') + +function ownedId(agent: Agent): string { + return agent.resumeArgs.split(' ').at(-1)! +} + +afterEach(() => setStructuredAgentSessionHost(null)) + +describe.each(AGENTS)('a $name resume read as a shell reads it', (agent) => { + const resume = `${agent.name} ${agent.resumeArgs}` + + it('ends at a separator glued to a word', async () => { + agent.own() + for (const command of [ + `${resume}; echo done`, + `${resume};`, + `${resume}|tee x`, + `${resume}&`, + `${resume}&&echo done`, + `cd /x&&${resume}`, + `cd /x||${resume}`, + `cd /x;${resume}`, + `cd /x|${resume}`, + `echo hi 2>&1;${resume} 2>&1` + ]) { + await refused(command) + } + }) + + it('keeps a quoted separator inside its word', async () => { + agent.own() + await refused(`${agent.name} "a;b" 'c|d' ${agent.resumeArgs}`) + await allowed(`echo "x;${resume}" 'y&&${resume}'`) + }) + + it('removes the quotes around a value after `=`', async () => { + agent.own() + for (const flag of agent.inlineFlags) { + await refused(`${agent.name} ${flag}"${ownedId(agent)}"`) + await refused(`${agent.name} ${flag}'${ownedId(agent)}'`) + await allowed(`${agent.name} ${flag}"${agent.otherId}"`) + } + }) +}) + +// terminal.send checks agent prompts too, and its text may still reach a shell, so a name counts +// only where a shell would run it. +describe.each(AGENTS)('a $name resume in command position', (agent) => { + const resume = `${agent.name} ${agent.resumeArgs}` + + it('is refused behind assignments, a path or a launcher', async () => { + agent.own() + for (const command of [ + `FOO=1 BAR='a b' ${resume}`, + `/usr/local/bin/${resume}`, + `${agent.name[0]!.toUpperCase()}${agent.name.slice(1)} ${agent.resumeArgs}`, + `env -i FOO=1 ${resume}`, + `exec ${resume}`, + `command ${resume}`, + `sudo -E ${resume}`, + `npx ${resume}`, + `bunx ${resume}`, + `pnpm dlx ${resume}`, + `& 'C:\\Program Files\\${agent.name}\\${agent.name}.exe' ${agent.resumeArgs}`, + `cd /d "C:/x" && ${resume}`, + `echo hi\n${resume}` + ]) { + await refused(command) + } + }) + + it('is allowed as another program’s argument or prompt text', async () => { + agent.own() + for (const command of [ + `Fix the ${agent.name} ${agent.resumeArgs} handling`, + `echo ${resume}`, + `git commit -m wip ${resume}`, + `rg -n ${resume}` + ]) { + await allowed(command) + } + }) +}) + +it('allows a prompt that names a Pi flag while a Pi chat owns a conversation', async () => { + AGENTS.find(({ name }) => name === 'pi')!.own() + await allowed('Fix the Pi --resume flag') + await allowed('The pi -c path is broken.\nPlease fix pi --continue too') +}) diff --git a/src/main/ai-vault/structured-session-ownership.test-support.ts b/src/main/ai-vault/structured-session-ownership.test-support.ts new file mode 100644 index 00000000000..f8c0c2ec6f7 --- /dev/null +++ b/src/main/ai-vault/structured-session-ownership.test-support.ts @@ -0,0 +1,86 @@ +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import type { AgentSessionProviderHandle } from '../../shared/agent-session-provider-handle' +import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' +import type { StructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../shared/agent-session-provider-handle-encoding' + +export const PROVIDER_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + +/** Installs a host whose one record owns a conversation: by default a Codex chat on + * PROVIDER_SESSION, or the agent `handle` names. */ +export function installOwnership( + overrides: Partial & { + handle?: AgentSessionProviderHandle + } = {} +): void { + const { handle, ...ownershipOverrides } = overrides + const ownership: StructuredProviderSessionOwnership = { + sessionId: 'session-alpha', + workspaceId: 'workspace-1', + provider: handle?.agent ?? 'codex', + providerSessionId: PROVIDER_SESSION, + lease: agentSessionLeaseFixture(), + ...ownershipOverrides + } + const record = agentSessionRecordFixture(ownership.lease) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ownership read touches only `deps.store.listRecords`; the rest of the host is never reached. + setStructuredAgentSessionHost({ + deps: { + store: { + listRecords: () => [ + { + ...record, + sessionId: ownership.sessionId, + location: { ...record.location, workspaceId: ownership.workspaceId }, + provider: ownership.provider, + providerHandleChain: [ + { + ...record.providerHandleChain[0]!, + handle: + handle ?? + (ownership.provider === 'claude' + ? claudeProviderHandle(ownership.providerSessionId, null) + : codexProviderHandle(ownership.providerSessionId)) + } + ], + lease: { ...ownership.lease, sessionId: ownership.sessionId }, + ...(ownership.conversationName ? { conversationName: ownership.conversationName } : {}) + } + ] + } + } + } as never) +} + +export function listResult(): AiVaultListResult { + const session: AiVaultSession = { + id: `local:codex:${PROVIDER_SESSION}`, + executionHostId: 'local', + agent: 'codex', + sessionId: PROVIDER_SESSION, + title: 'Owned', + cwd: '/repo', + branch: null, + model: null, + filePath: `/sessions/rollout-${PROVIDER_SESSION}.jsonl`, + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-08-11T00:00:00.000Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: `codex resume '${PROVIDER_SESSION}'`, + subagent: null + } + return { sessions: [session], issues: [], scannedAt: '2026-08-11T00:00:00.000Z' } +} diff --git a/src/main/ai-vault/structured-session-ownership.test.ts b/src/main/ai-vault/structured-session-ownership.test.ts index 3307032a340..3cda409fbad 100644 --- a/src/main/ai-vault/structured-session-ownership.test.ts +++ b/src/main/ai-vault/structured-session-ownership.test.ts @@ -1,24 +1,21 @@ import { afterEach, describe, expect, it } from 'vitest' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../shared/agent-session-record.test-fixture' -import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' -import type { StructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership' +import { agentSessionLeaseFixture } from '../../shared/agent-session-record.test-fixture' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { assertLegacyAiVaultResumeAllowed, assertLegacyAiVaultResumeCommandAllowed, + OPENS_EVERY_STRUCTURED_CHAT, projectStructuredAiVaultSearchResponse, projectStructuredAiVaultSessions } from './structured-session-ownership' -import type { AiVaultSearchHit } from '../../shared/ai-vault-search-types' +import type { AiVaultSearchHit, AiVaultSearchResponse } from '../../shared/ai-vault-search-types' import { - claudeProviderHandle, - codexProviderHandle -} from '../../shared/agent-session-provider-handle-encoding' + installOwnership, + listResult, + PROVIDER_SESSION +} from './structured-session-ownership.test-support' -const PROVIDER_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' +const OTHER_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' describe('structured AI Vault ownership', () => { afterEach(() => setStructuredAgentSessionHost(null)) @@ -27,8 +24,10 @@ describe('structured AI Vault ownership', () => { installOwnership() const result = listResult() - expect(projectStructuredAiVaultSessions(result, false).sessions).toEqual([]) - expect(projectStructuredAiVaultSessions(result, true).sessions[0]).toMatchObject({ + expect(projectStructuredAiVaultSessions(result, () => false).sessions).toEqual([]) + expect( + projectStructuredAiVaultSessions(result, OPENS_EVERY_STRUCTURED_CHAT).sessions[0] + ).toMatchObject({ structuredSession: { sessionId: 'session-alpha', workspaceId: 'workspace-1' } }) }) @@ -39,38 +38,24 @@ describe('structured AI Vault ownership', () => { const unowned = { ...result.sessions[0]!, sessionId: 'different-session', title: 'Original' } const projected = projectStructuredAiVaultSessions( { ...result, sessions: [...result.sessions, unowned] }, - true + OPENS_EVERY_STRUCTURED_CHAT ) expect(projected.sessions[0]?.title).toBe('auth/login') expect(projected.sessions[1]).toBe(unowned) }) + // An owned hit opens its chat, so it carries no terminal resume command for any agent. it('names and owns an indexed search hit as its list row is, leaving other hits alone', () => { installOwnership({ conversationName: 'auth/login' }) - const hit: AiVaultSearchHit = { - agent: 'codex', - sessionId: PROVIDER_SESSION, - title: 'First prompt', - cwd: '/repo', - branch: null, - updatedAt: null, - messageCount: 1, - score: 1, - source: { presence: 'present' }, - evidence: null - } - const other = { ...hit, sessionId: 'different-session', title: 'Original' } - const response = projectStructuredAiVaultSearchResponse({ - kind: 'results', - hits: [hit, other], - page: { cursor: null, hasMore: false }, - generation: 1, - truncated: { candidates: false, snippets: 0, query: false, freshness: false }, - durationMs: 1 - }) + const { resumeCommand: _resumeCommand, ...plain } = searchHit() + const other = { ...searchHit(), sessionId: 'different-session', title: 'Original' } + const response = projectStructuredAiVaultSearchResponse( + searchResults([searchHit(), other]), + OPENS_EVERY_STRUCTURED_CHAT + ) expect(response.kind === 'results' && response.hits).toEqual([ { - ...hit, + ...plain, title: 'auth/login', structuredSession: { sessionId: 'session-alpha', workspaceId: 'workspace-1' } }, @@ -78,6 +63,16 @@ describe('structured AI Vault ownership', () => { ]) }) + it('drops an owned hit for a client that cannot open its chat', () => { + installOwnership() + const other = { ...searchHit(), sessionId: 'different-session' } + const response = projectStructuredAiVaultSearchResponse( + searchResults([searchHit(), other]), + () => false + ) + expect(response.kind === 'results' && response.hits).toEqual([other]) + }) + it.each(['claude', 'codex'] as const)( 'keeps an unnamed %s chat at its ordinary label', (provider) => { @@ -88,9 +83,9 @@ describe('structured AI Vault ownership', () => { agent: provider, title: 'First prompt' })) - expect(projectStructuredAiVaultSessions(result, true).sessions[0]?.title).toBe( - provider === 'claude' ? 'Claude Chat' : 'Codex Chat' - ) + expect( + projectStructuredAiVaultSessions(result, OPENS_EVERY_STRUCTURED_CHAT).sessions[0]?.title + ).toBe(provider === 'claude' ? 'Claude Chat' : 'Codex Chat') } ) @@ -106,8 +101,10 @@ describe('structured AI Vault ownership', () => { title: 'Remote name' } const merged = { ...local, sessions: [...local.sessions, remote] } - expect(projectStructuredAiVaultSessions(merged, true).sessions[1]).toBe(remote) - expect(projectStructuredAiVaultSessions(merged, false).sessions).toEqual([remote]) + expect( + projectStructuredAiVaultSessions(merged, OPENS_EVERY_STRUCTURED_CHAT).sessions[1] + ).toBe(remote) + expect(projectStructuredAiVaultSessions(merged, () => false).sessions).toEqual([remote]) } ) @@ -211,66 +208,167 @@ describe('structured AI Vault ownership', () => { }) }) -function installOwnership(overrides: Partial = {}): void { - const ownership: StructuredProviderSessionOwnership = { - sessionId: 'session-alpha', - workspaceId: 'workspace-1', - provider: 'codex', - providerSessionId: PROVIDER_SESSION, - lease: agentSessionLeaseFixture(), - ...overrides - } - const record = agentSessionRecordFixture(ownership.lease) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ownership read touches only `deps.store.listRecords`; the rest of the host is never reached. - setStructuredAgentSessionHost({ - deps: { - store: { - listRecords: () => [ - { - ...record, - sessionId: ownership.sessionId, - location: { ...record.location, workspaceId: ownership.workspaceId }, - provider: ownership.provider, - providerHandleChain: [ - { - ...record.providerHandleChain[0]!, - handle: - ownership.provider === 'claude' - ? claudeProviderHandle(ownership.providerSessionId, null) - : codexProviderHandle(ownership.providerSessionId) - } - ], - lease: { ...ownership.lease, sessionId: ownership.sessionId }, - ...(ownership.conversationName ? { conversationName: ownership.conversationName } : {}) - } - ] - } - } - } as never) -} +describe('Session History ownership read from the agent registration', () => { + afterEach(() => setStructuredAgentSessionHost(null)) -function listResult(): AiVaultListResult { - const session: AiVaultSession = { - id: `local:codex:${PROVIDER_SESSION}`, - executionHostId: 'local', + it('leaves a row and resume of an agent no chat lists under alone', async () => { + installOwnership({ provider: 'claude' }) + const result = listResult() + result.sessions = result.sessions.map((session) => ({ ...session, agent: 'gemini' })) + + expect(projectStructuredAiVaultSessions(result, () => false)).toBe(result) + expect(() => + assertLegacyAiVaultResumeAllowed({ + agent: 'gemini', + sessionId: PROVIDER_SESSION, + filePath: `/sessions/${PROVIDER_SESSION}.json`, + codexHome: null, + executionHostId: 'local' + }) + ).not.toThrow() + await expect( + assertLegacyAiVaultResumeCommandAllowed( + `gemini --resume ${PROVIDER_SESSION}`, + async () => undefined + ) + ).resolves.toBeUndefined() + }) + + // Each agent reads its own binary's tokens: a folder or argument named after another agent no + // longer hides a Claude or Codex resume (on main the first claude/codex token decided). + it.each([ + { + provider: 'claude' as const, + command: `cd /Users/me/src/codex && claude --resume ${PROVIDER_SESSION}` + }, + { + provider: 'claude' as const, + command: `codex resume ${OTHER_SESSION} && claude -r ${PROVIDER_SESSION}` + }, + { + provider: 'codex' as const, + command: `cd /Users/me/claude && codex resume ${PROVIDER_SESSION}` + }, + { provider: 'claude' as const, command: `claude --model pi --resume ${PROVIDER_SESSION}` }, + { provider: 'claude' as const, command: `echo pi && claude --resume ${PROVIDER_SESSION}` }, + { provider: 'claude' as const, command: `cd /Users/me/grok && claude -c` }, + { provider: 'codex' as const, command: `cd /Users/me/omp && codex resume --last` } + ])( + 'refuses an owned resume after another agent is named: $command', + async ({ provider, command }) => { + installOwnership({ provider }) + + await expect( + assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined) + ).rejects.toThrow('agent_session_conflict') + } + ) + + // A later `claude` path inside the command is an argument, so `--session-id` still cancels the fork. + it.each([ + `claude --session-id ${PROVIDER_SESSION} --add-dir ~/src/claude --fork-session --resume ${PROVIDER_SESSION}`, + `claude --fork-session --resume=${PROVIDER_SESSION} --add-dir ~/x/claude --session-id ${PROVIDER_SESSION}`, + `claude --resume ${PROVIDER_SESSION} && claude --fork-session -p hi` + ])('refuses a writer whose own command names claude again: %s', async (command) => { + installOwnership({ provider: 'claude' }) + + await expect( + assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined) + ).rejects.toThrow('agent_session_conflict') + }) + + it.each( + ['\\', '`', '^'].flatMap((mark) => + ['\n', '\r', '\r\n'].map((lineBreak) => ({ mark, lineBreak })) + ) + )( + 'reads a command continued with $mark and a $lineBreak line break as one', + async ({ mark, lineBreak }) => { + installOwnership({ provider: 'claude' }) + + await expect( + assertLegacyAiVaultResumeCommandAllowed( + `claude ${mark}${lineBreak} --resume ${PROVIDER_SESSION}`, + async () => undefined + ) + ).rejects.toThrow('agent_session_conflict') + } + ) + + it.each(['\r', '\r\n'])('reads each %j line as its own Claude command', async (lineBreak) => { + installOwnership({ provider: 'claude' }) + + await expect( + assertLegacyAiVaultResumeCommandAllowed( + `claude --resume ${OTHER_SESSION}${lineBreak}claude --resume ${PROVIDER_SESSION}${lineBreak}`, + async () => undefined + ) + ).rejects.toThrow('agent_session_conflict') + await expect( + assertLegacyAiVaultResumeCommandAllowed( + `claude --resume ${OTHER_SESSION}${lineBreak}echo -c${lineBreak}`, + async () => undefined + ) + ).resolves.toBeUndefined() + }) + + it('allows a fork whose own command names claude again', async () => { + installOwnership({ provider: 'claude' }) + + await expect( + assertLegacyAiVaultResumeCommandAllowed( + `claude --resume ${PROVIDER_SESSION} --add-dir ~/src/claude --fork-session`, + async () => undefined + ) + ).resolves.toBeUndefined() + }) + + it.each([ + `claude --model pi --resume ${OTHER_SESSION}`, + `cd /Users/me/opencode && claude --resume ${OTHER_SESSION}` + ])('allows a different Claude target with another agent named: %s', async (command) => { + installOwnership({ provider: 'claude' }) + + await expect( + assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined) + ).resolves.toBeUndefined() + }) + + it.each([ + `/usr/local/bin/claude --resume ${PROVIDER_SESSION}`, + `C:\\bin\\CLAUDE.EXE -r ${PROVIDER_SESSION}` + ])("recognises the registered binary by its path's last name: %s", async (command) => { + installOwnership({ provider: 'claude' }) + + await expect( + assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined) + ).rejects.toThrow('agent_session_conflict') + }) +}) + +function searchHit(): AiVaultSearchHit { + return { agent: 'codex', sessionId: PROVIDER_SESSION, - title: 'Owned', + title: 'First prompt', cwd: '/repo', branch: null, - model: null, - filePath: `/sessions/rollout-${PROVIDER_SESSION}.jsonl`, - codexHome: null, - createdAt: null, updatedAt: null, - modifiedAt: '2026-08-11T00:00:00.000Z', messageCount: 1, - totalTokens: 0, - previewMessages: [], - queuedMessageCount: 0, - subagentTranscriptCount: 0, - resumeCommand: `codex resume '${PROVIDER_SESSION}'`, - subagent: null + score: 1, + source: { presence: 'present' }, + evidence: null, + resumeCommand: `codex resume '${PROVIDER_SESSION}'` + } +} + +function searchResults(hits: AiVaultSearchHit[]): AiVaultSearchResponse { + return { + kind: 'results', + hits, + page: { cursor: null, hasMore: false }, + generation: 1, + truncated: { candidates: false, snippets: 0, query: false, freshness: false }, + durationMs: 1 } - return { sessions: [session], issues: [], scannedAt: '2026-08-11T00:00:00.000Z' } } diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts index 7f4d81a4b9e..2499f9c50b2 100644 --- a/src/main/ai-vault/structured-session-ownership.ts +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -2,6 +2,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease-adjudication' import { defaultAgentChatLabel } from '../../shared/agent-session-chat-label' import type { AiVaultListResult } from '../../shared/ai-vault-types' +import { historyRowShowsChatName } from '../../shared/structured-chat-row-name' import type { AiVaultSearchResponse } from '../../shared/ai-vault-search-types' import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' @@ -10,10 +11,27 @@ import { listStructuredProviderSessionOwnership, type StructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' +import { + sessionHistoryBinaryNames, + type StructuredAgentResumeInvocation, + type StructuredAgentSessionHistory +} from '../native-chat/structured-agent-cli-conversations' +import { + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + type StructuredAgentRuntimeRegistration +} from '../runtime/structured-agent-runtime-registrations' +import { commandName, commandProgramIndex, shellCommands } from './shell-command-words' + +/** Whether the client a projection answers opens a chat of this agent from its history row. A row + * owned by a chat it cannot open is hidden from it: resuming that row in a terminal is refused. */ +export type StructuredChatOpener = (agent: StructuredAgentId) => boolean + +export const OPENS_EVERY_STRUCTURED_CHAT: StructuredChatOpener = () => true export function projectStructuredAiVaultSessions( result: AiVaultListResult, - structuredSupported: boolean + opensChat: StructuredChatOpener ): AiVaultListResult { const owner = ownershipLookup() if (!owner) { @@ -24,10 +42,10 @@ export function projectStructuredAiVaultSessions( if (!ownership) { return [session] } - if (!structuredSupported) { + if (!opensChat(ownership.provider)) { return [] } - return [{ ...session, ...ownedTitle(ownership) }] + return [{ ...session, ...ownedRow(session.agent, ownership) }] }) return sessions.length === result.sessions.length && sessions.every((row, index) => row === result.sessions[index]) @@ -35,9 +53,11 @@ export function projectStructuredAiVaultSessions( : { ...result, sessions } } -/** Indexed hits name and own a native chat exactly as list rows do; only this host's index can. */ +/** Indexed hits name and own a native chat exactly as list rows do; only this host's index can. + * An owned hit opens its chat, so it carries no terminal resume command. */ export function projectStructuredAiVaultSearchResponse( - response: AiVaultSearchResponse + response: AiVaultSearchResponse, + opensChat: StructuredChatOpener ): AiVaultSearchResponse { const owner = response.kind === 'results' ? ownershipLookup() : null if (!owner || response.kind !== 'results') { @@ -45,9 +65,16 @@ export function projectStructuredAiVaultSearchResponse( } return { ...response, - hits: response.hits.map((hit) => { + hits: response.hits.flatMap((hit) => { const ownership = owner(hit) - return ownership ? { ...hit, ...ownedTitle(ownership) } : hit + if (!ownership) { + return [hit] + } + if (!opensChat(ownership.provider)) { + return [] + } + const { resumeCommand: _resumeCommand, ...owned } = hit + return [{ ...owned, ...ownedRow(hit.agent, ownership) }] }) } } @@ -56,8 +83,12 @@ export function projectStructuredAiVaultSearchResponse( * naming them must never cost the user the search. */ export async function searchWithStructuredOwners( search: Promise, - ensureHost?: () => Promise + options: { + ensureHost?: (() => Promise) | undefined + opensChat: StructuredChatOpener + } ): Promise { + const { ensureHost } = options const hostReady = ensureHost ? ensureStructuredAgentSessionHostUnlessRefused(ensureHost).then( () => true, @@ -68,14 +99,22 @@ export async function searchWithStructuredOwners( ) : true const response = await search - return (await hostReady) ? projectStructuredAiVaultSearchResponse(response) : response + return (await hostReady) + ? projectStructuredAiVaultSearchResponse(response, options.opensChat) + : response } -function ownedTitle(ownership: StructuredProviderSessionOwnership) { - return { - title: ownership.conversationName ?? defaultAgentChatLabel(ownership.provider), - structuredSession: { sessionId: ownership.sessionId, workspaceId: ownership.workspaceId } +function ownedRow(rowAgent: string, ownership: StructuredProviderSessionOwnership) { + const structuredSession = { + sessionId: ownership.sessionId, + workspaceId: ownership.workspaceId } + return historyRowShowsChatName(rowAgent) + ? { + title: ownership.conversationName ?? defaultAgentChatLabel(ownership.provider), + structuredSession + } + : { structuredSession } } /** One index per projection: a list holds hundreds of rows, each once searched every record. */ @@ -92,10 +131,19 @@ function ownershipLookup(): byProviderSession.set(key, ownership) } } - return (row) => - row.agent === 'codex' || row.agent === 'claude' - ? (byProviderSession.get(`${row.agent}\0${row.sessionId}`) ?? null) - : null + return (row) => { + const owner = historyRowOwner(row.agent) + return owner ? (byProviderSession.get(`${owner}\0${row.sessionId}`) ?? null) : null + } +} + +/** The agent whose chats own history rows listed under `rowAgent`; null when none does. */ +function historyRowOwner(rowAgent: string): StructuredAgentId | null { + return ( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.find(({ sessionHistory }) => + sessionHistory.rowAgents.some((agent) => agent === rowAgent) + )?.definition.agent ?? null + ) } export function assertLegacyAiVaultResumeAllowed(args: AiVaultPrepareSessionResumeArgs): void { @@ -110,7 +158,8 @@ export async function assertLegacyAiVaultResumeCommandAllowed( command: string, ensureHost: () => Promise ): Promise { - if (!isPotentialStructuredResumeCommand(command)) { + const invocations = parseResumeInvocations(command) + if (invocations.length === 0) { return } // A terminal command is not a chat: with chats refused here there is no ownership to check. @@ -120,41 +169,37 @@ export async function assertLegacyAiVaultResumeCommandAllowed( return } for (const ownership of listOwnership()) { - if (isResumeCommandFor(command, ownership)) { + if (invocations.some((invocation) => resumesOwnedSession(invocation, ownership))) { refuseLegacyWriter(ownership) } } } -function isPotentialStructuredResumeCommand(command: string): boolean { - return parseResumeInvocation(command) !== null -} - function findResumeOwnership( args: AiVaultPrepareSessionResumeArgs ): StructuredProviderSessionOwnership | null { - if (args.agent !== 'codex' && args.agent !== 'claude') { + const owner = historyRowOwner(args.agent) + if (!owner) { return null } const host = getStructuredAgentSessionHost() if (!host) { return null } - const exact = args.sessionId ? findOwnership(args.agent, args.sessionId) : null + const exact = args.sessionId ? findOwnership(owner, args.sessionId) : null if (exact) { return exact } const fileName = args.filePath.split(/[\\/]/).at(-1) ?? '' return ( listOwnership().find( - (ownership) => - ownership.provider === args.agent && fileName.includes(ownership.providerSessionId) + (ownership) => ownership.provider === owner && fileName.includes(ownership.providerSessionId) ) ?? null ) } function findOwnership( - provider: 'claude' | 'codex', + provider: StructuredAgentId, providerSessionId: string ): StructuredProviderSessionOwnership | null { return ( @@ -170,82 +215,59 @@ function listOwnership(): StructuredProviderSessionOwnership[] { return host ? listStructuredProviderSessionOwnership(host.deps.store.listRecords()) : [] } -function isResumeCommandFor( - command: string, +function resumesOwnedSession( + invocation: ResumeInvocation, ownership: StructuredProviderSessionOwnership ): boolean { - const invocation = parseResumeInvocation(command) - if (!invocation || invocation.provider !== ownership.provider) { + if (invocation.provider !== ownership.provider) { return false } // A target-less resume (--last, --continue, or a bare --resume/-r) may pick // any provider session, so it cannot be admitted while one is structured. - // Only an explicit target that differs from this owned session is safe. - return invocation.target === null || invocation.target === ownership.providerSessionId + // Only an explicit target that names a different session is safe. + if (invocation.target === null) { + return true + } + const matches = invocation.history.resumeTargetMatches ?? sameIdInAnyCase + return matches(invocation.target, ownership.providerSessionId) } -type ResumeInvocation = { - provider: 'codex' | 'claude' - target: string | null +function sameIdInAnyCase(target: string, rowSessionId: string): boolean { + return target.toLowerCase() === rowSessionId.toLowerCase() } -function parseResumeInvocation(command: string): ResumeInvocation | null { - // Keep this deliberately conservative: shell quoting is normalized only - // enough to identify executable/flag tokens; an unrecognized shape is not - // treated as proof that a different session is being resumed. - const tokens = command.match(/"[^"\\]*(?:\\.[^"\\]*)*"|'[^']*'|[^\s]+/g) ?? [] - const normalized = tokens.map((token) => token.replace(/^['"]|['"]$/g, '')) - const executableIndex = normalized.findIndex((token) => - /(?:^|[\\/])(?:codex|claude)(?:\.exe)?$/i.test(token) - ) - if (executableIndex === -1) { - return null - } - const provider = /codex(?:\.exe)?$/i.test(normalized[executableIndex]!) ? 'codex' : 'claude' - const args = normalized.slice(executableIndex + 1) - // `codex fork` already falls through below: it carries no `resume` marker. - if (provider === 'claude' && isClaudeForkInvocation(args)) { - return null - } - // `--continue`/`-c` resume the most recent session and never take an id, so a - // following token is a prompt, not a target — they are always target-less. - const targetlessFlags = provider === 'codex' ? [] : ['--continue', '-c'] - const targetlessIndex = args.findIndex((token) => targetlessFlags.includes(token.toLowerCase())) - if (targetlessIndex !== -1) { - return { provider, target: null } - } - const resumeFlags = provider === 'codex' ? ['resume'] : ['--resume', '-r'] - const inlineIndex = args.findIndex( - (token) => - provider === 'claude' && - (token.toLowerCase().startsWith('--resume=') || token.toLowerCase().startsWith('-r=')) - ) - if (inlineIndex !== -1) { - const target = args[inlineIndex]!.slice(args[inlineIndex]!.indexOf('=') + 1) - return { provider, target: target.length > 0 ? target : null } - } - const markerIndex = args.findIndex((token) => resumeFlags.includes(token.toLowerCase())) - if (markerIndex === -1) { - return null - } - const candidate = args[markerIndex + 1] - return { - provider, - target: candidate && !candidate.startsWith('-') ? candidate : null - } +type ResumeInvocation = StructuredAgentResumeInvocation & { + provider: StructuredAgentId + history: StructuredAgentSessionHistory } -/** A fork reads the conversation and writes a new one, so it is no second writer. Not when - * `--session-id` names the id it writes under, and nothing after `--` is an option. */ -function isClaudeForkInvocation(args: readonly string[]): boolean { - const terminatorIndex = args.indexOf('--') - const options = (terminatorIndex === -1 ? args : args.slice(0, terminatorIndex)).map((token) => - token.toLowerCase() - ) - return ( - options.includes('--fork-session') && - !options.some((token) => token === '--session-id' || token.startsWith('--session-id=')) - ) +function parseResumeInvocations(command: string): ResumeInvocation[] { + const binaries = new Map() + for (const registration of STRUCTURED_AGENT_RUNTIME_REGISTRATIONS) { + for (const name of sessionHistoryBinaryNames(registration.sessionHistory)) { + binaries.set(name, registration) + } + } + // Only a command's program can resume: a name elsewhere is an argument or prompt text + // ("Fix the Pi --resume flag", `claude --model pi -r x`). + const invocations: ResumeInvocation[] = [] + for (const words of shellCommands(command)) { + const program = commandProgramIndex(words) + const registration = binaries.get(commandName(words[program] ?? '')) + if (!registration) { + continue + } + const history = registration.sessionHistory + const invocation = history.parseResumeArgs(words.slice(program + 1)) + if (invocation) { + invocations.push({ + ...invocation, + provider: registration.definition.agent, + history + }) + } + } + return invocations } function refuseLegacyWriter(ownership: StructuredProviderSessionOwnership): never { diff --git a/src/main/claude/claude-cli-conversations.ts b/src/main/claude/claude-cli-conversations.ts new file mode 100644 index 00000000000..db47cedaeca --- /dev/null +++ b/src/main/claude/claude-cli-conversations.ts @@ -0,0 +1,57 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' +import { resolveSessionFilePath } from '../native-chat/session-file-resolver' +import { + resumeInvocationFromOptions, + type StructuredAgentResumeInvocation, + type StructuredAgentCliConversations, + type StructuredAgentSessionHistory, + type StructuredAgentTranscriptImport +} from '../native-chat/structured-agent-cli-conversations' + +const CLAUDE_TRANSCRIPT_IMPORT: StructuredAgentTranscriptImport = { + accountHomeCandidates: ({ selectedAccountHomePath }) => [ + selectedAccountHomePath, + join(homedir(), '.claude'), + ...(getClaudeProfileRouter()?.accountHomes() ?? []) + ], + findTranscript: ({ providerSessionId, accountHomePath }) => + resolveSessionFilePath('claude', providerSessionId, { + claudeProjectsDir: join(accountHomePath, 'projects') + }) +} + +const CLAUDE_SESSION_HISTORY: StructuredAgentSessionHistory = { + rowAgents: ['claude'], + rowSessionId: (link) => link.handle.nativeId, + parseResumeArgs: parseClaudeResumeArgs +} + +export const CLAUDE_CLI_CONVERSATIONS: StructuredAgentCliConversations = { + transcriptImport: CLAUDE_TRANSCRIPT_IMPORT, + sessionHistory: CLAUDE_SESSION_HISTORY +} + +function parseClaudeResumeArgs(args: readonly string[]): StructuredAgentResumeInvocation | null { + // Flags match in any case (`claude -C`); a lowered id still matches, as ids compare in any case. + const folded = args.map((token) => token.toLowerCase()) + // `--continue`/`-c` never take an id, so a following token is a prompt, not a target. + return isClaudeForkInvocation(folded) + ? null + : resumeInvocationFromOptions(folded, { + targetless: ['--continue', '-c'], + markers: ['--resume', '-r'] + }) +} + +/** A fork reads the conversation and writes a new one, so it is no second writer. Not when + * `--session-id` names the id it writes under, and nothing after `--` is an option. */ +function isClaudeForkInvocation(args: readonly string[]): boolean { + const terminatorIndex = args.indexOf('--') + const options = terminatorIndex === -1 ? args : args.slice(0, terminatorIndex) + return ( + options.includes('--fork-session') && + !options.some((token) => token === '--session-id' || token.startsWith('--session-id=')) + ) +} diff --git a/src/main/codex/codex-cli-conversations.ts b/src/main/codex/codex-cli-conversations.ts new file mode 100644 index 00000000000..cb24cf8c492 --- /dev/null +++ b/src/main/codex/codex-cli-conversations.ts @@ -0,0 +1,36 @@ +import { join } from 'node:path' +import { configuredAdditionalCodexHomePaths } from '../ai-vault/cached-session-list' +import { resolveSessionFilePath } from '../native-chat/session-file-resolver' +import { + resumeInvocationAfterMarker, + type StructuredAgentCliConversations, + type StructuredAgentSessionHistory, + type StructuredAgentTranscriptImport +} from '../native-chat/structured-agent-cli-conversations' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' + +const CODEX_TRANSCRIPT_IMPORT: StructuredAgentTranscriptImport = { + accountHomeCandidates: ({ settings, selectedAccountHomePath }) => [ + selectedAccountHomePath, + ...(settings.codexManagedAccounts ?? []).map((account) => account.managedHomePath), + ...configuredAdditionalCodexHomePaths(), + getOrcaManagedCodexHomePath(), + getSystemCodexHomePath() + ], + findTranscript: ({ providerSessionId, accountHomePath }) => + resolveSessionFilePath('codex', providerSessionId, { + codexSessionsDirs: [join(accountHomePath, 'sessions')] + }) +} + +const CODEX_SESSION_HISTORY: StructuredAgentSessionHistory = { + rowAgents: ['codex'], + rowSessionId: (link) => link.handle.nativeId, + // `codex fork` carries no `resume` marker, and Codex has no target-less resume flag. + parseResumeArgs: (args) => resumeInvocationAfterMarker(args, ['resume']) +} + +export const CODEX_CLI_CONVERSATIONS: StructuredAgentCliConversations = { + transcriptImport: CODEX_TRANSCRIPT_IMPORT, + sessionHistory: CODEX_SESSION_HISTORY +} diff --git a/src/main/grok/grok-session-history.ts b/src/main/grok/grok-session-history.ts new file mode 100644 index 00000000000..52dbecd6203 --- /dev/null +++ b/src/main/grok/grok-session-history.ts @@ -0,0 +1,24 @@ +import { + ownOptionsInclude, + resumeInvocationFromOptions, + type StructuredAgentSessionHistory +} from '../native-chat/structured-agent-cli-conversations' + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +export const GROK_SESSION_HISTORY: StructuredAgentSessionHistory = { + rowAgents: ['grok'], + rowSessionId: (link) => link.handle.nativeId, + parseResumeArgs: (args) => { + // `--fork-session` resumes into a new session id, so it writes no owned one. + if (ownOptionsInclude(args, ['--fork-session'])) { + return null + } + const invocation = resumeInvocationFromOptions(args, { + targetless: ['-c', '--continue'], + markers: ['-r', '--resume', '--load'] + }) + // Grok reads a non-UUID value as a session title, which may name any of the folder's sessions. + return invocation?.target && !UUID.test(invocation.target) ? { target: null } : invocation + } +} diff --git a/src/main/ipc/ai-vault-search.ts b/src/main/ipc/ai-vault-search.ts index 98826cc59b7..baa335e85cd 100644 --- a/src/main/ipc/ai-vault-search.ts +++ b/src/main/ipc/ai-vault-search.ts @@ -30,7 +30,10 @@ import { clearSessionSearchInService } from '../ai-vault/session-scanner-service import { searchAllExecutionHosts, type SessionSearchHostLeg } from './ai-vault-search-all-hosts' import { getActiveRuntimeAiVaultHostInfosResult } from './ai-vault' import { AI_VAULT_ALL_HOST_TIMEOUT_MS } from './ai-vault-all-host-timeouts' -import { searchWithStructuredOwners } from '../ai-vault/structured-session-ownership' +import { + OPENS_EVERY_STRUCTURED_CHAT, + searchWithStructuredOwners +} from '../ai-vault/structured-session-ownership' export type RuntimeSessionSearchCall = ( environmentId: string, @@ -148,10 +151,10 @@ async function searchByExecutionHostScope( } async function searchLocalSessions(request: AiVaultSearchRequest): Promise { - return searchWithStructuredOwners( - searchSessionService(request, 'ipc'), - handlerOptions.ensureStructuredSessionOwnership - ) + return searchWithStructuredOwners(searchSessionService(request, 'ipc'), { + ensureHost: handlerOptions.ensureStructuredSessionOwnership, + opensChat: OPENS_EVERY_STRUCTURED_CHAT + }) } /** diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index f34c1214dab..7de9ee84360 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -54,7 +54,10 @@ import { resolveAiVaultSessionTitlesByHost, type RuntimeAiVaultSessionTitleResolver } from './ai-vault-session-title-routing' -import { projectStructuredAiVaultSessions } from '../ai-vault/structured-session-ownership' +import { + OPENS_EVERY_STRUCTURED_CHAT, + projectStructuredAiVaultSessions +} from '../ai-vault/structured-session-ownership' import { AI_VAULT_ALL_HOST_TIMEOUT_MS } from './ai-vault-all-host-timeouts' type AiVaultHandlerOptions = AiVaultSessionSources & @@ -241,7 +244,7 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo try { await handlerOptions.ensureStructuredSessionOwnership?.() const result = await listAiVaultSessions(args, { signal: controller?.signal }) - return projectStructuredAiVaultSessions(result, true) + return projectStructuredAiVaultSessions(result, OPENS_EVERY_STRUCTURED_CHAT) } catch (error) { // Why: superseding a scan is normal control flow, but Electron logs every // rejected handler — report it as a result so the log stays truthful. diff --git a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts index f759674d692..f9a89b05b95 100644 --- a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts @@ -1,5 +1,6 @@ import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' +import { structuredAgentRuntimeRegistration } from '../../runtime/structured-agent-runtime-registrations' export type StructuredProviderSessionOwnership = { sessionId: string @@ -10,17 +11,22 @@ export type StructuredProviderSessionOwnership = { lease: AgentSessionLease } +/** Which chat owns each conversation, under the Session History row id its agent's registration + * names: the one index both the duplicate-adoption check and Session History ownership read. */ export function listStructuredProviderSessionOwnership( records: readonly AgentSessionRecord[] ): StructuredProviderSessionOwnership[] { - return records.flatMap((record) => - record.providerHandleChain.map((link) => ({ - sessionId: record.sessionId, - workspaceId: record.location.workspaceId, - provider: record.provider, - providerSessionId: link.handle.nativeId, - conversationName: record.conversationName, - lease: record.lease - })) - ) + return records.flatMap((record) => { + const history = structuredAgentRuntimeRegistration(record.provider)?.sessionHistory + return history + ? record.providerHandleChain.map((link) => ({ + sessionId: record.sessionId, + workspaceId: record.location.workspaceId, + provider: record.provider, + providerSessionId: history.rowSessionId(link), + conversationName: record.conversationName, + lease: record.lease + })) + : [] + }) } diff --git a/src/main/native-chat/structured-agent-cli-conversations.ts b/src/main/native-chat/structured-agent-cli-conversations.ts new file mode 100644 index 00000000000..3c4dd8404c3 --- /dev/null +++ b/src/main/native-chat/structured-agent-cli-conversations.ts @@ -0,0 +1,121 @@ +// How a structured agent's chats meet the conversations its own CLI records: adopting one into a new +// chat, and owning the Session History rows a chat's conversation shows as. + +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { sessionIdFromFileName } from '../ai-vault/session-scanner-accumulator' + +/** What the runtime hands an importer: the settings that name extra account homes. */ +export type StructuredAgentTranscriptImportSettings = { + codexManagedAccounts?: readonly { managedHomePath: string }[] +} + +/** Finds a conversation the agent's CLI recorded, so a new chat can adopt it. */ +export type StructuredAgentTranscriptImport = { + /** Recognised account homes, most-preferred first; the first that holds the transcript wins. */ + accountHomeCandidates: (input: { + settings: StructuredAgentTranscriptImportSettings + selectedAccountHomePath: string + }) => string[] + findTranscript: (input: { + providerSessionId: string + accountHomePath: string + }) => Promise +} + +/** A terminal resume's target: null when it may pick any conversation (`--continue`, a bare flag). */ +export type StructuredAgentResumeInvocation = { target: string | null } + +/** How a chat owns its conversation's Session History rows, and refuses a terminal second writer. */ +export type StructuredAgentSessionHistory = { + /** The Session History row agents a chat's conversation lists under (one CLI may list as more); + * their CLIs' binaries are what a terminal resume of it runs. */ + rowAgents: readonly AiVaultAgent[] + /** The history row id a link of the chat's handle chain shows as. */ + rowSessionId: (link: AgentSessionProviderHandleLink) => string + /** The resume the arguments after the binary ask for; null when they resume nothing. */ + parseResumeArgs: (args: readonly string[]) => StructuredAgentResumeInvocation | null + /** Whether a resume target names the conversation listed under `rowSessionId`; absent, only + * that id does, in any case. */ + resumeTargetMatches?: (target: string, rowSessionId: string) => boolean +} + +/** Required on every registration, as `modelCatalog` is. */ +export type StructuredAgentCliConversations = { + /** How a chat adopts a conversation this agent's CLI recorded; null when it cannot. */ + transcriptImport: StructuredAgentTranscriptImport | null + /** How this agent's chats own their conversations' Session History rows. */ + sessionHistory: StructuredAgentSessionHistory +} + +/** The command names that run the agent's CLI binaries, lower-case, with and without `.exe`: a + * token names one when its path's last name does, in any case. */ +export function sessionHistoryBinaryNames(history: StructuredAgentSessionHistory): string[] { + return history.rowAgents.flatMap((agent) => { + const binary = TUI_AGENT_CONFIG[agent].expectedProcess.toLowerCase() + return [binary, `${binary}.exe`] + }) +} + +/** The resume a marker flag asks for: the token after it, unless that is another option. Flags + * match in any case. */ +export function resumeInvocationAfterMarker( + args: readonly string[], + markers: readonly string[] +): StructuredAgentResumeInvocation | null { + return invocationAfter( + args, + args.findIndex((token) => markers.includes(token.toLowerCase())) + ) +} + +function invocationAfter( + args: readonly string[], + markerIndex: number +): StructuredAgentResumeInvocation | null { + if (markerIndex === -1) { + return null + } + const candidate = args[markerIndex + 1] + return { target: candidate && !candidate.startsWith('-') ? candidate : null } +} + +/** Whether the command's options include one of `flags`, matched exactly as a case-sensitive + * CLI reads them; after `--` a flag is prompt text. */ +export function ownOptionsInclude(args: readonly string[], flags: readonly string[]): boolean { + const terminator = args.indexOf('--') + return (terminator === -1 ? args : args.slice(0, terminator)).some((token) => + flags.includes(token) + ) +} + +/** The resume a command's options ask for, matched exactly as a case-sensitive CLI reads them + * (`rg x src/pi -C 3` is no `pi -c`): a target-less flag may pick any conversation, a marker + * resumes its value (`--marker=value` too). */ +export function resumeInvocationFromOptions( + args: readonly string[], + options: { targetless: readonly string[]; markers: readonly string[] } +): StructuredAgentResumeInvocation | null { + if (args.some((token) => options.targetless.includes(token))) { + return { target: null } + } + const inline = args.find((token) => + options.markers.some((marker) => token.startsWith(`${marker}=`)) + ) + if (inline !== undefined) { + const target = inline.slice(inline.indexOf('=') + 1) + return { target: target.length > 0 ? target : null } + } + return invocationAfter( + args, + args.findIndex((token) => options.markers.includes(token)) + ) +} + +/** For a CLI that resumes a session file by its id, any prefix of it, or the file's path: the + * same file-name id Session History lists the file under. */ +export function sessionFileResumeTargetMatches(target: string, rowSessionId: string): boolean { + const id = rowSessionId.toLowerCase() + return id.startsWith(target.toLowerCase()) || sessionIdFromFileName(target).toLowerCase() === id +} 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 cdfb26842eb..0845d6570ab 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 @@ -193,7 +193,6 @@ describe('resolveStructuredAgentSessionAdoption', () => { await expect( resolveStructuredAgentSessionAdoption({ - agent: 'codex', providerSessionId: 'thread-1', candidateAccountHomes: ['/home/dev/.orca-codex', '/home/dev/.codex', '/never/probed'], resolveTranscript @@ -210,7 +209,6 @@ describe('resolveStructuredAgentSessionAdoption', () => { await expect( resolveStructuredAgentSessionAdoption({ - agent: 'claude', providerSessionId: 'session-1', candidateAccountHomes: ['', ' ', '/home/dev/.claude', ' /home/dev/.claude ', ''], resolveTranscript @@ -225,7 +223,6 @@ describe('resolveStructuredAgentSessionAdoption', () => { // A resume under the wrong home lands in a blank chat wearing the old chat's name. await expect( resolveStructuredAgentSessionAdoption({ - agent: 'claude', providerSessionId: 'session-1', candidateAccountHomes: ['/home/dev/.claude-work', '/home/dev/.claude'], resolveTranscript: async () => null 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 b5e0ac97fe4..429aa69a0f0 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -17,7 +17,7 @@ import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease- export type StructuredAgentSessionAdoptionOwnership = { sessionId: string - /** Any registered agent's chat may hold the conversation, though only Claude and Codex adopt. */ + /** Any agent's chat may hold the conversation, though only one with an importer adopts. */ provider: StructuredAgentId providerSessionId: string lease: AgentSessionLease @@ -36,7 +36,7 @@ export type CommittedStructuredAgentSessionAdoptionReplay = { /** Exact committed-operation identity; attach still validates its fingerprint. */ export function findCommittedStructuredAgentSessionAdoptionReplay(input: { - agent: 'claude' | 'codex' + agent: StructuredAgentId providerSessionId: string selfSessionId: string callerKey: string @@ -82,7 +82,7 @@ export function findCommittedStructuredAgentSessionAdoptionReplay(input: { * ownership index — without this exemption the replay refuses instead of replaying. */ export function findConflictingStructuredAdoption(input: { - agent: 'claude' | 'codex' + agent: StructuredAgentId providerSessionId: string selfSessionId: string ownership: readonly StructuredAgentSessionAdoptionOwnership[] @@ -122,11 +122,9 @@ export function structuredAdoptionConflictError( * (selected account before the system default). */ export async function resolveStructuredAgentSessionAdoption(input: { - agent: 'claude' | 'codex' providerSessionId: string candidateAccountHomes: readonly string[] resolveTranscript: (args: { - agent: 'claude' | 'codex' providerSessionId: string accountHomePath: string }) => Promise @@ -139,7 +137,6 @@ export async function resolveStructuredAgentSessionAdoption(input: { } seen.add(trimmed) const transcriptPath = await input.resolveTranscript({ - agent: input.agent, providerSessionId: input.providerSessionId, accountHomePath: trimmed }) diff --git a/src/main/omp/omp-session-history.ts b/src/main/omp/omp-session-history.ts new file mode 100644 index 00000000000..d2334bf268c --- /dev/null +++ b/src/main/omp/omp-session-history.ts @@ -0,0 +1,18 @@ +import { + resumeInvocationFromOptions, + sessionFileResumeTargetMatches, + type StructuredAgentSessionHistory +} from '../native-chat/structured-agent-cli-conversations' + +/** A chat's ACP session id is its session file's header id, which Session History lists it under. */ +export const OMP_SESSION_HISTORY: StructuredAgentSessionHistory = { + rowAgents: ['omp'], + rowSessionId: (link) => link.handle.nativeId, + // A bare `--resume` opens the picker; `--fork ` writes a new session and resumes none. + parseResumeArgs: (args) => + resumeInvocationFromOptions(args, { + targetless: ['-c', '--continue'], + markers: ['-r', '--resume', '--session'] + }), + resumeTargetMatches: sessionFileResumeTargetMatches +} diff --git a/src/main/opencode/opencode-session-history.ts b/src/main/opencode/opencode-session-history.ts new file mode 100644 index 00000000000..604fe40d56c --- /dev/null +++ b/src/main/opencode/opencode-session-history.ts @@ -0,0 +1,20 @@ +import { + ownOptionsInclude, + resumeInvocationFromOptions, + type StructuredAgentSessionHistory +} from '../native-chat/structured-agent-cli-conversations' + +/** OpenCode 2.x lists a conversation as an `opencode2` row, 1.x as `opencode`, both under the + * `ses_` id the chat's ACP session carries. */ +export const OPENCODE_SESSION_HISTORY: StructuredAgentSessionHistory = { + rowAgents: ['opencode', 'opencode2'], + rowSessionId: (link) => link.handle.nativeId, + parseResumeArgs: (args) => + // `--fork` continues into a new session, so it writes no owned one. + ownOptionsInclude(args, ['--fork']) + ? null + : resumeInvocationFromOptions(args, { + targetless: ['-c', '--continue'], + markers: ['-s', '--session'] + }) +} diff --git a/src/main/pi/pi-session-history.ts b/src/main/pi/pi-session-history.ts new file mode 100644 index 00000000000..0f26107a33e --- /dev/null +++ b/src/main/pi/pi-session-history.ts @@ -0,0 +1,20 @@ +import { sessionIdFromFileName } from '../ai-vault/session-scanner-accumulator' +import { + resumeInvocationFromOptions, + sessionFileResumeTargetMatches, + type StructuredAgentSessionHistory +} from '../native-chat/structured-agent-cli-conversations' + +export const PI_SESSION_HISTORY: StructuredAgentSessionHistory = { + rowAgents: ['pi'], + // A chat's handle is its session file's path; Session History lists the file by its name's id. + rowSessionId: (link) => sessionIdFromFileName(link.handle.nativeId), + // `-r/--resume` opens a picker; `--session-id` reopens that id when it exists; `--fork ` + // writes a new session and resumes none. + parseResumeArgs: (args) => + resumeInvocationFromOptions(args, { + targetless: ['-c', '--continue', '-r', '--resume'], + markers: ['--session', '--session-id'] + }), + resumeTargetMatches: sessionFileResumeTargetMatches +} diff --git a/src/main/pi/rpc-runtime-registration.ts b/src/main/pi/rpc-runtime-registration.ts index a8e97e5e308..a8952bcb417 100644 --- a/src/main/pi/rpc-runtime-registration.ts +++ b/src/main/pi/rpc-runtime-registration.ts @@ -20,6 +20,7 @@ import { } from './rpc-launch-resolution' import { PiRpcSessionAdapter } from './rpc-session-adapter' import { createPiModelCatalogProbe } from './rpc-model-catalog-probe' +import { PI_SESSION_HISTORY } from './pi-session-history' function createPiRpcAdapter(context: StructuredAgentAdapterContext): StructuredAgentRuntimeAdapter { const { deps } = context @@ -62,6 +63,8 @@ export const PI_RPC_RUNTIME_REGISTRATION: StructuredAgentRuntimeRegistration = { : {}) }) }), + transcriptImport: null, + sessionHistory: PI_SESSION_HISTORY, supportsLocation: supportsSupervisedProviderChildLocation, supportsLaunch: async ({ cwd, env, commandSettings }) => { let command: string 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 b81144a2a1a..2c1ac6c87d6 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 @@ -26,10 +26,7 @@ import { isLegacyAgentSessionAccountHome, type AgentSessionAccountHome } from '../../shared/agent-session-account-home' -import { - isAgentSessionHandleProvider, - type StructuredAgentId -} from '../../shared/agent-session-provider-handle' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' import { agentSessionWireProviderHandle } from '../../shared/agent-session-provider-handle-encoding' export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithGetWorktreePs { @@ -208,9 +205,10 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR }) => AgentSessionAccountHome | Promise ): Promise { const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) - // Adopting a conversation reads the agent's own transcript, which only Claude and Codex have - // importers for. - if (!support.supported || (input.resumeFrom && !isAgentSessionHandleProvider(input.agent))) { + // Adopting a conversation reads the agent's own transcript, so only an agent registered with an + // importer adopts. + const transcriptImport = structuredAgentRuntimeRegistration(input.agent)?.transcriptImport + if (!support.supported || (input.resumeFrom && !transcriptImport)) { throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostUnsupported' }) @@ -241,11 +239,12 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR // `accountHome.path`, and Claude reads its transcript under `/projects`. Resuming under // the wrong home finds nothing and lands the user in a blank chat wearing the old chat's name. const adoption = - input.resumeFrom && isLegacyAgentSessionAccountHome(selectedAccountHome) + input.resumeFrom && transcriptImport && isLegacyAgentSessionAccountHome(selectedAccountHome) ? await resolveStructuredAgentSessionAdoptionForCreate({ host, settings, agent: input.agent, + transcriptImport, providerSessionId: input.resumeFrom.providerSessionId, selfSessionId: input.envelope.sessionId, selectedAccountHomePath: selectedAccountHome.path 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 c6e75cd5a23..9ce82c0681b 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 @@ -291,3 +291,32 @@ describe('structured agent-session create intent', () => { }) }) }) + +describe('adopting a conversation recorded outside Orca', () => { + 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. + { getSettings: () => ({ agentDefaultEnv: {} }) } as never + ) + vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({ + supported: true + }) + const resolveStructuredAgentSessionLocation = vi.fn() + Object.assign(runtime, { resolveStructuredAgentSessionLocation }) + + await expect( + runtime.resolveStructuredAgentSessionCreateIntent({ + envelope: { sessionId: 'session-1', clientOperationId: 'operation-1' }, + worktree: 'id:workspace-1', + agent: 'opencode', + resumeFrom: { providerSessionId: 'provider-1' } + }) + ).rejects.toMatchObject({ + refusal: { + code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' } + } + }) + expect(resolveStructuredAgentSessionLocation).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index 3bfa0b90479..cda50f28f67 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -13,13 +13,16 @@ import { restampAiVaultListResult } from '../../../ai-vault/session-list-results import type { AiVaultPrepareSessionResumeArgs } from '../../../../shared/ai-vault-resume-preparation' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { describeAiVaultScanError } from '../../../../shared/ai-vault-scan-error-message' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { assertLegacyAiVaultResumeAllowed, projectStructuredAiVaultSessions, searchWithStructuredOwners } from '../../../ai-vault/structured-session-ownership' import { ensureStructuredAgentSessionHostUnlessRefused } from '../../structured-agent-session-host-refusal' +import { + clientOpensStructuredChatFromHistory, + supportsStructuredAgentSessions +} from './structured-agent-session-policy' import { AiVaultListSessionsParams, AiVaultPrepareSessionResumeParams, @@ -32,12 +35,14 @@ export const AI_VAULT_METHODS = [ name: 'aiVault.searchSessions', permission: 'workspace', params: AiVaultSearchRequestSchema, - handler: (params, { runtime, clientKind, clientCapabilities }) => { - const response = searchSessionService(params, clientKind ? 'relay' : 'runtime') - // A client that cannot open the native owner keeps the transcript hit, as before. - return clientKind === undefined || - clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) - ? searchWithStructuredOwners(response, () => runtime.ensureStructuredAgentSessionHost()) + handler: (params, context) => { + const response = searchSessionService(params, context.clientKind ? 'relay' : 'runtime') + // A client that reads no structured sessions keeps the transcript hit, as before. + return supportsStructuredAgentSessions(context) + ? searchWithStructuredOwners(response, { + ensureHost: () => context.runtime.ensureStructuredAgentSessionHost(), + opensChat: (agent) => clientOpensStructuredChatFromHistory(context, agent) + }) : response } }), @@ -101,10 +106,8 @@ export const AI_VAULT_METHODS = [ } // Why: web clients consume this response directly (no parent-side retag), // so sessions must come back stamped as the runtime host they addressed. - const projected = projectStructuredAiVaultSessions( - result, - clientKind === undefined || - (clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false) + const projected = projectStructuredAiVaultSessions(result, (agent) => + clientOpensStructuredChatFromHistory({ clientKind, clientCapabilities }, agent) ) return conditionalAiVaultListResult( params.executionHostId diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts index bdac8dc6d67..f2cb804a451 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -8,6 +8,7 @@ import { } from '../../../../shared/protocol-version' import { AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY } from '../../../../shared/agent-session-optional-model-capability' import { isNativeChatEnabled } from '../../../../shared/structured-native-chat-launch-route' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcContext } from '../core' @@ -57,6 +58,21 @@ export function clientRendersStructuredAgent( ) } +/** Whether this client opens an agent's chat from its Session History row. Claude and Codex keep + * their shipped rule, any client that reads structured sessions; every other agent needs a client + * that renders its chats. */ +export function clientOpensStructuredChatFromHistory( + context: Pick, + agent: string +): boolean { + return ( + supportsStructuredAgentSessions(context) && + (context.clientKind === undefined || + isAgentSessionHandleProvider(agent) || + clientRendersStructuredAgent(context.clientCapabilities, agent)) + ) +} + /** Preserve the existing session audience for every agent except Pi. */ export function clientReadsStructuredSessionAgent( context: Pick, diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 170a48a18f9..75d406b5974 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -15,6 +15,9 @@ import { import { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' +import { CLAUDE_CLI_CONVERSATIONS } from '../claude/claude-cli-conversations' +import { CODEX_CLI_CONVERSATIONS } from '../codex/codex-cli-conversations' +import type { StructuredAgentCliConversations } from '../native-chat/structured-agent-cli-conversations' import { isAgentSessionPreSpawnError, type StructuredAgentSessionAdapter, @@ -106,7 +109,7 @@ export type StructuredAgentModelCatalogContext = Pick< 'deps' | 'environment' > -export type StructuredAgentRuntimeRegistration = { +export type StructuredAgentRuntimeRegistration = StructuredAgentCliConversations & { definition: StructuredAgentDefinition createAdapter: (context: StructuredAgentAdapterContext) => StructuredAgentRuntimeAdapter /** How this agent's models are listed before any session of it runs. Required: an agent that @@ -224,6 +227,8 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio : {}), resolveAccountHome: ({ launchEnv }) => spec.account.resolve({ launchEnv }), modelCatalog: (context) => acpModelCatalogDiscovery(spec, context), + transcriptImport: null, + sessionHistory: spec.sessionHistory, createAdapter: (context) => { const { deps, followUps } = context const readJournal = (sessionId: string) => @@ -283,6 +288,7 @@ export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRun definition: CODEX_STRUCTURED_AGENT, createAdapter: createCodexAdapter, modelCatalog: codexModelCatalogDiscovery, + ...CODEX_CLI_CONVERSATIONS, supportsLocation: (location) => supportsCodexStructuredLocation(location), resolveAccountHome: async (request, services) => agentSessionAccountHome( @@ -294,6 +300,7 @@ export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRun definition: CLAUDE_STRUCTURED_AGENT, createAdapter: createClaudeAdapter, modelCatalog: claudeModelCatalogDiscovery, + ...CLAUDE_CLI_CONVERSATIONS, supportsLocation: supportsClaudeStructuredLocation, resolveAccountHome: async ({ launchEnv, location }, services) => agentSessionAccountHome( diff --git a/src/main/runtime/structured-agent-session-create-adoption.ts b/src/main/runtime/structured-agent-session-create-adoption.ts index ed636508389..2a3abee4c5e 100644 --- a/src/main/runtime/structured-agent-session-create-adoption.ts +++ b/src/main/runtime/structured-agent-session-create-adoption.ts @@ -1,7 +1,5 @@ -import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' -import { homedir } from 'node:os' -import { join } from 'node:path' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' import { agentSessionExecutionLocationsEqual } from '../../shared/agent-session-record' import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' @@ -12,18 +10,15 @@ import { resolveStructuredAgentSessionAdoption, structuredAdoptionConflictError } from '../native-chat/structured-agent-session-history-adoption' -import { resolveSessionFilePath } from '../native-chat/session-file-resolver' -import { configuredAdditionalCodexHomePaths } from '../ai-vault/cached-session-list' -import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' - -type AdoptionSettings = { - codexManagedAccounts?: readonly { managedHomePath: string }[] -} +import type { + StructuredAgentTranscriptImport, + StructuredAgentTranscriptImportSettings +} from '../native-chat/structured-agent-cli-conversations' export function resolveCommittedStructuredAgentSessionAdoptionIntent(input: { host: StructuredAgentSessionHost | null envelope: { sessionId: string; clientOperationId: string } - agent: 'claude' | 'codex' + agent: StructuredAgentId callerKey?: string resumeFrom?: { providerSessionId: string } location: AgentSessionExecutionLocation @@ -63,8 +58,10 @@ export function resolveCommittedStructuredAgentSessionAdoptionIntent(input: { export async function resolveStructuredAgentSessionAdoptionForCreate(input: { host: StructuredAgentSessionHost | null - settings: AdoptionSettings - agent: 'claude' | 'codex' + settings: StructuredAgentTranscriptImportSettings + agent: StructuredAgentId + /** The agent's registered importer; only an agent with one adopts. */ + transcriptImport: StructuredAgentTranscriptImport providerSessionId: string selfSessionId: string selectedAccountHomePath: string @@ -81,38 +78,8 @@ export async function resolveStructuredAgentSessionAdoptionForCreate(input: { throw structuredAdoptionConflictError(conflict) } return resolveStructuredAgentSessionAdoption({ - agent: input.agent, providerSessionId: input.providerSessionId, - candidateAccountHomes: structuredAdoptionAccountHomeCandidates(input), - resolveTranscript: async ({ agent, providerSessionId, accountHomePath }) => - resolveSessionFilePath( - agent, - providerSessionId, - agent === 'claude' - ? { claudeProjectsDir: join(accountHomePath, 'projects') } - : { codexSessionsDirs: [join(accountHomePath, 'sessions')] } - ) + candidateAccountHomes: input.transcriptImport.accountHomeCandidates(input), + resolveTranscript: input.transcriptImport.findTranscript }) } - -/** Recognised adoption homes, most-preferred first. */ -function structuredAdoptionAccountHomeCandidates(input: { - settings: AdoptionSettings - agent: 'claude' | 'codex' - selectedAccountHomePath: string -}): string[] { - if (input.agent === 'claude') { - return [ - input.selectedAccountHomePath, - join(homedir(), '.claude'), - ...(getClaudeProfileRouter()?.accountHomes() ?? []) - ] - } - return [ - input.selectedAccountHomePath, - ...(input.settings.codexManagedAccounts ?? []).map((account) => account.managedHomePath), - ...configuredAdditionalCodexHomePaths(), - getOrcaManagedCodexHomePath(), - getSystemCodexHomePath() - ] -} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx index a15a8eb74da..f8507970523 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx @@ -51,7 +51,10 @@ function row(host: ExecutionHostId = 'local', title = 'Codex Chat'): AiVaultSess subagentTranscriptCount: 0, resumeCommand: 'codex resume provider-session', subagent: null, - structuredSession: { workspaceId: 'folder-workspace', sessionId: 'native-session' } + structuredSession: { + workspaceId: 'folder-workspace', + sessionId: 'native-session' + } } } @@ -65,9 +68,15 @@ function searchRow(): AiVaultSession { updatedAt: null, messageCount: 1, score: 1, - source: { presence: 'present', filePath: '/sessions/provider-session.jsonl' }, + source: { + presence: 'present', + filePath: '/sessions/provider-session.jsonl' + }, evidence: null, - structuredSession: { workspaceId: 'folder-workspace', sessionId: 'native-session' } + structuredSession: { + workspaceId: 'folder-workspace', + sessionId: 'native-session' + } } return aiVaultSearchHitToSession(hit, 'local') } @@ -166,7 +175,9 @@ afterEach(() => { describe('native chat names in Vault rows', () => { it('shows the name its host publishes with the tab open, and keeps it when the tab closes', async () => { - useAppStore.setState({ unifiedTabsByWorktree: { 'folder-workspace': [tab()] } }) + useAppStore.setState({ + unifiedTabsByWorktree: { 'folder-workspace': [tab()] } + }) await renderRow(row()) expect(screen.getByText('Codex Chat')).toBeTruthy() publish('local', 'Explain the parser') @@ -182,15 +193,41 @@ describe('native chat names in Vault rows', () => { ])( 'lets a manual tab rename win in a %s row, and returns to the name once cleared', async (_kind, make) => { - useAppStore.setState({ unifiedTabsByWorktree: { 'folder-workspace': [tab('Manual name')] } }) + useAppStore.setState({ + unifiedTabsByWorktree: { 'folder-workspace': [tab('Manual name')] } + }) await renderRow(make()) publish('local', 'Explain the parser') expect(screen.getByText('Manual name')).toBeTruthy() - act(() => useAppStore.setState({ unifiedTabsByWorktree: { 'folder-workspace': [tab()] } })) + act(() => + useAppStore.setState({ + unifiedTabsByWorktree: { 'folder-workspace': [tab()] } + }) + ) expect(screen.getByText('Explain the parser')).toBeTruthy() } ) + it.each(['opencode', 'opencode2', 'grok', 'omp', 'pi'] as const)( + 'keeps the CLI title on a %s chat row, as its terminal conversations do', + async (agent) => { + useAppStore.setState({ + unifiedTabsByWorktree: { + 'folder-workspace': [ + { + ...tab('Manual name'), + agentSessionAgent: agent === 'opencode2' ? 'opencode' : agent + } + ] + } + }) + await renderRow({ ...row('local', 'Fix the parser'), agent }) + publish('local', 'Explain the parser') + expect(screen.getByText('Fix the parser')).toBeTruthy() + expect(screen.queryByText('Manual name')).toBeNull() + } + ) + it('shows a search hit the name its host published', async () => { await renderRow(searchRow()) publish('local', 'Explain the parser') @@ -222,7 +259,9 @@ describe('native chat names in Vault rows', () => { it('never shows a name another host published for the same session id', async () => { mocks.supports.mockResolvedValue(true) - const releaseLocal = getStructuredAgentSessionStatusFeed({ kind: 'local' }).activate() + const releaseLocal = getStructuredAgentSessionStatusFeed({ + kind: 'local' + }).activate() await renderRow(row('runtime:paired')) expect(emitters.has('local')).toBe(true) publish('local', 'Local chat name') diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.test.ts index 4b524e4ca26..9977da1a7ac 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.test.ts @@ -5,27 +5,66 @@ import { } from './ai-vault-session-cli-fork' const OWNED = { sessionId: 'chat-1', workspaceId: 'repo-1::worktree-1' } +const ROW = { sessionId: 's1', filePath: '/sessions/s1.jsonl' } const RESUMABLE = { worktreeId: 'repo-1::worktree-2', disabled: false } describe('Resume in New CLI eligibility', () => { it('offers the fork for Claude and Codex rows native chat owns', () => { expect( - aiVaultSessionCliForkWorktreeId({ agent: 'claude', structuredSession: OWNED }, RESUMABLE) + aiVaultSessionCliForkWorktreeId( + { agent: 'claude', ...ROW, structuredSession: OWNED }, + RESUMABLE + ) ).toBe('repo-1::worktree-2') expect( - aiVaultSessionCliForkWorktreeId({ agent: 'codex', structuredSession: OWNED }, RESUMABLE) + aiVaultSessionCliForkWorktreeId( + { agent: 'codex', ...ROW, structuredSession: OWNED }, + RESUMABLE + ) ).toBe('repo-1::worktree-2') }) + it.each(['grok', 'pi'] as const)('offers the fork for a %s chat row', (agent) => { + expect( + aiVaultSessionCliForkWorktreeId({ agent, ...ROW, structuredSession: OWNED }, RESUMABLE) + ).toBe('repo-1::worktree-2') + }) + + it.each(['opencode', 'opencode2', 'omp'] as const)( + 'withholds it from a %s chat row, whose CLI cannot open a copy', + (agent) => { + expect( + aiVaultSessionCliForkWorktreeId({ agent, ...ROW, structuredSession: OWNED }, RESUMABLE) + ).toBeNull() + } + ) + + it('withholds it from a Pi row whose session file is unknown, as on a remote search hit', () => { + expect( + aiVaultSessionCliForkWorktreeId( + { agent: 'pi', ...ROW, filePath: '', structuredSession: OWNED }, + RESUMABLE + ) + ).toBeNull() + }) + it('leaves rows no chat owns to plain Resume', () => { - expect(aiVaultSessionCliForkWorktreeId({ agent: 'claude' }, RESUMABLE)).toBeNull() + expect(aiVaultSessionCliForkWorktreeId({ agent: 'claude', ...ROW }, RESUMABLE)).toBeNull() }) it('withholds the fork when resume is blocked or has no target', () => { - const session = { agent: 'claude' as const, structuredSession: OWNED } - expect(aiVaultSessionCliForkWorktreeId(session, { worktreeId: 'w', disabled: true })).toBeNull() + const session = { agent: 'claude' as const, ...ROW, structuredSession: OWNED } expect( - aiVaultSessionCliForkWorktreeId(session, { worktreeId: null, disabled: false }) + aiVaultSessionCliForkWorktreeId(session, { + worktreeId: 'w', + disabled: true + }) + ).toBeNull() + expect( + aiVaultSessionCliForkWorktreeId(session, { + worktreeId: null, + disabled: false + }) ).toBeNull() }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts index 84f7dde79de..6e9c9f95434 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts @@ -1,20 +1,28 @@ import { translate } from '@/i18n/i18n' import { compactIpcErrorMessage } from '@/lib/ipc-error' import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { getAgentForkArgv, isResumableTuiAgent } from '../../../../shared/agent-session-resume' +import { getAiVaultAgentProviderSession } from '@/lib/ai-vault-resume-command' /** * Where "Resume in New CLI" opens its copy of a conversation native chat owns, or null when the row - * does not offer it. A row no chat owns already resumes in the CLI through plain Resume, and only - * Claude and Codex can fork. + * does not offer it. A row no chat owns already resumes in the CLI through plain Resume, and a row + * offers it only when its CLI's copy command can be built (Pi's needs the session file, which a + * remote search hit doesn't carry). */ export function aiVaultSessionCliForkWorktreeId( - session: Pick, + session: Pick, resume: { worktreeId: string | null | undefined; disabled: boolean } ): string | null { if (!session.structuredSession || resume.disabled || !resume.worktreeId) { return null } - return session.agent === 'claude' || session.agent === 'codex' ? resume.worktreeId : null + const providerSession = getAiVaultAgentProviderSession(session) + return providerSession && + isResumableTuiAgent(session.agent) && + getAgentForkArgv(session.agent, providerSession) + ? resume.worktreeId + : null } /** The toast for a failed "Resume in New CLI". Only a host whose guard predates the fork refuses diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-surface-switch.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-surface-switch.test.ts index 911aba7ed79..0e40337357e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-surface-switch.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-surface-switch.test.ts @@ -11,12 +11,13 @@ const RESUMABLE = { usesSessionWorktree: true } const OWNED = { sessionId: 'chat-1', workspaceId: 'repo-1::wt' } +const ROW = { sessionId: 's1', filePath: '/sessions/s1.jsonl' } describe('resolveAiVaultSessionSurfaceSwitchTargets', () => { it('offers the CLI fork for a chat-owned row with turns and a resume target', () => { expect( resolveAiVaultSessionSurfaceSwitchTargets( - { agent: 'codex', structuredSession: OWNED, ...TURNS }, + { agent: 'codex', ...ROW, structuredSession: OWNED, ...TURNS }, RESUMABLE, null ) @@ -29,6 +30,7 @@ describe('resolveAiVaultSessionSurfaceSwitchTargets', () => { it('withholds the CLI fork for an empty or blocked chat row', () => { const empty = { agent: 'claude' as const, + ...ROW, structuredSession: OWNED, messageCount: 0 } @@ -38,7 +40,7 @@ describe('resolveAiVaultSessionSurfaceSwitchTargets', () => { ).toBeNull() expect( resolveAiVaultSessionSurfaceSwitchTargets( - { agent: 'claude', structuredSession: OWNED, ...TURNS }, + { agent: 'claude', ...ROW, structuredSession: OWNED, ...TURNS }, { ...RESUMABLE, blocked: true }, null ).resumeInNewCliWorktreeId @@ -46,7 +48,7 @@ describe('resolveAiVaultSessionSurfaceSwitchTargets', () => { }) it('offers the chat resume only where its eligibility says so', () => { - const cliRow = { agent: 'claude' as const, ...TURNS } + const cliRow = { agent: 'claude' as const, ...ROW, ...TURNS } expect( resolveAiVaultSessionSurfaceSwitchTargets(cliRow, RESUMABLE, { available: true, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-surface-switch.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-surface-switch.ts index f623bafc5de..6640ab0290d 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-surface-switch.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-surface-switch.ts @@ -16,7 +16,10 @@ export type AiVaultSessionSurfaceSwitchTargets = { /** The one gate for moving a session between native chat and the CLI, shared by the session-history * row and the tab menu so both offer the move in exactly the same cases. */ export function resolveAiVaultSessionSurfaceSwitchTargets( - session: Pick, + session: Pick< + AiVaultSession, + 'agent' | 'sessionId' | 'filePath' | 'messageCount' | 'previewMessages' | 'structuredSession' + >, resumeState: AiVaultSessionResumeState | null, resumeInChat: AiVaultResumeInChatEligibility | null ): AiVaultSessionSurfaceSwitchTargets { diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-session-display-title.ts b/src/renderer/src/components/right-sidebar/use-ai-vault-session-display-title.ts index 3500101213c..66caea4010b 100644 --- a/src/renderer/src/components/right-sidebar/use-ai-vault-session-display-title.ts +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-session-display-title.ts @@ -1,13 +1,17 @@ import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import { structuredChatDisplayName } from '../../../../shared/structured-chat-row-name' +import { + historyRowShowsChatName, + structuredChatDisplayName +} from '../../../../shared/structured-chat-row-name' import { useAppStore } from '@/store' import { structuredChatTabBySessionId } from '@/lib/structured-chat-tab-index' import { useStructuredConversationName } from '@/runtime/structured-conversation-name' -/** A native row reads the same name as the chat's tab; the host's row title is the fallback for a - * chat its host has not published this run, such as one closed before a restart. */ +/** A Claude or Codex chat's row reads the same name as the chat's tab; the host's row title is the + * fallback for a chat its host has not published this run, such as one closed before a restart. + * Other agents' rows keep their CLI's own title. */ export function useAiVaultSessionDisplayTitle(session: AiVaultSession): string { - const owner = session.structuredSession + const owner = historyRowShowsChatName(session.agent) ? session.structuredSession : undefined const customLabel = useAppStore((state) => { const tab = owner ? structuredChatTabBySessionId( 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 e3f8f81efda..8fb3188fb13 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 @@ -87,7 +87,9 @@ function makeState( activeRepoId: 'repo-1', activeWorktreeId: WORKTREE_ID, repos: [{ ...makeRepo(), ...repo }], - tabsByWorktree: { [WORKTREE_ID]: [makeTerminalTab('term-1', WORKTREE_ID, 'claude')] }, + tabsByWorktree: { + [WORKTREE_ID]: [makeTerminalTab('term-1', WORKTREE_ID, 'claude')] + }, worktreesByRepo: { 'repo-1': [ makeWorktree(WORKTREE_ID, 'wt', { path: '/repo/wt' }), @@ -179,15 +181,27 @@ describe('resolveTabSessionHistorySubject', () => { ).toEqual({ ...chatSubject }) }) - it('skips chats whose agent has no history move', () => { + it('finds a chat of any agent whose CLI can open a copy', () => { expect( resolveTabSessionHistorySubject(makeState(), { tab: { id: 'unified-1', worktreeId: WORKTREE_ID, launchAgent: 'grok' }, structuredSessionId: 'orca-chat-1' }) - ).toBeNull() + ).toEqual({ ...chatSubject }) }) + it.each(['opencode', 'omp'] as const)( + 'skips a %s chat, whose CLI cannot open a copy', + (launchAgent) => { + expect( + resolveTabSessionHistorySubject(makeState(), { + tab: { id: 'unified-1', worktreeId: WORKTREE_ID, launchAgent }, + structuredSessionId: 'orca-chat-1' + }) + ).toBeNull() + } + ) + it('finds a terminal tab by the conversation its agent reported', () => { expect( resolveTabSessionHistorySubject(makeState(liveClaudeEntry), { @@ -265,21 +279,33 @@ describe('resolveTabSessionHistorySubject', () => { it('looks nothing up over SSH, where neither move is ever offered', () => { const state = makeState(liveClaudeEntry, { connectionId: 'dev-box' }) expect( - resolveTabSessionHistorySubject(state, { tab: { id: 'term-1', worktreeId: WORKTREE_ID } }) + resolveTabSessionHistorySubject(state, { + tab: { id: 'term-1', worktreeId: WORKTREE_ID } + }) ).toBeNull() expect( resolveTabSessionHistorySubject(state, { - tab: { id: 'unified-1', worktreeId: WORKTREE_ID, launchAgent: 'claude' }, + tab: { + id: 'unified-1', + worktreeId: WORKTREE_ID, + launchAgent: 'claude' + }, structuredSessionId: 'orca-chat-1' }) ).toBeNull() }) it('looks a chat up on a remote Orca server, which owns its chats, but never a CLI there', () => { - const state = makeState(liveClaudeEntry, { executionHostId: 'runtime:server-1' }) + const state = makeState(liveClaudeEntry, { + executionHostId: 'runtime:server-1' + }) expect( resolveTabSessionHistorySubject(state, { - tab: { id: 'unified-1', worktreeId: WORKTREE_ID, launchAgent: 'claude' }, + tab: { + id: 'unified-1', + worktreeId: WORKTREE_ID, + launchAgent: 'claude' + }, structuredSessionId: 'orca-chat-1' }) ).toMatchObject({ @@ -287,7 +313,9 @@ describe('resolveTabSessionHistorySubject', () => { request: { executionHostScope: 'runtime:server-1' } }) expect( - resolveTabSessionHistorySubject(state, { tab: { id: 'term-1', worktreeId: WORKTREE_ID } }) + resolveTabSessionHistorySubject(state, { + tab: { id: 'term-1', worktreeId: WORKTREE_ID } + }) ).toBeNull() }) @@ -537,7 +565,10 @@ describe('lookupTabSessionHistoryRow', () => { it('forces no scan on a remote Orca server', async () => { const listSessions = vi.fn(async (_args: AiVaultListArgs) => listResult([])) await lookupTabSessionHistoryRow( - { ...chatSubject, request: { ...PANEL_REQUEST, executionHostScope: 'runtime:server-1' } }, + { + ...chatSubject, + request: { ...PANEL_REQUEST, executionHostScope: 'runtime:server-1' } + }, listSessions, lookupOptions ) 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 bd20f200cbe..7eac50cd99c 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 @@ -18,10 +18,8 @@ import { parseExecutionHostId, type ExecutionHostScope } from '../../../../shared/execution-host' -import { - isAgentSessionHandleProvider, - type AgentSessionHandleProvider -} from '../../../../shared/agent-session-provider-handle' +import type { AgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { agentForksConversations } from '../../../../shared/agent-resume-argv' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { resolveAiVaultSessionSurfaceSwitchTargets } from '../right-sidebar/ai-vault-session-surface-switch' import { resolveAiVaultHistoryRowResume } from '../right-sidebar/ai-vault-session-resume-in-chat-workspace' @@ -122,10 +120,11 @@ export function resolveTabSessionHistorySubject( : null } if (args.structuredSessionId !== undefined) { - // Only these providers have history rows either move can act on. - const target = isAgentSessionHandleProvider(args.tab.launchAgent) - ? workspace(args.tab.worktreeId, 'chat') - : null + // A chat tab's only move is opening a copy in its CLI. + const target = + args.tab.launchAgent && agentForksConversations(args.tab.launchAgent) + ? workspace(args.tab.worktreeId, 'chat') + : null return target ? { ...target, kind: 'chat', sessionId: args.structuredSessionId } : null } // The pane-to-conversation mapping tab titles use, limited to agents still running: once the agent diff --git a/src/shared/agent-resume-argv.ts b/src/shared/agent-resume-argv.ts index b884e4e032d..c165ec83385 100644 --- a/src/shared/agent-resume-argv.ts +++ b/src/shared/agent-resume-argv.ts @@ -80,17 +80,25 @@ export function getAgentResumeArgv( } } -/** Opens a copy of the conversation under a new id, leaving the original untouched. Only the two - * agents native chat can own a conversation for, which is the only case that needs a fork. */ +/** Opens a copy of the conversation under a new id, leaving the original untouched: how a chat's + * conversation continues in the CLI without a second writer. Only CLIs that can copy one. */ +const FORK_ARGV: Partial< + Record string[] | null> +> = { + claude: ({ id }) => ['claude', '--resume', id, '--fork-session'], + codex: ({ id }) => ['codex', 'fork', id], + grok: ({ id }) => ['grok', '--resume', id, '--fork-session'], + pi: ({ transcriptPath }) => (transcriptPath ? ['pi', '--fork', transcriptPath] : null) +} + +export function agentForksConversations(agent: string): boolean { + return Object.hasOwn(FORK_ARGV, agent) +} + export function getAgentForkArgv( agent: ResumableTuiAgent, providerSession: AgentProviderSessionMetadata ): string[] | null { - if (providerSession.key !== 'session_id') { - return null - } - if (agent === 'claude') { - return ['claude', '--resume', providerSession.id, '--fork-session'] - } - return agent === 'codex' ? ['codex', 'fork', providerSession.id] : null + const forkArgv = FORK_ARGV[agent] + return forkArgv && providerSession.key === 'session_id' ? forkArgv(providerSession) : null } diff --git a/src/shared/structured-chat-row-name.ts b/src/shared/structured-chat-row-name.ts index 96363248cd1..cbe4cd7c3e4 100644 --- a/src/shared/structured-chat-row-name.ts +++ b/src/shared/structured-chat-row-name.ts @@ -7,3 +7,9 @@ export function structuredChatDisplayName( ): string { return customLabel?.trim() || conversationName || fallback.trim() } + +/** Whether a Session History row a native chat owns shows the chat's name. Only Claude and Codex + * rows do; other agents' rows keep their CLI's own title, as their terminal conversations do. */ +export function historyRowShowsChatName(rowAgent: string): boolean { + return rowAgent === 'claude' || rowAgent === 'codex' +} diff --git a/src/shared/tui-agent-fork-startup.test.ts b/src/shared/tui-agent-fork-startup.test.ts index 476644837cb..e56b5b0892b 100644 --- a/src/shared/tui-agent-fork-startup.test.ts +++ b/src/shared/tui-agent-fork-startup.test.ts @@ -1,11 +1,24 @@ import { describe, expect, it } from 'vitest' -import { getAgentForkArgv } from './agent-resume-argv' +import { agentForksConversations, getAgentForkArgv } from './agent-resume-argv' import { buildAgentResumeStartupPlan } from './tui-agent-startup' const SESSION = { key: 'session_id', id: 's1' } as const describe('agent fork startup', () => { - it('forks Claude and Codex and nothing else', () => { + it('forks with each CLI that can copy a conversation', () => { + expect(getAgentForkArgv('grok', SESSION)).toEqual(['grok', '--resume', 's1', '--fork-session']) + expect(getAgentForkArgv('pi', { ...SESSION, transcriptPath: '/s/a.jsonl' })).toEqual([ + 'pi', + '--fork', + '/s/a.jsonl' + ]) + expect(getAgentForkArgv('pi', SESSION)).toBeNull() + expect(getAgentForkArgv('opencode', SESSION)).toBeNull() + expect(getAgentForkArgv('omp', SESSION)).toBeNull() + expect(agentForksConversations('constructor')).toBe(false) + }) + + it('forks Claude and Codex', () => { expect(getAgentForkArgv('claude', SESSION)).toEqual([ 'claude', '--resume',