mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
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<string>()
|
||||
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)]
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
]
|
||||
@@ -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.')
|
||||
})
|
||||
})
|
||||
+23
-6
@@ -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`)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>> = {
|
||||
const RECORDED: Record<'grok' | 'omp' | 'pi', (home: string, text: string) => Promise<string>> = {
|
||||
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<s
|
||||
.join('\n')
|
||||
)
|
||||
return file
|
||||
},
|
||||
// Pi's documented format: a system message carries the prompt in `sections`, its content empty.
|
||||
pi: async (home, text) => {
|
||||
const dir = join(home, 'sessions', '--repo-orca--')
|
||||
await mkdir(dir, { recursive: true })
|
||||
const file = join(dir, `2026-10-12T00-00-00-000Z_${SESSION_ID}.jsonl`)
|
||||
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' } }
|
||||
|
||||
@@ -30,6 +30,8 @@ export type StructuredAgentTranscriptImport = {
|
||||
launchEnv: NodeJS.ProcessEnv
|
||||
workspaceCwd: string
|
||||
}) => Promise<string | null>
|
||||
/** 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<string | null>
|
||||
handleNamesTranscript?: true
|
||||
}): StructuredAgentTranscriptImport {
|
||||
return {
|
||||
...(directories.handleNamesTranscript ? { handleNamesTranscript: true } : {}),
|
||||
accountHomeCandidates: ({ settings, selectedAccountHome }) =>
|
||||
isLegacyAgentSessionAccountHome(selectedAccountHome)
|
||||
? directories
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
})
|
||||
@@ -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<string | null> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
export async function samePiDirectory(left: string, right: string): Promise<boolean> {
|
||||
const canonical = async (value: string): Promise<string> =>
|
||||
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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<Record<string, string>>
|
||||
}): 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,
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('resolveAiVaultSessionResumeInChatEligibility', () => {
|
||||
expect(eligibility()).toEqual({ available: true, workspaceId: 'repo-1::/repo/orca' })
|
||||
})
|
||||
|
||||
it.each<AiVaultSession['agent']>(['grok', 'omp', 'opencode', 'opencode2'])(
|
||||
it.each<AiVaultSession['agent']>(['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<AiVaultSession['agent']>(['hermes', 'pi'])(
|
||||
it.each<AiVaultSession['agent']>(['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<AiVaultSession['agent']>(['claude', 'grok', 'omp', 'opencode', 'opencode2'])(
|
||||
it.each<AiVaultSession['agent']>(['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.
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ vi.mock('@/runtime/local-runtime-capabilities', async (importOriginal) => ({
|
||||
vi.mock('@/runtime/host-structured-agents', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
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'
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user