fix(pi): preserve discovered project prompts

Append Paseo instructions from the integration extension because Pi's append-system-prompt flag suppresses automatic APPEND_SYSTEM.md discovery.
This commit is contained in:
Mohamed Boudra
2026-07-21 13:44:23 +02:00
parent d0456b1943
commit 6acc82e9d3
6 changed files with 97 additions and 49 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ Paseo tools are not implemented as MCP tools internally. They live in a shared t
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages. Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions. Paseo's per-agent and daemon-wide system prompts are appended by its generated Pi integration extension. Paseo deliberately does not pass `--append-system-prompt`, because that flag replaces Pi's automatic `APPEND_SYSTEM.md` discovery instead of composing with it.
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead. Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
@@ -13,6 +13,7 @@ import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import pino from "pino"; import pino from "pino";
import { setImmediate as waitForImmediate } from "node:timers/promises"; import { setImmediate as waitForImmediate } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import { describe, expect, onTestFinished, test } from "vitest"; import { describe, expect, onTestFinished, test } from "vitest";
import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js"; import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js";
@@ -55,6 +56,26 @@ function readUtf8File(pathname: string): string {
closeSync(fd); closeSync(fd);
} }
} }
async function applyPaseoExtensionSystemPrompt(
extensionPath: string,
systemPrompt: string,
): Promise<string | undefined> {
const listeners = new Map<string, (event: { systemPrompt: string }) => unknown>();
const extension = (await import(pathToFileURL(extensionPath).href)) as {
default: (piApi: {
on: (event: string, listener: (event: { systemPrompt: string }) => unknown) => void;
registerCommand: () => void;
}) => void;
};
extension.default({
on: (event, listener) => listeners.set(event, listener),
registerCommand: () => undefined,
});
const result = await listeners.get("before_agent_start")?.({ systemPrompt });
return (result as { systemPrompt?: string } | undefined)?.systemPrompt;
}
async function flushTurnScheduling(): Promise<void> { async function flushTurnScheduling(): Promise<void> {
await waitForImmediate(); await waitForImmediate();
} }
@@ -713,11 +734,11 @@ describe("PiRpcAgentSession", () => {
]); ]);
}); });
test("creates Pi sessions with agent and daemon system prompts appended", async () => { test("appends agent and daemon prompts after Pi's discovered system prompt", async () => {
const pi = new FakePi(); const pi = new FakePi();
const client = createClient(pi); const client = createClient(pi);
await client.createSession( const session = await client.createSession(
createConfig({ createConfig({
systemPrompt: "Agent prompt", systemPrompt: "Agent prompt",
daemonAppendSystemPrompt: "Daemon prompt", daemonAppendSystemPrompt: "Daemon prompt",
@@ -727,7 +748,6 @@ describe("PiRpcAgentSession", () => {
const actualLaunch = pi.recordedLaunches[0]!; const actualLaunch = pi.recordedLaunches[0]!;
expect(actualLaunch).toMatchObject({ expect(actualLaunch).toMatchObject({
cwd: "/tmp/paseo-pi-rpc-test", cwd: "/tmp/paseo-pi-rpc-test",
systemPrompt: "Agent prompt\n\nDaemon prompt",
}); });
expect(actualLaunch.extensionPaths).toHaveLength(1); expect(actualLaunch.extensionPaths).toHaveLength(1);
expect(actualLaunch.argv).toEqual([ expect(actualLaunch.argv).toEqual([
@@ -736,11 +756,15 @@ describe("PiRpcAgentSession", () => {
"rpc", "rpc",
"--thinking", "--thinking",
"medium", "medium",
"--append-system-prompt",
"Agent prompt\n\nDaemon prompt",
"--extension", "--extension",
actualLaunch.extensionPaths[0], actualLaunch.extensionPaths[0],
]); ]);
await expect(
applyPaseoExtensionSystemPrompt(actualLaunch.extensionPaths[0]!, "Pi project prompt"),
).resolves.toBe("Pi project prompt\n\nAgent prompt\n\nDaemon prompt");
await session.close();
}); });
test("resumes Pi sessions with daemon system prompts appended", async () => { test("resumes Pi sessions with daemon system prompts appended", async () => {
@@ -769,7 +793,6 @@ describe("PiRpcAgentSession", () => {
expect(actualLaunch).toMatchObject({ expect(actualLaunch).toMatchObject({
cwd: "/workspace/project", cwd: "/workspace/project",
session: "/tmp/native-pi-session", session: "/tmp/native-pi-session",
systemPrompt: "Agent prompt\n\nDaemon prompt",
}); });
expect(actualLaunch.extensionPaths).toHaveLength(1); expect(actualLaunch.extensionPaths).toHaveLength(1);
expect(actualLaunch.argv).toEqual([ expect(actualLaunch.argv).toEqual([
@@ -782,11 +805,12 @@ describe("PiRpcAgentSession", () => {
"high", "high",
"--session", "--session",
"/tmp/native-pi-session", "/tmp/native-pi-session",
"--append-system-prompt",
"Agent prompt\n\nDaemon prompt",
"--extension", "--extension",
actualLaunch.extensionPaths[0], actualLaunch.extensionPaths[0],
]); ]);
await expect(
applyPaseoExtensionSystemPrompt(actualLaunch.extensionPaths[0]!, "Pi project prompt"),
).resolves.toBe("Pi project prompt\n\nAgent prompt\n\nDaemon prompt");
}); });
test("updates model and thinking through Pi runtime commands", async () => { test("updates model and thinking through Pi runtime commands", async () => {
@@ -535,10 +535,6 @@ function buildResumeStartInput(input: {
session: input.sessionFile, session: input.sessionFile,
model: input.resumeConfig.model, model: input.resumeConfig.model,
thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined, thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined,
systemPrompt: composeSystemPromptParts(
input.resumeConfig.config.systemPrompt,
input.resumeConfig.config.daemonAppendSystemPrompt,
),
mcpConfigPath: input.mcpConfig?.path, mcpConfigPath: input.mcpConfig?.path,
extensionPaths: input.paseoExtension ? [input.paseoExtension.path] : undefined, extensionPaths: input.paseoExtension ? [input.paseoExtension.path] : undefined,
}; };
@@ -630,7 +626,7 @@ function createPiMcpConfigFile(
}; };
} }
function createPiPaseoExtensionFile(): PiTempFile { function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile {
const dir = mkdtempSync(join(tmpdir(), "paseo-pi-extension-")); const dir = mkdtempSync(join(tmpdir(), "paseo-pi-extension-"));
const filePath = join(dir, "paseo-integration.mjs"); const filePath = join(dir, "paseo-integration.mjs");
writeFileSync( writeFileSync(
@@ -680,6 +676,14 @@ function createPiPaseoExtensionFile(): PiTempFile {
} }
export default function paseoIntegration(pi) { export default function paseoIntegration(pi) {
${
systemPrompt
? `pi.on("before_agent_start", async (event) => ({
systemPrompt: event.systemPrompt + "\\n\\n" + ${JSON.stringify(systemPrompt)},
}));`
: ""
}
pi.on("session_start", async (_event, ctx) => { pi.on("session_start", async (_event, ctx) => {
emitEntryCapture(ctx, "session_start"); emitEntryCapture(ctx, "session_start");
}); });
@@ -2307,7 +2311,9 @@ export class PiRpcAgentClient implements AgentClient {
...launchContext?.env, ...launchContext?.env,
}; };
const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers, mcpEnv); const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers, mcpEnv);
const paseoExtension = createPiPaseoExtensionFile(); const paseoExtension = createPiPaseoExtensionFile(
composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt),
);
let runtimeSession: PiRuntimeSession; let runtimeSession: PiRuntimeSession;
try { try {
runtimeSession = await this.runtime.startSession({ runtimeSession = await this.runtime.startSession({
@@ -2316,10 +2322,6 @@ export class PiRpcAgentClient implements AgentClient {
thinkingOptionId: thinkingOptionId:
normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL, normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL,
noSession: config.internal === true, noSession: config.internal === true,
systemPrompt: composeSystemPromptParts(
config.systemPrompt,
config.daemonAppendSystemPrompt,
),
env: launchContext?.env, env: launchContext?.env,
mcpConfigPath: mcpConfig?.path, mcpConfigPath: mcpConfig?.path,
extensionPaths: paseoExtension ? [paseoExtension.path] : undefined, extensionPaths: paseoExtension ? [paseoExtension.path] : undefined,
@@ -2368,7 +2370,12 @@ export class PiRpcAgentClient implements AgentClient {
resumeConfig.config.mcpServers, resumeConfig.config.mcpServers,
mcpEnv, mcpEnv,
); );
const paseoExtension = createPiPaseoExtensionFile(); const paseoExtension = createPiPaseoExtensionFile(
composeSystemPromptParts(
resumeConfig.config.systemPrompt,
resumeConfig.config.daemonAppendSystemPrompt,
),
);
let runtimeSession: PiRuntimeSession; let runtimeSession: PiRuntimeSession;
try { try {
runtimeSession = await this.runtime.startSession( runtimeSession = await this.runtime.startSession(
@@ -229,26 +229,6 @@ describe("PiCliRuntime", () => {
]); ]);
}); });
test("passes an appended system prompt to Pi", async () => {
const child = createPiChild();
replyToCommands(child, () => ({}));
const launches: PiRuntimeLaunch[] = [];
const runtime = createRuntime(child, launches);
await runtime.startSession({
cwd: "/workspace/project",
systemPrompt: " Use the daemon prompt. ",
});
expect(launches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
systemPrompt: "Use the daemon prompt.",
argv: ["pi", "--mode", "rpc", "--append-system-prompt", "Use the daemon prompt."],
}),
]);
});
test("delivers events separately from command responses", async () => { test("delivers events separately from command responses", async () => {
const child = createPiChild(); const child = createPiChild();
replyToCommands(child, () => ({ models: [] })); replyToCommands(child, () => ({ models: [] }));
@@ -19,7 +19,6 @@ export interface PiRuntimeLaunch {
modeId?: string; modeId?: string;
session?: string; session?: string;
noSession?: boolean; noSession?: boolean;
systemPrompt?: string;
mcpConfigPath?: string; mcpConfigPath?: string;
extensionPaths?: string[]; extensionPaths?: string[];
extraArgs?: string[]; extraArgs?: string[];
@@ -34,7 +33,6 @@ export interface PiStartSessionInput {
modeId?: string; modeId?: string;
session?: string; session?: string;
noSession?: boolean; noSession?: boolean;
systemPrompt?: string;
mcpConfigPath?: string; mcpConfigPath?: string;
extensionPaths?: string[]; extensionPaths?: string[];
extraArgs?: string[]; extraArgs?: string[];
@@ -85,8 +83,7 @@ export function buildPiLaunch(input: {
const argv = [...command]; const argv = [...command];
const protocolMode = input.session.protocolMode ?? "rpc"; const protocolMode = input.session.protocolMode ?? "rpc";
const systemPrompt = input.session.systemPrompt?.trim(); appendPiLaunchArgs(argv, input.session, protocolMode);
appendPiLaunchArgs(argv, input.session, protocolMode, systemPrompt);
return { return {
cwd: input.session.cwd, cwd: input.session.cwd,
@@ -104,7 +101,6 @@ export function buildPiLaunch(input: {
modeId: input.session.modeId, modeId: input.session.modeId,
session: input.session.session, session: input.session.session,
noSession: input.session.noSession, noSession: input.session.noSession,
systemPrompt,
mcpConfigPath: input.session.mcpConfigPath, mcpConfigPath: input.session.mcpConfigPath,
extensionPaths: input.session.extensionPaths, extensionPaths: input.session.extensionPaths,
extraArgs: input.session.extraArgs, extraArgs: input.session.extraArgs,
@@ -115,7 +111,6 @@ function appendPiLaunchArgs(
argv: string[], argv: string[],
session: PiStartSessionInput, session: PiStartSessionInput,
protocolMode: "rpc" | "rpc-ui", protocolMode: "rpc" | "rpc-ui",
systemPrompt: string | undefined,
): void { ): void {
if (!hasModeFlag(argv)) { if (!hasModeFlag(argv)) {
argv.push("--mode", protocolMode); argv.push("--mode", protocolMode);
@@ -134,9 +129,6 @@ function appendPiLaunchArgs(
} else if (session.session) { } else if (session.session) {
argv.push("--session", session.session); argv.push("--session", session.session);
} }
if (systemPrompt) {
argv.push("--append-system-prompt", systemPrompt);
}
if (session.mcpConfigPath) { if (session.mcpConfigPath) {
argv.push("--mcp-config", session.mcpConfigPath); argv.push("--mcp-config", session.mcpConfigPath);
} }
@@ -139,6 +139,51 @@ beforeEach((context) => {
} }
}); });
test(
"real Pi daemon composes project and Paseo system prompts",
async () => {
const cwd = tmpCwd("pi-system-prompts-");
try {
mkdirSync(path.join(cwd, ".pi"), { recursive: true });
writeFileSync(
path.join(cwd, ".pi", "APPEND_SYSTEM.md"),
[
"When the user says PASEO_SYSTEM_PROMPT_PROBE, reply with exactly two tokens:",
"PROJECT_PROMPT followed by the value of PASEO_PROMPT_TOKEN from later system instructions.",
"If no PASEO_PROMPT_TOKEN exists, use MISSING as the second token.",
].join("\n"),
);
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-system-prompts",
provider: "pi",
model: PI_REAL_TEST_MODEL,
systemPrompt:
"PASEO_PROMPT_TOKEN is PASEO_PROMPT. Follow the project instruction for PASEO_SYSTEM_PROMPT_PROBE.",
});
await client.sendMessage(agent.id, "PASEO_SYSTEM_PROMPT_PROBE");
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, agent.id);
const response = items
.filter((item) => item.type === "assistant_message")
.map((item) => item.text)
.join("")
.trim();
expect(response).toBe("PROJECT_PROMPT PASEO_PROMPT");
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test( test(
"real Pi daemon lists Paseo-handled compact slash commands", "real Pi daemon lists Paseo-handled compact slash commands",
async () => { async () => {