diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index a27cdf22b..305f055cd 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -779,6 +779,85 @@ describe("PiRpcAgentClient", () => { ]); }); + test("imports JSONL sessions with the recorded model and thinking level", async () => { + const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-import-config-")); + const cwd = path.join(root, "workspace"); + const sessionsDir = path.join(root, "sessions"); + mkdirSync(sessionsDir, { recursive: true }); + const sessionFile = path.join(sessionsDir, "20260103_session.jsonl"); + writeFileSync( + sessionFile, + [ + JSON.stringify({ + type: "session", + version: 3, + id: "pi-import-session", + timestamp: "2026-01-03T00:00:00.000Z", + cwd, + }), + JSON.stringify({ + type: "message", + id: "entry-1", + timestamp: "2026-01-03T00:00:01.000Z", + message: { role: "user", content: "first prompt" }, + }), + JSON.stringify({ + type: "model_change", + id: "model-1", + timestamp: "2026-01-03T00:00:02.000Z", + provider: "openrouter", + modelId: "anthropic/claude-sonnet-4.5", + }), + JSON.stringify({ + type: "thinking_level_change", + id: "thinking-1", + timestamp: "2026-01-03T00:00:03.000Z", + thinkingLevel: "high", + }), + ].join("\n") + "\n", + "utf8", + ); + const pi = new FakePi(); + const client = new PiRpcAgentClient({ + logger: pino({ level: "silent" }), + runtime: pi, + providerParams: { sessionDir: sessionsDir }, + }); + + const imported = await client.importSession( + { providerHandleId: sessionFile, cwd }, + { config: createConfig({ cwd }), storedConfig: createConfig({ cwd }) }, + ); + + const actualLaunch = pi.recordedLaunches[0]!; + expect(actualLaunch.extensionPaths).toHaveLength(1); + expect(actualLaunch.argv).toEqual([ + "pi", + "--mode", + "rpc", + "--model", + "openrouter/anthropic/claude-sonnet-4.5", + "--thinking", + "high", + "--session", + sessionFile, + "--extension", + actualLaunch.extensionPaths[0], + ]); + expect(imported.config).toMatchObject({ + provider: "pi", + cwd, + model: "openrouter/anthropic/claude-sonnet-4.5", + thinkingOptionId: "high", + }); + expect(imported.persistence.metadata).toMatchObject({ + provider: "pi", + cwd, + model: "openrouter/anthropic/claude-sonnet-4.5", + thinkingOptionId: "high", + }); + }); + test("lists models from a short-lived Pi session in the requested cwd", async () => { const pi = new FakePi(); const client = createClient(pi); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 0be7988ca..27ce7b445 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -57,7 +57,7 @@ import { } from "./history-mapper.js"; import { PiCliRuntime } from "./cli-runtime.js"; import { revertPiConversation } from "./rewind.js"; -import { listPiImportableSessions } from "./session-descriptor.js"; +import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js"; import type { PiRuntime, PiRuntimeSession } from "./runtime.js"; import type { PiAgentSessionEvent, @@ -1984,11 +1984,13 @@ export class PiRpcAgentClient implements AgentClient { } async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { + const importConfig = await readPiImportSessionConfig(input.providerHandleId); return importSessionFromPersistence({ provider: PI_PROVIDER, request: input, context, resumeSession: this.resumeSession.bind(this), + config: importConfig, }); } diff --git a/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts b/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts new file mode 100644 index 000000000..575f59392 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts @@ -0,0 +1,172 @@ +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; + +import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js"; + +async function writeSession(root: string, lines: unknown[]): Promise { + const sessionsDir = path.join(root, "sessions", "project"); + await mkdir(sessionsDir, { recursive: true }); + const filePath = path.join(sessionsDir, "2026-06-09T00-00-00-000Z_session.jsonl"); + await writeFile(filePath, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8"); + return filePath; +} + +test("Pi import config preserves the latest recorded model and thinking level", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-model-")); + const cwd = path.join(root, "repo"); + const sessionFile = await writeSession(root, [ + { + type: "session", + version: 3, + id: "session-1", + timestamp: "2026-06-09T00:00:00.000Z", + cwd, + }, + { + type: "model_change", + id: "model-1", + timestamp: "2026-06-09T00:00:01.000Z", + provider: "openai-codex", + modelId: "gpt-5.1", + }, + { + type: "thinking_level_change", + id: "thinking-1", + timestamp: "2026-06-09T00:00:01.500Z", + thinkingLevel: "low", + }, + { + type: "message", + id: "user-1", + timestamp: "2026-06-09T00:00:02.000Z", + message: { + role: "user", + content: [{ type: "text", text: "hello" }], + }, + }, + { + type: "model_change", + id: "model-2", + timestamp: "2026-06-09T00:00:03.000Z", + provider: "openrouter", + modelId: "anthropic/claude-sonnet-4.5", + }, + { + type: "thinking_level_change", + id: "thinking-2", + timestamp: "2026-06-09T00:00:04.000Z", + thinkingLevel: "high", + }, + ]); + + const [descriptor] = await listPiImportableSessions({ sessionDir: path.join(root, "sessions") }); + const importConfig = await readPiImportSessionConfig(sessionFile); + + expect(descriptor).toMatchObject({ + providerHandleId: sessionFile, + cwd, + firstPromptPreview: "hello", + }); + expect(importConfig).toEqual({ + model: "openrouter/anthropic/claude-sonnet-4.5", + thinkingOptionId: "high", + }); +}); + +test("Pi import config can infer model from assistant messages", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-message-model-")); + const cwd = path.join(root, "repo"); + const sessionFile = await writeSession(root, [ + { + type: "session", + version: 3, + id: "session-2", + timestamp: "2026-06-09T00:00:00.000Z", + cwd, + }, + { + type: "message", + id: "user-1", + timestamp: "2026-06-09T00:00:01.000Z", + message: { + role: "user", + content: [{ type: "text", text: "hello" }], + }, + }, + { + type: "message", + id: "assistant-1", + timestamp: "2026-06-09T00:00:02.000Z", + message: { + role: "assistant", + content: [{ type: "text", text: "hi" }], + provider: "google", + model: "gemini-2.5-pro", + }, + }, + ]); + + const importConfig = await readPiImportSessionConfig(sessionFile); + + expect(importConfig).toEqual({ + model: "google/gemini-2.5-pro", + }); +}); + +test("Pi import config preserves thinking before a later model in large sessions", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-large-thinking-")); + const cwd = path.join(root, "repo"); + const fillerMessages = Array.from({ length: 2_100 }, (_, index) => ({ + type: "message", + id: `filler-${index}`, + timestamp: `2026-06-09T00:${String(Math.floor(index / 60)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}.000Z`, + message: { + role: "assistant", + content: [{ type: "text", text: `filler ${index}` }], + }, + })); + const sessionFile = await writeSession(root, [ + { + type: "session", + version: 3, + id: "session-3", + timestamp: "2026-06-09T00:00:00.000Z", + cwd, + }, + { + type: "message", + id: "user-1", + timestamp: "2026-06-09T00:00:01.000Z", + message: { role: "user", content: "hello" }, + }, + ...fillerMessages, + { + type: "thinking_level_change", + id: "thinking-1", + timestamp: "2026-06-09T01:00:00.000Z", + thinkingLevel: "low", + }, + { + type: "model_change", + id: "model-1", + timestamp: "2026-06-09T01:00:01.000Z", + provider: "openrouter", + modelId: "google/gemini-2.5-pro", + }, + { + type: "session_info", + id: "info-1", + timestamp: "2026-06-09T01:00:02.000Z", + name: "large session", + }, + ]); + + const importConfig = await readPiImportSessionConfig(sessionFile); + + expect(importConfig).toEqual({ + model: "openrouter/google/gemini-2.5-pro", + thinkingOptionId: "low", + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/session-descriptor.ts b/packages/server/src/server/agent/providers/pi/session-descriptor.ts index deafe54f9..8f37e4861 100644 --- a/packages/server/src/server/agent/providers/pi/session-descriptor.ts +++ b/packages/server/src/server/agent/providers/pi/session-descriptor.ts @@ -33,6 +33,30 @@ interface PiSessionTail { title: string | null; lastActivityAt: Date | null; lastUserMessage: string | null; + model: string | null; + thinkingOptionId: string | null; +} + +interface PiSessionHead { + title: string | null; + firstUserMessage: string | null; + model: string | null; + thinkingOptionId: string | null; +} + +interface PiSessionDescriptor { + cwd: string; + title: string | null; + firstUserMessage: string | null; + lastUserMessage: string | null; + lastActivityAt: Date; + model: string | null; + thinkingOptionId: string | null; +} + +export interface PiImportSessionConfig { + model?: string; + thinkingOptionId?: string; } export async function listPiImportableSessions( @@ -56,6 +80,12 @@ export async function listPiImportableSessions( .slice(0, limit); } +export async function readPiImportSessionConfig(filePath: string): Promise { + const descriptor = await readPiSessionDescriptor(filePath); + if (!descriptor) return {}; + return toPiImportSessionConfig(descriptor); +} + async function resolvePiSessionsDir(options: PiSessionDescriptorOptions): Promise { const env = options.env ?? process.env; const homeDir = options.homeDir ?? homedir(); @@ -155,6 +185,22 @@ async function walkJsonlFiles(root: string): Promise { async function readPiImportableSession( filePath: string, ): Promise { + const descriptor = await readPiSessionDescriptor(filePath); + if (!descriptor) return null; + + return { + providerHandleId: filePath, + cwd: descriptor.cwd, + title: descriptor.title, + firstPromptPreview: normalizePromptPreview(descriptor.firstUserMessage), + lastPromptPreview: normalizePromptPreview( + descriptor.lastUserMessage ?? descriptor.firstUserMessage, + ), + lastActivityAt: descriptor.lastActivityAt, + }; +} + +async function readPiSessionDescriptor(filePath: string): Promise { const firstLine = await readFirstLine(filePath); if (!firstLine) return null; const header = parseSessionHeader(firstLine); @@ -164,17 +210,26 @@ async function readPiImportableSession( const tailInfo = parseSessionTail(tail); const headInfo = await scanSessionHead(filePath); const title = tailInfo.title ?? headInfo.title ?? headInfo.firstUserMessage; + const model = tailInfo.model ?? headInfo.model; + const thinkingOptionId = tailInfo.thinkingOptionId ?? headInfo.thinkingOptionId; const lastActivityAt = tailInfo.lastActivityAt ?? (await readFileMtime(filePath)) ?? header.createdAt ?? new Date(0); + return { - providerHandleId: filePath, cwd: header.cwd, title, - firstPromptPreview: normalizePromptPreview(headInfo.firstUserMessage), - lastPromptPreview: normalizePromptPreview( - tailInfo.lastUserMessage ?? headInfo.firstUserMessage, - ), + firstUserMessage: headInfo.firstUserMessage, + lastUserMessage: tailInfo.lastUserMessage, lastActivityAt, + model, + thinkingOptionId, + }; +} + +function toPiImportSessionConfig(descriptor: PiSessionDescriptor): PiImportSessionConfig { + return { + ...(descriptor.model ? { model: descriptor.model } : {}), + ...(descriptor.thinkingOptionId ? { thinkingOptionId: descriptor.thinkingOptionId } : {}), }; } @@ -231,6 +286,8 @@ function parseSessionTail(tail: string): PiSessionTail { let lastActivityAt: Date | null = null; let fallbackTimestamp: Date | null = null; let lastUserMessage: string | null = null; + let model: string | null = null; + let thinkingOptionId: string | null = null; for (let index = lines.length - 1; index >= 0; index -= 1) { const entry = parseJsonRecord(lines[index].trim()); @@ -240,14 +297,20 @@ function parseSessionTail(tail: string): PiSessionTail { title = readNonEmptyString(entry.name); } + if (!model) { + model = extractModel(entry); + } + + if (!thinkingOptionId) { + thinkingOptionId = extractThinkingOptionId(entry); + } + const entryTimestamp = parseDate(entry.timestamp); if (!fallbackTimestamp && entryTimestamp) { fallbackTimestamp = entryTimestamp; } - if (entry.type !== "message") { - continue; - } + if (entry.type !== "message") continue; if (!lastActivityAt && entryTimestamp) { lastActivityAt = entryTimestamp; @@ -256,28 +319,29 @@ function parseSessionTail(tail: string): PiSessionTail { if (!lastUserMessage && isRecord(entry.message) && entry.message.role === "user") { lastUserMessage = extractMessageText(entry.message.content); } - - if (title && lastActivityAt && lastUserMessage) { - break; - } } - return { title, lastActivityAt: lastActivityAt ?? fallbackTimestamp, lastUserMessage }; + return { + title, + lastActivityAt: lastActivityAt ?? fallbackTimestamp, + lastUserMessage, + model, + thinkingOptionId, + }; } -async function scanSessionHead(filePath: string): Promise<{ - title: string | null; - firstUserMessage: string | null; -}> { +async function scanSessionHead(filePath: string): Promise { let content: string; try { content = await readFile(filePath, "utf8"); } catch { - return { title: null, firstUserMessage: null }; + return { title: null, firstUserMessage: null, model: null, thinkingOptionId: null }; } let title: string | null = null; let firstUserMessage: string | null = null; + let model: string | null = null; + let thinkingOptionId: string | null = null; let lineCount = 0; for (const rawLine of content.split(/\r?\n/u)) { @@ -289,13 +353,16 @@ async function scanSessionHead(filePath: string): Promise<{ title = readNonEmptyString(entry.name) ?? title; } + model = extractModel(entry) ?? model; + thinkingOptionId = extractThinkingOptionId(entry) ?? thinkingOptionId; + if (!firstUserMessage && entry.type === "message" && isRecord(entry.message)) { if (entry.message.role === "user") { firstUserMessage = extractMessageText(entry.message.content); } } - if (title && firstUserMessage) { + if (title && firstUserMessage && model && thinkingOptionId) { break; } if (lineCount >= FULL_SCAN_LINE_LIMIT && firstUserMessage) { @@ -303,7 +370,32 @@ async function scanSessionHead(filePath: string): Promise<{ } } - return { title, firstUserMessage }; + return { title, firstUserMessage, model, thinkingOptionId }; +} + +function extractModel(entry: Record): string | null { + if (entry.type === "model_change") { + return buildModelId(entry.provider, entry.modelId); + } + + if (entry.type === "message" && isRecord(entry.message)) { + return buildModelId(entry.message.provider, entry.message.model); + } + + return null; +} + +function extractThinkingOptionId(entry: Record): string | null { + return entry.type === "thinking_level_change" ? readNonEmptyString(entry.thinkingLevel) : null; +} + +function buildModelId(provider: unknown, modelId: unknown): string | null { + const providerName = readNonEmptyString(provider); + const modelName = readNonEmptyString(modelId); + if (!providerName || !modelName) { + return null; + } + return `${providerName}/${modelName}`; } function parseJsonRecord(line: string): Record | null {