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:42:44 +02:00
parent d0456b1943
commit 6acc82e9d3
6 changed files with 97 additions and 49 deletions

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

View File

@@ -13,6 +13,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { setImmediate as waitForImmediate } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import { describe, expect, onTestFinished, test } from "vitest";
import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js";
@@ -55,6 +56,26 @@ function readUtf8File(pathname: string): string {
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> {
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 client = createClient(pi);
await client.createSession(
const session = await client.createSession(
createConfig({
systemPrompt: "Agent prompt",
daemonAppendSystemPrompt: "Daemon prompt",
@@ -727,7 +748,6 @@ describe("PiRpcAgentSession", () => {
const actualLaunch = pi.recordedLaunches[0]!;
expect(actualLaunch).toMatchObject({
cwd: "/tmp/paseo-pi-rpc-test",
systemPrompt: "Agent prompt\n\nDaemon prompt",
});
expect(actualLaunch.extensionPaths).toHaveLength(1);
expect(actualLaunch.argv).toEqual([
@@ -736,11 +756,15 @@ describe("PiRpcAgentSession", () => {
"rpc",
"--thinking",
"medium",
"--append-system-prompt",
"Agent prompt\n\nDaemon prompt",
"--extension",
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 () => {
@@ -769,7 +793,6 @@ describe("PiRpcAgentSession", () => {
expect(actualLaunch).toMatchObject({
cwd: "/workspace/project",
session: "/tmp/native-pi-session",
systemPrompt: "Agent prompt\n\nDaemon prompt",
});
expect(actualLaunch.extensionPaths).toHaveLength(1);
expect(actualLaunch.argv).toEqual([
@@ -782,11 +805,12 @@ describe("PiRpcAgentSession", () => {
"high",
"--session",
"/tmp/native-pi-session",
"--append-system-prompt",
"Agent prompt\n\nDaemon prompt",
"--extension",
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 () => {

View File

@@ -535,10 +535,6 @@ function buildResumeStartInput(input: {
session: input.sessionFile,
model: input.resumeConfig.model,
thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined,
systemPrompt: composeSystemPromptParts(
input.resumeConfig.config.systemPrompt,
input.resumeConfig.config.daemonAppendSystemPrompt,
),
mcpConfigPath: input.mcpConfig?.path,
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 filePath = join(dir, "paseo-integration.mjs");
writeFileSync(
@@ -680,6 +676,14 @@ function createPiPaseoExtensionFile(): PiTempFile {
}
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) => {
emitEntryCapture(ctx, "session_start");
});
@@ -2307,7 +2311,9 @@ export class PiRpcAgentClient implements AgentClient {
...launchContext?.env,
};
const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers, mcpEnv);
const paseoExtension = createPiPaseoExtensionFile();
const paseoExtension = createPiPaseoExtensionFile(
composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt),
);
let runtimeSession: PiRuntimeSession;
try {
runtimeSession = await this.runtime.startSession({
@@ -2316,10 +2322,6 @@ export class PiRpcAgentClient implements AgentClient {
thinkingOptionId:
normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL,
noSession: config.internal === true,
systemPrompt: composeSystemPromptParts(
config.systemPrompt,
config.daemonAppendSystemPrompt,
),
env: launchContext?.env,
mcpConfigPath: mcpConfig?.path,
extensionPaths: paseoExtension ? [paseoExtension.path] : undefined,
@@ -2368,7 +2370,12 @@ export class PiRpcAgentClient implements AgentClient {
resumeConfig.config.mcpServers,
mcpEnv,
);
const paseoExtension = createPiPaseoExtensionFile();
const paseoExtension = createPiPaseoExtensionFile(
composeSystemPromptParts(
resumeConfig.config.systemPrompt,
resumeConfig.config.daemonAppendSystemPrompt,
),
);
let runtimeSession: PiRuntimeSession;
try {
runtimeSession = await this.runtime.startSession(

View File

@@ -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 () => {
const child = createPiChild();
replyToCommands(child, () => ({ models: [] }));

View File

@@ -19,7 +19,6 @@ export interface PiRuntimeLaunch {
modeId?: string;
session?: string;
noSession?: boolean;
systemPrompt?: string;
mcpConfigPath?: string;
extensionPaths?: string[];
extraArgs?: string[];
@@ -34,7 +33,6 @@ export interface PiStartSessionInput {
modeId?: string;
session?: string;
noSession?: boolean;
systemPrompt?: string;
mcpConfigPath?: string;
extensionPaths?: string[];
extraArgs?: string[];
@@ -85,8 +83,7 @@ export function buildPiLaunch(input: {
const argv = [...command];
const protocolMode = input.session.protocolMode ?? "rpc";
const systemPrompt = input.session.systemPrompt?.trim();
appendPiLaunchArgs(argv, input.session, protocolMode, systemPrompt);
appendPiLaunchArgs(argv, input.session, protocolMode);
return {
cwd: input.session.cwd,
@@ -104,7 +101,6 @@ export function buildPiLaunch(input: {
modeId: input.session.modeId,
session: input.session.session,
noSession: input.session.noSession,
systemPrompt,
mcpConfigPath: input.session.mcpConfigPath,
extensionPaths: input.session.extensionPaths,
extraArgs: input.session.extraArgs,
@@ -115,7 +111,6 @@ function appendPiLaunchArgs(
argv: string[],
session: PiStartSessionInput,
protocolMode: "rpc" | "rpc-ui",
systemPrompt: string | undefined,
): void {
if (!hasModeFlag(argv)) {
argv.push("--mode", protocolMode);
@@ -134,9 +129,6 @@ function appendPiLaunchArgs(
} else if (session.session) {
argv.push("--session", session.session);
}
if (systemPrompt) {
argv.push("--append-system-prompt", systemPrompt);
}
if (session.mcpConfigPath) {
argv.push("--mcp-config", session.mcpConfigPath);
}

View File

@@ -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(
"real Pi daemon lists Paseo-handled compact slash commands",
async () => {