From e83e40e258ac9993aacd3df806d75daad1619614 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:14:01 -0700 Subject: [PATCH] Session History shows OpenCode, Grok, OMP and Pi chats as chats and refuses a second terminal writer (#27183) * refactor(chat): answer transcript adoption and Session History from the agent registration Claude and Codex's transcript importers and Session History ownership (row id, resume-command grammar) move onto their runtime registrations as required-nullable fields; every other agent writes null. agentSession.agents publishes the derived transcriptAdoption/sessionHistory flags, and a client reads a host that omits them (or lists nothing) as the old build's answer. Strict parity: only Claude and Codex answer yes anywhere. * refactor(chat): one Session History ownership index, binaries from row agents, fork offer from fork argv Review round 1: the duplicate-adoption check and Session History ownership read one index keyed by the registration's row id (an importer now requires sessionHistory); resume-command binaries derive from rowAgents through the TUI agent config; the Resume in New CLI offer asks getAgentForkArgv instead of naming Claude and Codex. Strict parity: only Claude and Codex answer yes. * feat(chat): Session History owns OpenCode, Grok, OMP and Pi native chats An OpenCode, Grok, OMP or Pi native chat's conversation listed in Session History as a plain terminal row, and resuming it ran the agent's CLI as a second writer to the conversation the chat was still using. Each agent's registration now fills `sessionHistory`, so those rows show as the chat (its name, opening the chat) and Orca refuses a terminal resume of an owned conversation, as it already did for Claude and Codex. - OpenCode lists under `opencode` and `opencode2` (2.x) rows by its ses_ id; `-s/--session`, `-c/--continue` resume, `--fork` does not. - Grok: `-r/--resume/--load` and `-c`; a non-UUID value names a title, so it is read as target-less; `--fork-session` does not resume. - OMP: `-r/--resume/--session` take an id, any prefix of it, or the session file's path; bare `--resume` and `-c` are target-less. - Pi: rows are listed by the session file name's id while the chat holds the file's path; `--session `, `-c`, `-r` (picker). - An optional `resumeTargetMatches` on the registration covers prefix and path targets; it defaults to exact equality. - Each agent now reads every token naming its own binary, so another agent's name earlier in a command (`claude --model pi`, `cd ~/src/codex && claude --resume X`) no longer hides a resume. For Claude and Codex this only ever refuses more than before. - A renamed OpenCode chat tab names its `opencode2` row too. * fix(chat): show owned rows only to clients that open the chat, without a resume command - The list and search projection now asks, per owning agent, whether the requesting client opens that agent's chat. Current mobile renders only Claude and Codex chats, so it no longer receives OpenCode/Grok/OMP/Pi rows owned by a chat (a tap there opened an empty terminal and showed agent_session_conflict). Claude and Codex keep their rule: any client that reads structured sessions. - An owned search hit opens its chat, so it no longer carries a terminal resume command (all agents; `orca` CLI search stopped printing one for a chat). - OpenCode/Grok/OMP/Pi flags match case-sensitively, as their CLIs read them, so `rg foo src/pi -C 3` is not `pi -c`. Claude and Codex still fold case. - A test feeds every resume command Orca builds for an owned row of each agent (Session History, live-tab argv; posix, PowerShell, cmd) into the guard. * fix(chat): keep the resume guard linear and skip chat-tab lookups with no move - Each agent binary mention in a command now reads its arguments only up to that agent's next mention, which reads the rest, so every token is parsed once per agent. A 200 KB text naming agents throughout took 9.4 s on the host's main process; it now takes about 90 ms. Every refusal before still holds: a later mention still reads its own resume. - Right-clicking a native chat tab looks up its Session History row only when the agent's CLI can fork a conversation, the one move a chat tab offers, so OpenCode, Grok, OMP and Pi chat tabs run no list or rescan for nothing. Claude and Codex are unchanged. * fix(chat): read each shell command once in the resume guard The guard tokenizes the text once and splits it into shell commands at &&, ||, ;, |, & and newlines (a newline after a line-continuation mark stays inside its command). In each command, each agent reads its arguments from its first binary token to the command's end; a later token naming it is an argument. A `claude` path inside a Claude command no longer ends that command, so `--session-id ` still cancels a fork after it, and a binary-named path between a resume and its fork flag keeps the fork a fork. Binary names are looked up once per token, which keeps a 16 MB send to about 2 s. Against main's parser, a Claude/Codex command is now allowed only when main refused it for a flag in a later shell command (`claude ; echo --resume X`). * fix(chat): end a resume-guard command at a bare carriage return A PTY's Enter, and many orca terminal send texts, end lines with \r. The guard now treats \r and \r\n as line breaks like \n, including after a line continuation mark, so a later line's resume is read as its own command. * refactor(chat): drop the transcript-adoption and Session History wire flags agentSession.agents no longer publishes transcriptAdoption or sessionHistory, so there is no new field for older and newer peers to reconcile. Their only consumers were renderer gates that could answer from what they already have: "Resume in New Native Chat" eligibility goes back to main's rule (adopting a CLI conversation is Claude/Codex-only, and the host still refuses any other agent from its registration's transcriptImport). Removes the AgentSessionDeclaredCapabilities split, the older-host decoder shim and the generic host-flag reader. * refactor(chat): keep main's Claude/Codex rule for the CLI fork offer "Resume in New CLI" and the chat-tab menu read the agent name again instead of building a synthetic provider session to ask the fork builder, and the chat-tab lookup loses its host Session History flag gate. Only Claude and Codex CLIs fork, so only their chat tabs look up a row, exactly as on main: no new list requests. * refactor(chat): one registration-aware ownership index, no registration union - listStructuredProviderSessionOwnership reads each record's registration for its Session History row id; the callback parameter and its one-function wrapper module are gone (no import cycle: checked value and type imports). - StructuredAgentCliConversations is a plain type: transcriptImport is nullable and sessionHistory is required, since every registered agent now owns its rows. The "importer needs history" union and its null checks go. - A test pins that no two registrations claim the same Session History row agent, since the owner lookup takes the first match. * fix(chat): read resume commands into words as a shell does Separators glued to a word (`opencode -s id; echo`, `cd /x&&claude -r id`) now end the command, and quotes are removed inside a word, so `--session="id"` reads as the id. Applies to every agent, Claude and Codex included. The acp-and-pi allowed() helper now asserts. * fix(chat): count an agent's name only where a shell would run it terminal.send checks agent prompts too, and agentPrompt does not keep the text from a shell (it falls back to a plain PTY write), so prompt text like "Fix the Pi --resume flag" was refused. The resume check now reads each command's program: its first word past VAR=x prefixes, a path, and env/exec/command/sudo/npx/bunx/pnpm dlx. Every command Orca's builders produce, Claude and Codex included and with a user's command override, is still refused. * refactor(chat): compare resume ids in any case; Claude uses the shared option reader The default target match now ignores case (ids are UUIDs or ses_ ids), so Grok's override, which only repeated it, is gone. Claude folds its flags and reads them with resumeInvocationFromOptions instead of repeating the `=` handling. * perf(chat): read terminal text in one linear pass A repeated regex group overflowed the stack on a 16 MB word. A sticky-regex scanner reads words, quoted runs and separators in one pass; an unclosed quote is searched for once per quote kind. * refactor(chat): read a command's resume only when its program is an agent * fix(chat): read an agent run under nohup, time or wsl as the command's program * chore: restore pnpm-lock.yaml * fix(chat): keep the CLI's own title on OpenCode, Grok, OMP and Pi chat rows Session History shows these agents' chat conversations under the title their CLI saved, as it does for their terminal conversations; the chat tab's name and renames stay on the tab. Claude and Codex rows keep the chat's name, as on main. * feat(chat): Resume in New CLI for Grok and Pi chats A Grok or Pi chat's Session History row and tab menu now offer "Resume in New CLI", like Claude and Codex: the CLI opens a copy of the conversation (grok --resume --fork-session, pi --fork ) and the chat keeps its own. OpenCode's interactive CLI and OMP have no way to open a copy, so their chats keep only Resume. * fix(chat): offer Resume in New CLI only when the copy command can be built A Pi chat found by search on a remote Orca server has no session file path, so its copy command can't be built; the row no longer offers the button there. * fix(chat): refuse pi --session-id of a conversation a chat owns `pi --session-id ` reopens that session when it exists, so it is a second writer like --session. --- src/main/acp/acp-launch-specs.ts | 15 +- src/main/ai-vault/shell-command-words.test.ts | 66 ++++ src/main/ai-vault/shell-command-words.ts | 137 +++++++ ...tured-session-ownership-acp-and-pi.test.ts | 358 ++++++++++++++++++ ...-session-ownership-client-audience.test.ts | 238 ++++++++++++ ...d-session-ownership-shell-commands.test.ts | 171 +++++++++ ...ructured-session-ownership.test-support.ts | 86 +++++ .../structured-session-ownership.test.ts | 288 +++++++++----- .../ai-vault/structured-session-ownership.ts | 206 +++++----- src/main/claude/claude-cli-conversations.ts | 57 +++ src/main/codex/codex-cli-conversations.ts | 36 ++ src/main/grok/grok-session-history.ts | 24 ++ src/main/ipc/ai-vault-search.ts | 13 +- src/main/ipc/ai-vault.ts | 7 +- .../structured-provider-session-ownership.ts | 26 +- .../structured-agent-cli-conversations.ts | 121 ++++++ ...red-agent-session-history-adoption.test.ts | 3 - ...ructured-agent-session-history-adoption.ts | 9 +- src/main/omp/omp-session-history.ts | 18 + src/main/opencode/opencode-session-history.ts | 20 + src/main/pi/pi-session-history.ts | 20 + src/main/pi/rpc-runtime-registration.ts | 3 + ...structured-agent-session-create-support.ts | 15 +- ...ctured-agent-session-create-intent.test.ts | 29 ++ src/main/runtime/rpc/methods/ai-vault.ts | 25 +- .../structured-agent-session-policy.ts | 16 + .../structured-agent-runtime-registrations.ts | 9 +- ...tructured-agent-session-create-adoption.ts | 57 +-- ...iVaultSessionRow.structured-title.test.tsx | 53 ++- .../ai-vault-session-cli-fork.test.ts | 51 ++- .../ai-vault-session-cli-fork.ts | 16 +- .../ai-vault-session-surface-switch.test.ts | 8 +- .../ai-vault-session-surface-switch.ts | 5 +- .../use-ai-vault-session-display-title.ts | 12 +- .../tab-session-history-switch.test.ts | 49 ++- .../tab-bar/tab-session-history-switch.ts | 15 +- src/shared/agent-resume-argv.ts | 26 +- src/shared/structured-chat-row-name.ts | 6 + src/shared/tui-agent-fork-startup.test.ts | 17 +- 39 files changed, 1997 insertions(+), 334 deletions(-) create mode 100644 src/main/ai-vault/shell-command-words.test.ts create mode 100644 src/main/ai-vault/shell-command-words.ts create mode 100644 src/main/ai-vault/structured-session-ownership-acp-and-pi.test.ts create mode 100644 src/main/ai-vault/structured-session-ownership-client-audience.test.ts create mode 100644 src/main/ai-vault/structured-session-ownership-shell-commands.test.ts create mode 100644 src/main/ai-vault/structured-session-ownership.test-support.ts create mode 100644 src/main/claude/claude-cli-conversations.ts create mode 100644 src/main/codex/codex-cli-conversations.ts create mode 100644 src/main/grok/grok-session-history.ts create mode 100644 src/main/native-chat/structured-agent-cli-conversations.ts create mode 100644 src/main/omp/omp-session-history.ts create mode 100644 src/main/opencode/opencode-session-history.ts create mode 100644 src/main/pi/pi-session-history.ts 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',