Preserve Pi import model and thinking (#1441)
* Preserve Pi import model and thinking * Format Pi import config helper * Preserve Pi thinking in large imports --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
8e6455f1d6
commit
5ea586a926
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string> {
|
||||
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",
|
||||
});
|
||||
});
|
||||
@@ -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<PiImportSessionConfig> {
|
||||
const descriptor = await readPiSessionDescriptor(filePath);
|
||||
if (!descriptor) return {};
|
||||
return toPiImportSessionConfig(descriptor);
|
||||
}
|
||||
|
||||
async function resolvePiSessionsDir(options: PiSessionDescriptorOptions): Promise<string> {
|
||||
const env = options.env ?? process.env;
|
||||
const homeDir = options.homeDir ?? homedir();
|
||||
@@ -155,6 +185,22 @@ async function walkJsonlFiles(root: string): Promise<string[]> {
|
||||
async function readPiImportableSession(
|
||||
filePath: string,
|
||||
): Promise<ImportableProviderSession | null> {
|
||||
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<PiSessionDescriptor | null> {
|
||||
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<PiSessionHead> {
|
||||
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, unknown>): 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, unknown>): 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<string, unknown> | null {
|
||||
|
||||
Reference in New Issue
Block a user