From 8dfaeea5720894188570aee9d5102a1cef7a4d14 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:31:40 -0700 Subject: [PATCH] Resume in New Native Chat for Pi (#28080) * feat(chat): Resume in New Native Chat for Pi terminal conversations A Pi conversation started in the terminal now offers "Resume in New Native Chat" in its Session History row menu and its tab's right-click menu, like the other chat agents: a new Pi chat takes it over, showing its history and continuing it. Pi joins the shared adoption path. Its import finds the session file Session History lists under the row id, and reads it with OMP's decoder, since OMP kept Pi's session format. A Pi chat's handle names its session file, so the import says so and create mints the adopted handle from the found file; the committed-create replay compares through the registration's row id, and the adopted link id is kept to the link-id alphabet. A Pi chat forks a conversation recorded in another folder instead of resuming it, so the workspace rule covers Pi too. * fix(pi): preserve adopted session identity and active history * Fix saved Pi adoption history and summary content * test: keep only the tests that pin user-visible behavior --- .../ai-vault/session-scanner-agent-sources.ts | 12 +- .../journal-legacy-import.ts | 19 ++- .../journal-pi-active-branch.ts | 123 ++++++++++++++++++ .../journal-pi-context-fixtures.ts | 73 +++++++++++ .../journal-pi-context-import.test.ts | 98 ++++++++++++++ ...tured-agent-session-adopted-import.test.ts | 29 ++++- .../structured-agent-session-attach.ts | 5 +- ...ed-agent-cli-conversation-adoption.test.ts | 76 +++++++++-- .../structured-agent-cli-conversations.ts | 4 + ...red-agent-session-history-adoption.test.ts | 44 +++++++ ...ructured-agent-session-history-adoption.ts | 5 +- src/main/pi/pi-transcript-import.ts | 35 +++++ src/main/pi/rpc-launch-resolution.ts | 31 ++++- src/main/pi/rpc-runtime-registration.ts | 3 +- ...structured-agent-session-create-support.ts | 14 +- ...ctured-agent-session-create-intent.test.ts | 66 +++++++++- ...tructured-agent-session-create-adoption.ts | 5 +- .../ai-vault-session-resume-in-chat.test.ts | 6 +- .../ai-vault-session-resume-in-chat.ts | 6 +- .../tab-session-history-switch.test.ts | 8 +- ...uctured-agent-cli-conversation-adoption.ts | 3 +- 21 files changed, 618 insertions(+), 47 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-pi-active-branch.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-pi-context-fixtures.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-pi-context-import.test.ts create mode 100644 src/main/pi/pi-transcript-import.ts diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index bce3a10ae8e..224e355538f 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -42,10 +42,12 @@ const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects') const CODEBUDDY_PROJECTS_DIR = join(homedir(), '.codebuddy', 'projects') const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions') const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions') -const PI_SESSIONS_DIR = normalizeAgentSessionsDir( - process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions'), - '.pi' -) +export function piSessionHistorySessionsDir(): string { + return normalizeAgentSessionsDir( + process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions'), + '.pi' + ) +} // Why: Prime Agent brands Pi's env contract with its own prefix and adds a // dedicated sessions-root override, so resolution differs from Pi/OMP in shape // as well as in variable name. @@ -248,7 +250,7 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = { }, pi: { rootDirs: (options, wslHomeDirs) => - sessionRootDirs(options.piSessionsDir ?? PI_SESSIONS_DIR, wslHomeDirs, [ + sessionRootDirs(options.piSessionsDir ?? piSessionHistorySessionsDir(), wslHomeDirs, [ '.pi', 'agent', 'sessions' diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index b934e2a5dc7..e69abd28a67 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -1,8 +1,8 @@ // Hydrating a journal from a bridge-era transcript. // -// This reuses the existing per-agent transcript decoders verbatim — a second -// parser would drift from the one the live view already uses. The decoders -// return a render model with no identity, so the import wraps them: the wrapper +// Ordinary messages reuse the existing per-agent transcript decoders; Pi also +// projects its saved branch and summaries. The decoders return a render model +// with no identity, so the import wraps them: the wrapper // reads an identity anchor off the SAME raw line, then delegates the content. // // Import always opens a fresh epoch. The imported timeline is a best-effort @@ -31,6 +31,7 @@ import { decodeTranscriptStream } from '../transcript-stream-lines' import { readOpenCodeNativeChatTranscriptFull } from '../transcript-opencode' import { boundSubagentEntryId } from '../subagent-entry-id-bounds' import { createLegacyIdentityTracker } from './journal-legacy-identity' +import { createPiTranscriptCollector } from './journal-pi-active-branch' import type { JournalReplacementItem } from './journal-epoch-replacement' import { boundInlineText, @@ -89,7 +90,9 @@ export async function prepareLegacyTranscriptImport(input: { }): Promise<{ ok: true; items: JournalReplacementItem[] } | { ok: false; error: string }> { const options = input.options ?? {} const limits = options.limits ?? DEFAULT_JOURNAL_PAYLOAD_LIMITS - const transcriptAgent = resolveNativeChatTranscriptAgent(input.agent) + // OMP is a Pi fork that kept Pi's session format, so OMP's decoder reads Pi's sessions too. + const transcriptAgent = + input.agent === 'pi' ? 'omp' : resolveNativeChatTranscriptAgent(input.agent) if (!transcriptAgent) { return { ok: false, error: `Unsupported agent for journal import: ${input.agent}` } } @@ -206,6 +209,7 @@ async function decodeWithIdentities(input: { }) const decode = TRANSCRIPT_DECODERS[input.transcriptAgent] const identities: AgentJournalItemIdentity[] = [] + const pi = input.agent === 'pi' ? createPiTranscriptCollector() : null let lineIndex = 0 // Count raw bytes while reading: the source can grow after the stat check. @@ -217,6 +221,10 @@ async function decodeWithIdentities(input: { (line, fallbackId) => { const trackedIdentity = tracker.identify(line, lineIndex) lineIndex += 1 + if (pi) { + pi.record(line, fallbackId, trackedIdentity) + return null + } const message = decode(line, fallbackId) if (message) { identities.push( @@ -235,6 +243,9 @@ async function decodeWithIdentities(input: { true, MAX_LEGACY_IMPORT_SOURCE_BYTES ) + if (pi) { + return pi.project() + } return { messages, identities } } diff --git a/src/main/native-chat/agent-session-journal/journal-pi-active-branch.ts b/src/main/native-chat/agent-session-journal/journal-pi-active-branch.ts new file mode 100644 index 00000000000..7940fa70f9c --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-pi-active-branch.ts @@ -0,0 +1,123 @@ +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../shared/native-chat-types' +import { asRecord, parseJsonObject, timestampMs } from '../../ai-vault/session-scanner-values' +import { decodeOmpTranscriptLine } from '../transcript-line-decoders-omp' + +type PiTranscriptBranchEntry = { + id: string + parentId: string | null + type: unknown + firstKeptEntryId: string | null + message: NativeChatMessage | null + identity: AgentJournalItemIdentity +} + +export function createPiTranscriptCollector(): { + record(line: string, fallbackId: string, identity: AgentJournalItemIdentity): void + project(): { messages: NativeChatMessage[]; identities: AgentJournalItemIdentity[] } +} { + const entries: PiTranscriptBranchEntry[] = [] + const fileEntryIds: (string | null)[] = [] + let version = 1 + return { + record(line, fallbackId, identity) { + const record = parseJsonObject(line) + if (!record) { + return + } + if (record.type === 'session') { + version = typeof record.version === 'number' ? record.version : 1 + fileEntryIds.push(null) + return + } + const id = version < 2 ? `#${fileEntryIds.length}` : record.id + const parentId = version < 2 ? (entries.at(-1)?.id ?? null) : record.parentId + if (typeof id !== 'string' || !(parentId === null || typeof parentId === 'string')) { + throw new Error('Pi session entry has no valid branch identity') + } + // Pi v1 indexes include the header and parsed metadata, but not malformed lines. + const firstKeptEntryId = + version < 2 && typeof record.firstKeptEntryIndex === 'number' + ? fileEntryIds[record.firstKeptEntryIndex] + : record.firstKeptEntryId + const message = + record.type === 'compaction' || record.type === 'branch_summary' + ? piSummaryMessage(record, fallbackId) + : asRecord(record.message)?.role === 'system' + ? null + : decodeOmpTranscriptLine(line, fallbackId) + entries.push({ + id, + parentId, + type: record.type, + firstKeptEntryId: typeof firstKeptEntryId === 'string' ? firstKeptEntryId : null, + message, + identity + }) + fileEntryIds.push(id) + }, + project: () => selectActivePiBranch(entries) + } +} + +function piSummaryMessage( + record: Record, + fallbackId: string +): NativeChatMessage | null { + const summary = typeof record.summary === 'string' ? record.summary : '' + if (record.type === 'branch_summary' && !summary) { + return null + } + const timestamp = timestampMs(record.timestamp) + return { + id: typeof record.id === 'string' ? record.id : fallbackId, + role: 'system', + blocks: [{ type: 'text', text: summary }], + timestamp: Number.isFinite(timestamp) ? timestamp : null, + source: 'transcript' + } +} + +/** Match Pi's fresh-session TUI: persisted leaf ancestry, then buildContextEntries. */ +function selectActivePiBranch(entries: readonly PiTranscriptBranchEntry[]): { + messages: NativeChatMessage[] + identities: AgentJournalItemIdentity[] +} { + const byId = new Map(entries.map((entry) => [entry.id, entry])) + if (byId.size !== entries.length) { + throw new Error('Pi session has duplicate branch identities') + } + const branch: PiTranscriptBranchEntry[] = [] + const visited = new Set() + let current = entries.at(-1) + while (current) { + if (visited.has(current.id)) { + throw new Error('Pi session branch contains a cycle') + } + visited.add(current.id) + branch.push(current) + current = current.parentId ? byId.get(current.parentId) : undefined + } + const messages: NativeChatMessage[] = [] + const identities: AgentJournalItemIdentity[] = [] + for (const entry of compactedPiBranch(branch.toReversed())) { + if (entry.message) { + messages.push(entry.message) + identities.push(entry.identity) + } + } + return { messages, identities } +} + +function compactedPiBranch(branch: PiTranscriptBranchEntry[]): PiTranscriptBranchEntry[] { + const compactionIndex = branch.findLastIndex((entry) => entry.type === 'compaction') + const compaction = branch[compactionIndex] + if (!compaction) { + return branch + } + const keptIndex = branch.findIndex((entry) => entry.id === compaction.firstKeptEntryId) + // Pi searches for firstKeptEntryId only before the latest compaction. + const kept = + keptIndex !== -1 && keptIndex < compactionIndex ? branch.slice(keptIndex, compactionIndex) : [] + return [compaction, ...kept, ...branch.slice(compactionIndex + 1)] +} diff --git a/src/main/native-chat/agent-session-journal/journal-pi-context-fixtures.ts b/src/main/native-chat/agent-session-journal/journal-pi-context-fixtures.ts new file mode 100644 index 00000000000..c738a16d6e7 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-pi-context-fixtures.ts @@ -0,0 +1,73 @@ +const timestamp = '2026-10-12T12:00:00.000Z' + +function turn(id: string, parentId: string | null, text = id, role = 'user') { + return { type: 'message', id, parentId, timestamp, message: { role, content: text } } +} + +function compact(id: string, parentId: string, firstKeptEntryId: string | null) { + return { + type: 'compaction', + id, + parentId, + timestamp, + firstKeptEntryId, + summary: id, + tokensBefore: 100 + } +} + +const root = turn('root', null) +const kept = turn('kept', 'root') +const checkpoint = compact('summary', 'kept', 'kept') +const later = turn('later', 'summary', 'later', 'assistant') + +// Verified against Pi 1.1.0 buildContextEntries + sessionEntryToContextMessages. +export const PI_CONTEXT_FIXTURES = [ + { + name: 'kept range before latest compaction', + entries: [root, kept, checkpoint, later], + texts: ['summary', 'kept', 'later'] + }, + { + name: 'branch summary with metadata leaf omits abandoned branch', + entries: [ + root, + turn('abandoned', 'root'), + { + type: 'branch_summary', + id: 'branch', + parentId: 'root', + fromId: 'abandoned', + summary: 'branch', + timestamp + }, + { + type: 'label', + id: 'label', + parentId: 'branch', + targetId: 'branch', + label: 'saved', + timestamp + } + ], + texts: ['root', 'branch'] + }, + { + name: 'version 1 migration uses parsed file index including header', + version: 1, + entries: [ + { type: 'message', message: { role: 'user', content: 'root' }, timestamp }, + { type: 'model_change', provider: 'test', modelId: 'test', timestamp }, + { type: 'message', message: { role: 'user', content: 'kept' }, timestamp }, + { + type: 'compaction', + firstKeptEntryIndex: 3, + summary: 'summary', + tokensBefore: 100, + timestamp + }, + { type: 'message', message: { role: 'assistant', content: 'later' }, timestamp } + ], + texts: ['summary', 'kept', 'later'] + } +] diff --git a/src/main/native-chat/agent-session-journal/journal-pi-context-import.test.ts b/src/main/native-chat/agent-session-journal/journal-pi-context-import.test.ts new file mode 100644 index 00000000000..a1450e923f3 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-pi-context-import.test.ts @@ -0,0 +1,98 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + importLegacyTranscriptIntoJournal, + prepareLegacyTranscriptImport +} from './journal-legacy-import' +import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { PI_CONTEXT_FIXTURES } from './journal-pi-context-fixtures' + +let root: string +const journals = createTrackedJournalOpener() +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-pi-context-')) +}) +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +async function prepare(entries: unknown[], version = 3) { + const filePath = join(root, 'session.jsonl') + const header = { type: 'session', id: 'session', version, cwd: root } + // Malformed records do not contribute to Pi's v1 entry indexes. + await writeFile( + filePath, + [JSON.stringify(header), '{bad json', ...entries.map((entry) => JSON.stringify(entry))].join( + '\n' + ) + ) + const result = await prepareLegacyTranscriptImport({ + agent: 'pi', + sessionId: 'session', + options: { filePath } + }) + if (!result.ok) { + throw new Error(result.error) + } + return result.items +} + +describe('saved Pi context import', () => { + it.each(PI_CONTEXT_FIXTURES)('$name', async (fixture) => { + const items = await prepare(fixture.entries, 'version' in fixture ? fixture.version : 3) + expect( + items.map((item) => + item.body.kind === 'message' + ? item.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) + : [] + ) + ).toEqual(fixture.texts.map((text) => [text])) + }) + + it('persists compaction and branch summaries in the adopted journal across reopen', async () => { + const fixture = PI_CONTEXT_FIXTURES[0] + if (!fixture) { + throw new Error('missing compaction fixture') + } + const expected = await prepare([ + ...fixture.entries, + { + type: 'branch_summary', + id: 'branch', + parentId: 'later', + fromId: 'discarded', + summary: 'Saved branch details.' + } + ]) + const filePath = join(root, 'session.jsonl') + const options = { + stateDirectory: join(root, 'journal'), + identity: { + sessionId: 'native-chat', + workspaceId: 'workspace', + hostId: 'host', + agent: 'pi' as const, + providerHandle: { transport: 'jsonl-rpc', agent: 'pi', nativeId: filePath } + } + } + const journal = await journals.open(options) + expect( + await importLegacyTranscriptIntoJournal({ + journal, + agent: 'pi', + sessionId: 'session', + fence: 1, + options: { filePath } + }) + ).toMatchObject({ ok: true, imported: 4, replaced: true }) + await journal.close() + const reopened = await journals.open(options) + expect(reopened.snapshot().items.map((item) => item.body)).toEqual( + expected.map((item) => item.body) + ) + expect(JSON.stringify(reopened.snapshot().items)).toContain('Saved branch details.') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index cd9206afd5a..92eed86e5e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -25,8 +25,7 @@ import { NO_STRUCTURED_AGENTS, structuredAgentsDeclaringNothing } from './structured-agent-session-adapter-router-test-support' -import { acpStructuredAgentDefinition } from '../../acp/acp-structured-agent-definitions' -import { acpLaunchSpecFor } from '../../acp/acp-launch-specs' +import { structuredAgentRuntimeRegistration } from '../../runtime/structured-agent-runtime-registrations' import { writeOpenCodeSqliteDatabase } from '../../ai-vault/session-scanner-opencode-sqlite-fixture' // The OpenCode reader's worker, run in process. @@ -208,17 +207,35 @@ describe('adopting a provider conversation on create', () => { ]) return path } + }, + { + agent: 'pi', + accountHome: { variable: 'PI_CODING_AGENT_DIR', path: '/home/dev/.pi/agent' }, + record: async (dir: string, text: string) => { + const path = join(dir, `2026-10-12T00-00-00-000Z_${THREAD}.jsonl`) + const records = [ + { type: 'session', version: 3, id: THREAD, cwd: '/workspace' }, + { type: 'message', id: 'rec-1', parentId: null, message: { role: 'user', content: text } } + ] + await writeFile(path, `${records.map((line) => JSON.stringify(line)).join('\n')}\n`) + return path + } } ] as const)( "seeds a $agent chat's chain with the session it adopts and imports that CLI's history", async ({ agent, accountHome, record }) => { root = await mkdtemp(join(tmpdir(), `orca-adopt-${agent}-`)) const transcriptPath = await record(root, `ORCA-${agent}-ADOPT`) - const spec = acpLaunchSpecFor(agent) - if (!spec) { + const definition = structuredAgentRuntimeRegistration(agent)?.definition + if (!definition) { throw new Error(`this build registers no ${agent} chats`) } - const handle = { transport: 'acp', agent, nativeId: THREAD } + // A Pi chat's handle names its session file; the others name the conversation id. + const handle = { + transport: definition.handleTransport, + agent, + nativeId: agent === 'pi' ? transcriptPath : THREAD + } const sessionAdapter = adapter() vi.mocked(sessionAdapter.acquire).mockImplementation(async ({ fence, spawnToken }) => ({ process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, @@ -241,7 +258,7 @@ describe('adopting a provider conversation on create', () => { accountHome, adopt: { providerHandle: handle, transcriptPath } }), - structuredAgentsDeclaringNothing([acpStructuredAgentDefinition(spec)]) + structuredAgentsDeclaringNothing([definition]) ) expect(result.ok && JSON.stringify(result.value.page.items)).toContain(`ORCA-${agent}-ADOPT`) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 54e7f61845d..c0ce0923e43 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -285,7 +285,10 @@ function adoptedProviderHandleLink( observedAt: number ): AgentSessionProviderHandleLink { return { - linkId: `${handle.agent}-${ADOPTED_HANDLE_FENCE}-${handle.nativeId}`.slice(0, 128), + // A link id is a bounded slug; Pi's native id is a file path. + linkId: `${handle.agent}-${ADOPTED_HANDLE_FENCE}-${handle.nativeId}` + .replace(/[^A-Za-z0-9_-]/g, '_') + .slice(0, 128), handle, origin: 'adopted', mintedAtFence: ADOPTED_HANDLE_FENCE, diff --git a/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts b/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts index 720189aa7a6..42009e6d884 100644 --- a/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts +++ b/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts @@ -39,8 +39,9 @@ const SESSION_ID = '0198c2a4-7d1e-7c3a-9a4b-1f2e3d4c5b6a' let root: string | null = null beforeEach(() => { - // Grok also looks where Session History lists it from; keep that off the real home. + // Grok and Pi also look where Session History lists them from; keep that off the real home. vi.stubEnv('GROK_HOME', join(tmpdir(), 'orca-adopt-no-grok-home')) + vi.stubEnv('PI_CODING_AGENT_DIR', join(tmpdir(), 'orca-adopt-no-pi-home')) }) afterEach(async () => { @@ -54,7 +55,7 @@ afterEach(async () => { }) /** Each agent's CLI conversation as its own CLI lays it out under an account home. */ -const RECORDED: Record<'grok' | 'omp', (home: string, text: string) => Promise> = { +const RECORDED: Record<'grok' | 'omp' | 'pi', (home: string, text: string) => Promise> = { grok: async (home, text) => { const dir = join(home, 'sessions', encodeURIComponent('/repo/orca'), SESSION_ID) await mkdir(dir, { recursive: true }) @@ -90,11 +91,46 @@ const RECORDED: Record<'grok' | 'omp', (home: string, text: string) => Promise { + const dir = join(home, 'sessions', '--repo-orca--') + await mkdir(dir, { recursive: true }) + const file = join(dir, `2026-10-12T00-00-00-000Z_${SESSION_ID}.jsonl`) + const records = [ + { type: 'session', version: 3, id: SESSION_ID, cwd: '/repo/orca' }, + { + type: 'message', + id: 'a0b1c2d3', + parentId: null, + message: { role: 'system', content: '', sections: { preamble: 'You are Pi' } } + }, + { + type: 'message', + id: 'a1b2c3d4', + parentId: 'a0b1c2d3', + message: { role: 'user', content: text, timestamp: 1_791_763_201_000 } + }, + { + type: 'message', + id: 'b2c3d4e5', + parentId: 'a1b2c3d4', + message: { role: 'assistant', content: [{ type: 'text', text: 'Hi!' }], stopReason: 'stop' } + } + ] + await writeFile(file, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`) + return file } } +const ACCOUNT_VARIABLE = { + grok: 'GROK_HOME', + omp: 'PI_CODING_AGENT_DIR', + pi: 'PI_CODING_AGENT_DIR' +} + function adopt( - agent: 'grok' | 'omp' | 'opencode', + agent: 'grok' | 'omp' | 'opencode' | 'pi', selectedAccountHome: AgentSessionAccountHome, providerSessionId = SESSION_ID, launchEnv: NodeJS.ProcessEnv = process.env, @@ -287,15 +323,15 @@ it('refuses OMP history from another workspace without creating a blank chat', a }) }) -describe.each(['grok', 'omp'] as const)('adopting a %s CLI conversation', (agent) => { +describe.each(['grok', 'omp', 'pi'] as const)('adopting a %s CLI conversation', (agent) => { it('finds the conversation under the chat account and imports its history', async () => { root = await mkdtemp(join(tmpdir(), `orca-${agent}-adopt-`)) const recorded = await RECORDED[agent](root, `ORCA-${agent}-ADOPT`) - - const accountHome = { - variable: agent === 'grok' ? 'GROK_HOME' : 'PI_CODING_AGENT_DIR', - path: root + if (agent === 'pi') { + vi.stubEnv('PI_CODING_AGENT_DIR', root) } + + const accountHome = { variable: ACCOUNT_VARIABLE[agent], path: root } const adoption = await adopt(agent, accountHome) expect(adoption).toEqual({ accountHome, transcriptPath: recorded }) @@ -305,18 +341,40 @@ describe.each(['grok', 'omp'] as const)('adopting a %s CLI conversation', (agent options: { filePath: adoption.transcriptPath } }) expect(imported.ok && JSON.stringify(imported.items)).toContain(`ORCA-${agent}-ADOPT`) + if (agent === 'pi' && imported.ok) { + expect(imported.items.map((item) => item.body.kind === 'message' && item.body.role)).toEqual([ + 'user', + 'assistant' + ]) + } }) it('refuses a conversation the account does not hold, rather than opening a blank chat', async () => { root = await mkdtemp(join(tmpdir(), `orca-${agent}-adopt-missing-`)) await expect( - adopt(agent, { variable: agent === 'grok' ? 'GROK_HOME' : 'PI_CODING_AGENT_DIR', path: root }) + adopt(agent, { variable: ACCOUNT_VARIABLE[agent], path: root }) ).rejects.toMatchObject({ refusal: { code: 'agent_session_identity_required' } }) }) }) +describe('adopting a Pi CLI conversation', () => { + it('pins the Session History account when another Pi home has the same id', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-pi-adopt-homes-')) + const selected = join(root, 'selected') + const history = join(root, 'history') + await RECORDED.pi(selected, 'SELECTED') + const source = await RECORDED.pi(history, 'HISTORY') + vi.stubEnv('PI_CODING_AGENT_DIR', history) + + expect(await adopt('pi', { variable: 'PI_CODING_AGENT_DIR', path: selected })).toEqual({ + accountHome: { variable: 'PI_CODING_AGENT_DIR', path: history }, + transcriptPath: source + }) + }) +}) + describe('adopting an OpenCode CLI conversation', () => { const OPENCODE_SESSION = 'ses_orca_adopt' const unmanaged: AgentSessionAccountHome = { kind: 'opencode', locator: { kind: 'unmanaged' } } diff --git a/src/main/native-chat/structured-agent-cli-conversations.ts b/src/main/native-chat/structured-agent-cli-conversations.ts index f4ed02ecda4..3e84138c0c7 100644 --- a/src/main/native-chat/structured-agent-cli-conversations.ts +++ b/src/main/native-chat/structured-agent-cli-conversations.ts @@ -30,6 +30,8 @@ export type StructuredAgentTranscriptImport = { launchEnv: NodeJS.ProcessEnv workspaceCwd: string }) => Promise + /** The adopted chat's handle names the transcript's path, not the conversation id (Pi). */ + handleNamesTranscript?: true } /** The import of an agent whose account is one directory, named by the chat's own variable. */ @@ -44,8 +46,10 @@ export function directoryTranscriptImport(directories: { launchEnv: NodeJS.ProcessEnv workspaceCwd: string }) => Promise + handleNamesTranscript?: true }): StructuredAgentTranscriptImport { return { + ...(directories.handleNamesTranscript ? { handleNamesTranscript: true } : {}), accountHomeCandidates: ({ settings, selectedAccountHome }) => isLegacyAgentSessionAccountHome(selectedAccountHome) ? directories 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 f2fa0348d9a..44154317b90 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 @@ -10,6 +10,7 @@ import { structuredAdoptionConflictError, type StructuredAgentSessionAdoptionOwnership } from './structured-agent-session-history-adoption' +import { PI_SESSION_HISTORY } from '../pi/pi-session-history' import { claudeProviderHandle, codexProviderHandle @@ -22,6 +23,7 @@ function committedReplay(overrides: { callerKey?: string; operationId?: string } return findCommittedStructuredAgentSessionAdoptionReplay({ agent: 'codex', providerSessionId: 'thread-1', + rowSessionId: (link) => link.handle.nativeId, selfSessionId: 'codex_adopted', callerKey: overrides.callerKey ?? 'client-1', operationId: overrides.operationId ?? OPERATION, @@ -139,6 +141,7 @@ describe('findCommittedStructuredAgentSessionAdoptionReplay', () => { findCommittedStructuredAgentSessionAdoptionReplay({ agent: 'claude', providerSessionId: 'provider-session-alpha-1', + rowSessionId: (link) => link.handle.nativeId, selfSessionId: 'claude_adopted', callerKey: 'client-1', operationId: OPERATION, @@ -161,6 +164,47 @@ describe('findCommittedStructuredAgentSessionAdoptionReplay', () => { }) }) +describe('replaying a Pi adoption, whose handle names its session file', () => { + it("matches the row id the agent's Session History shows the file as", () => { + const file = + '/home/dev/.pi/agent/sessions/--repo--/2026-10-12T00-00-00-000Z_0198c2a4-7d1e-7c3a-9a4b-1f2e3d4c5b6a.jsonl' + const lease = agentSessionLeaseFixture({ sessionId: 'pi_adopted' }) + const record = agentSessionRecordFixture(lease) + record.provider = 'pi' + record.providerHandleChain[0] = { + ...record.providerHandleChain[0]!, + origin: 'adopted', + handle: { transport: 'jsonl-rpc', agent: 'pi', nativeId: file } + } + const replay = (providerSessionId: string) => + findCommittedStructuredAgentSessionAdoptionReplay({ + agent: 'pi', + providerSessionId, + rowSessionId: PI_SESSION_HISTORY.rowSessionId, + selfSessionId: 'pi_adopted', + callerKey: 'client-1', + operationId: OPERATION, + record, + operations: [ + { + callerKey: 'client-1', + operationId: OPERATION, + fingerprint: 'fingerprint-1', + operationTimestamp: 1_800_000_000_000, + recordedAt: 1_800_000_000_000, + expiresAt: 1_900_000_000_000, + outcome: { status: 'succeeded', sessionId: 'pi_adopted' } + } + ] + }) + + expect(replay('0198c2a4-7d1e-7c3a-9a4b-1f2e3d4c5b6a')).toMatchObject({ + providerHandle: { nativeId: file } + }) + expect(replay('0198c2a4-7d1e-7c3a-9a4b-000000000000')).toBeNull() + }) +}) + describe('structuredAdoptionConflictError', () => { it('calls a conversation with an admitted writer a conflict', () => { expect(structuredAdoptionConflictError(ownership()).message).toBe('agent_session_conflict') 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 06581ae1858..8d06a282a6e 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -9,6 +9,7 @@ import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusa import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import type { AgentSessionProviderHandle, + AgentSessionProviderHandleLink, StructuredAgentId } from '../../shared/agent-session-provider-handle' import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' @@ -41,6 +42,8 @@ export type CommittedStructuredAgentSessionAdoptionReplay = { export function findCommittedStructuredAgentSessionAdoptionReplay(input: { agent: StructuredAgentId providerSessionId: string + /** The Session History row id a link of the agent's handle chain shows as. */ + rowSessionId: (link: AgentSessionProviderHandleLink) => string selfSessionId: string callerKey: string operationId: string @@ -66,7 +69,7 @@ export function findCommittedStructuredAgentSessionAdoptionReplay(input: { ) { return null } - if (adopted.handle.nativeId !== input.providerSessionId) { + if (input.rowSessionId(adopted) !== input.providerSessionId) { return null } return { record, providerHandle: adopted.handle } diff --git a/src/main/pi/pi-transcript-import.ts b/src/main/pi/pi-transcript-import.ts new file mode 100644 index 00000000000..d2db4dca167 --- /dev/null +++ b/src/main/pi/pi-transcript-import.ts @@ -0,0 +1,35 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import { sessionIdFromFileName } from '../ai-vault/session-scanner-accumulator' +import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' +import { piSessionHistorySessionsDir } from '../ai-vault/session-scanner-agent-sources' +import { directoryTranscriptImport } from '../native-chat/structured-agent-cli-conversations' +import { readPiSessionHeader } from './rpc-launch-resolution' + +export const PI_TRANSCRIPT_IMPORT = directoryTranscriptImport({ + // Session History's host source is authoritative, including when the chat chose another home. + accountHomeCandidates: () => [ + process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent') + ], + // The file Session History lists under this id. + findTranscript: async ({ providerSessionId }) => { + const files = await walkSessionFiles(piSessionHistorySessionsDir(), 'pi', [], { + extensions: new Set(['.jsonl']), + filePredicate: (path) => sessionIdFromFileName(path) === providerSessionId + }) + if (files.length !== 1) { + return null + } + const file = files[0] + if (!file) { + return null + } + const header = await readPiSessionHeader(file) + if (header?.id !== providerSessionId) { + return null + } + return file + }, + // A Pi chat's handle names its session file, not the id. + handleNamesTranscript: true +}) diff --git a/src/main/pi/rpc-launch-resolution.ts b/src/main/pi/rpc-launch-resolution.ts index aa639e5315a..6ece66fa81c 100644 --- a/src/main/pi/rpc-launch-resolution.ts +++ b/src/main/pi/rpc-launch-resolution.ts @@ -17,6 +17,7 @@ import { } from '../native-chat/structured-agent-command-resolution' import type { StructuredAgentLaunchPartResolver } from '../runtime/structured-agent-launch-composition' import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' +import { sessionIdFromFileName } from '../ai-vault/session-scanner-accumulator' import { PI_RPC_SESSION_ENV_TO_DELETE, withoutPiCallerEnv, @@ -66,9 +67,15 @@ export function piRpcVersionSupported( return probe({ program: launch.command, cwd: launch.cwd, env: launch.env }, supportsPiRpcVersion) } -const headerSchema = z.object({ type: z.literal('session'), cwd: z.string().min(1) }) +const headerSchema = z.object({ + type: z.literal('session'), + cwd: z.string().min(1), + id: z.string().optional() +}) -async function sessionDirectory(file: string): Promise { +export async function readPiSessionHeader( + file: string +): Promise<{ cwd: string; id?: string } | null> { let descriptor try { descriptor = await open(file, 'r') @@ -86,13 +93,13 @@ async function sessionDirectory(file: string): Promise { if (lf === -1) { throw new Error('Pi session header is incomplete or oversized') } - return headerSchema.parse(JSON.parse(data.slice(0, lf))).cwd + return headerSchema.parse(JSON.parse(data.slice(0, lf))) } finally { await descriptor.close() } } -async function sameDirectory(left: string, right: string): Promise { +export async function samePiDirectory(left: string, right: string): Promise { const canonical = async (value: string): Promise => realpath(value).catch(() => normalize(value)) const [a, b] = await Promise.all([canonical(left), canonical(right)]) @@ -159,10 +166,20 @@ export function piRpcLaunchPart( throw new Error('Pi resume handle is not a host session file') } const source = previous.handle.nativeId - const directory = await sessionDirectory(source) - if (directory === null) { + const header = await readPiSessionHeader(source) + if ( + previous.origin === 'adopted' && + (!header || + header.id !== sessionIdFromFileName(source) || + !(await samePiDirectory(header.cwd, cwd))) + ) { + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptNotFound' + }) + } + if (header === null) { replacement = previous.origin === 'created' ? 'unsaved' : 'restore-failed' - } else if (await sameDirectory(directory, cwd)) { + } else if (await samePiDirectory(header.cwd, cwd)) { sessionFile = source } else { forkFile = source diff --git a/src/main/pi/rpc-runtime-registration.ts b/src/main/pi/rpc-runtime-registration.ts index 4239948c65e..ed94a89eb52 100644 --- a/src/main/pi/rpc-runtime-registration.ts +++ b/src/main/pi/rpc-runtime-registration.ts @@ -20,6 +20,7 @@ import { import { PiRpcSessionAdapter } from './rpc-session-adapter' import { createPiModelCatalogProbe } from './rpc-model-catalog-probe' import { PI_SESSION_HISTORY } from './pi-session-history' +import { PI_TRANSCRIPT_IMPORT } from './pi-transcript-import' function createPiRpcAdapter(context: StructuredAgentAdapterContext): StructuredAgentRuntimeAdapter { const { deps } = context @@ -63,7 +64,7 @@ export const PI_RPC_RUNTIME_REGISTRATION: StructuredAgentRuntimeRegistration = { : {}) }) }), - transcriptImport: null, + transcriptImport: PI_TRANSCRIPT_IMPORT, sessionHistory: PI_SESSION_HISTORY, supportsLocation: supportsSupervisedProviderChildLocation, launchCheck: { 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 42593b34fc8..c4ad2e6c116 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,6 +26,7 @@ import { import { parseWorkspaceKey } from '../../shared/workspace-scope' import type { AgentSessionAccountHome } from '../../shared/agent-session-account-home' import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' +import { readPiSessionHeader, samePiDirectory } from '../pi/rpc-launch-resolution' export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithGetWorktreePs { async getStructuredAgentSessionCreateSupport( @@ -239,6 +240,15 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR workspaceCwd: (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path }) : null + if (input.agent === 'pi' && adoption) { + const header = await readPiSessionHeader(adoption.transcriptPath) + const targetDirectory = (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path + if (!header || !(await samePiDirectory(header.cwd, targetDirectory))) { + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptNotFound' + }) + } + } return { envelope: { sessionId: input.envelope.sessionId, @@ -260,7 +270,9 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR providerHandle: { transport: definition.handleTransport, agent: definition.agent, - nativeId: input.resumeFrom.providerSessionId + nativeId: transcriptImport.handleNamesTranscript + ? adoption.transcriptPath + : input.resumeFrom.providerSessionId }, transcriptPath: adoption.transcriptPath } diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index 27a986a51f7..8b87429e894 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -398,7 +398,8 @@ describe('adopting a conversation recorded outside Orca', () => { runtime.resolveStructuredAgentSessionCreateIntent({ envelope: { sessionId: 'session-1', clientOperationId: 'operation-1' }, worktree: 'id:workspace-1', - agent: 'pi', + // Every registered agent adopts; an unregistered one has no importer. + agent: 'hermes', resumeFrom: { providerSessionId: 'provider-1' } }) ).rejects.toMatchObject({ @@ -409,4 +410,67 @@ describe('adopting a conversation recorded outside Orca', () => { }) expect(resolveStructuredAgentSessionLocation).not.toHaveBeenCalled() }) + + it('adopts the original Pi file into a folder workspace and refuses another folder', async () => { + const accountHome = await mkdtemp(join(tmpdir(), 'orca-pi-adopt-intent-')) + vi.stubEnv('PI_CODING_AGENT_DIR', accountHome) + try { + const file = join( + accountHome, + 'sessions', + '--repos-workspace-1--', + '2026-10-12T00-00-00-000Z_0198c2a4-7d1e-7c3a-9a4b-1f2e3d4c5b6a.jsonl' + ) + await mkdir(dirname(file), { recursive: true }) + await writeFile( + file, + `${JSON.stringify({ type: 'session', id: '0198c2a4-7d1e-7c3a-9a4b-1f2e3d4c5b6a', cwd: '/repos/workspace-1' })}\n` + ) + const runtime = new OrcaRuntimeService( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create intent only reads getSettings from the store. + { + getSettings: () => ({ agentDefaultEnv: { pi: { PI_CODING_AGENT_DIR: accountHome } } }) + } as never + ) + vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({ + supported: true + }) + let workspaceDirectory = '/repos/workspace-1' + Object.assign(runtime, { + resolveStructuredAgentSessionLocation: vi.fn(async () => ({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' as const + })), + resolveRuntimeFileTarget: vi.fn(async () => ({ worktree: { path: workspaceDirectory } })) + }) + + const intent = await runtime.resolveStructuredAgentSessionCreateIntent({ + envelope: { sessionId: 'session-1', clientOperationId: 'operation-1' }, + worktree: 'id:workspace-1', + agent: 'pi', + resumeFrom: { providerSessionId: '0198c2a4-7d1e-7c3a-9a4b-1f2e3d4c5b6a' } + }) + + expect(intent.accountHome).toEqual({ variable: 'PI_CODING_AGENT_DIR', path: accountHome }) + expect(intent.adopt).toEqual({ + providerHandle: { transport: 'jsonl-rpc', agent: 'pi', nativeId: file }, + transcriptPath: file + }) + + workspaceDirectory = '/different-folder' + await expect( + runtime.resolveStructuredAgentSessionCreateIntent({ + envelope: { sessionId: 'session-2', clientOperationId: 'operation-2' }, + worktree: 'id:workspace-1', + agent: 'pi', + resumeFrom: { providerSessionId: '0198c2a4-7d1e-7c3a-9a4b-1f2e3d4c5b6a' } + }) + ).rejects.toMatchObject({ refusal: { code: 'agent_session_identity_required' } }) + } finally { + vi.unstubAllEnvs() + await rm(accountHome, { recursive: true, force: true }) + } + }) }) diff --git a/src/main/runtime/structured-agent-session-create-adoption.ts b/src/main/runtime/structured-agent-session-create-adoption.ts index 80ed28fd402..627da4a842b 100644 --- a/src/main/runtime/structured-agent-session-create-adoption.ts +++ b/src/main/runtime/structured-agent-session-create-adoption.ts @@ -11,6 +11,7 @@ import { resolveStructuredAgentSessionAdoption, structuredAdoptionConflictError } from '../native-chat/structured-agent-session-history-adoption' +import { structuredAgentRuntimeRegistration } from './structured-agent-runtime-registrations' import type { StructuredAgentTranscriptImport, StructuredAgentTranscriptImportSettings @@ -25,11 +26,13 @@ export function resolveCommittedStructuredAgentSessionAdoptionIntent(input: { location: AgentSessionExecutionLocation options?: Readonly> }): AgentSessionAttachParams | null { + const history = structuredAgentRuntimeRegistration(input.agent)?.sessionHistory const replay = - input.resumeFrom && input.callerKey && input.host + input.resumeFrom && input.callerKey && input.host && history ? findCommittedStructuredAgentSessionAdoptionReplay({ agent: input.agent, providerSessionId: input.resumeFrom.providerSessionId, + rowSessionId: history.rowSessionId, selfSessionId: input.envelope.sessionId, callerKey: input.callerKey, operationId: input.envelope.clientOperationId, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts index 4a5584f53c6..79cdeae3bc7 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts @@ -40,7 +40,7 @@ describe('resolveAiVaultSessionResumeInChatEligibility', () => { expect(eligibility()).toEqual({ available: true, workspaceId: 'repo-1::/repo/orca' }) }) - it.each(['grok', 'omp', 'opencode', 'opencode2'])( + it.each(['grok', 'omp', 'opencode', 'opencode2', 'pi'])( 'offers the chat for a local %s row in its own workspace', (agent) => { expect(eligibility({ session: session({ agent }) })).toEqual({ @@ -50,7 +50,7 @@ describe('resolveAiVaultSessionResumeInChatEligibility', () => { } ) - it.each(['hermes', 'pi'])( + it.each(['hermes', 'gemini'])( 'refuses %s, whose chats cannot take over a CLI conversation', (agent) => { expect(eligibility({ session: session({ agent }) })).toEqual({ @@ -124,7 +124,7 @@ describe('resolveAiVaultSessionResumeInChatEligibility', () => { }) describe('workspace matching, which binds the agents that store conversations per folder', () => { - it.each(['claude', 'grok', 'omp', 'opencode', 'opencode2'])( + it.each(['claude', 'grok', 'omp', 'opencode', 'opencode2', 'pi'])( 'refuses a %s row whose conversation was recorded in another workspace', (agent) => { // These key conversations by launch cwd, so resuming elsewhere finds or loads nothing. diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts index 5c060999cf4..489715d1d88 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts @@ -31,7 +31,8 @@ export type AiVaultResumeInChatEligibility = * Codex is handed the rollout file and a cwd, so it can resume into any workspace. Claude, Grok and * OMP store conversations under a folder derived from the launch cwd, so resuming from a workspace * other than the one the conversation was recorded in looks in a folder the conversation is not in, - * and OpenCode refuses a conversation that does not belong to the launch cwd. + * OpenCode refuses a conversation that does not belong to the launch cwd, and a Pi chat forks a + * conversation recorded elsewhere into a copy instead of taking it over. * That is a resume that silently yields nothing, which is worse than a disabled affordance. */ export function aiVaultSessionResumeInChatWorkspaceMatters( @@ -42,7 +43,8 @@ export function aiVaultSessionResumeInChatWorkspaceMatters( agent === 'grok' || agent === 'omp' || agent === 'opencode' || - agent === 'opencode2' + agent === 'opencode2' || + agent === 'pi' ) } 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 ea3483d956e..6b7ab2ce318 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts @@ -46,7 +46,7 @@ vi.mock('@/runtime/local-runtime-capabilities', async (importOriginal) => ({ vi.mock('@/runtime/host-structured-agents', async (importOriginal) => ({ ...(await importOriginal>()), readHostStructuredAgents: () => - ['claude', 'codex', 'grok', 'omp', 'opencode'].map((agent) => ({ agent, capabilities: {} })) + ['claude', 'codex', 'grok', 'omp', 'opencode', 'pi'].map((agent) => ({ agent, capabilities: {} })) })) vi.mock('@/lib/renderer-app-platform', () => ({ getRendererAppPlatform: () => mocks.platform })) @@ -287,7 +287,7 @@ describe('resolveTabSessionHistorySubject', () => { ).toEqual(cliSubject) }) - it.each(['grok', 'omp', 'opencode', 'opencode2'] as const)( + it.each(['grok', 'omp', 'opencode', 'opencode2', 'pi'] as const)( 'finds a %s terminal tab, whose conversation a chat can take over', (agentType) => { const state = makeState({ @@ -305,7 +305,7 @@ describe('resolveTabSessionHistorySubject', () => { } ) - it.each(['pi'] as const)( + it.each(['gemini'] as const)( 'skips a %s terminal tab, whose conversation no chat can take over', (agentType) => { const state = makeState({ @@ -404,7 +404,7 @@ describe('one pane of a terminal tab', () => { expect(paneGate(state, geminiPane)).toBe(false) }) - it.each(['grok', 'omp', 'opencode', 'opencode2'] as const)( + it.each(['grok', 'omp', 'opencode', 'opencode2', 'pi'] as const)( "finds a %s pane's own conversation", (agentType) => { const paneKey = 'term-1:leaf-1' diff --git a/src/shared/structured-agent-cli-conversation-adoption.ts b/src/shared/structured-agent-cli-conversation-adoption.ts index 63fdc0e9bd4..04877b6e588 100644 --- a/src/shared/structured-agent-cli-conversation-adoption.ts +++ b/src/shared/structured-agent-cli-conversation-adoption.ts @@ -12,7 +12,8 @@ export const CLI_CONVERSATION_ADOPTING_AGENTS = { omp: 'omp', opencode: 'opencode', // OpenCode 2.x lists its conversations as `opencode2` rows; an OpenCode chat runs either line. - opencode2: 'opencode' + opencode2: 'opencode', + pi: 'pi' } as const /** A Session History row agent whose conversations a chat can take over. */