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. */