Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-05-19 22:57:37 +07:00
83 changed files with 5932 additions and 4359 deletions
@@ -100,12 +100,15 @@ function expectArchivedAgentRecord(
class TestAgentClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
readonly createdConfigs: AgentSessionConfig[] = [];
readonly resumeOverrides: Array<Partial<AgentSessionConfig> | undefined> = [];
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.createdConfigs.push(config);
return new TestAgentSession(config);
}
@@ -135,9 +138,11 @@ class TestAgentClient implements AgentClient {
config?: Partial<AgentSessionConfig>,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
this.resumeOverrides.push(config);
return new TestAgentSession({
provider: "codex",
cwd: config?.cwd ?? process.cwd(),
daemonAppendSystemPrompt: config?.daemonAppendSystemPrompt,
});
}
}
@@ -391,6 +396,62 @@ test("normalizeConfig strips legacy 'default' model id", async () => {
expect(snapshot.config.modeId).toBe("auto");
});
test("createAgent injects daemon append system prompt at runtime only", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const client = new TestAgentClient();
const manager = new AgentManager({
clients: {
codex: client,
},
registry: storage,
logger,
appendSystemPrompt: " Daemon instructions. ",
idFactory: () => "00000000-0000-4000-8000-000000000103",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
systemPrompt: "Agent instructions.",
});
const record = await storage.get(snapshot.id);
expect(client.createdConfigs[0]?.systemPrompt).toBe("Agent instructions.");
expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBe("Daemon instructions.");
expect(snapshot.config.daemonAppendSystemPrompt).toBe("Daemon instructions.");
expect(record?.config?.systemPrompt).toBe("Agent instructions.");
expect(record?.config).not.toHaveProperty("daemonAppendSystemPrompt");
});
test("daemon append system prompt is injected into Pi configs", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const client = new TestAgentClient();
const manager = new AgentManager({
clients: {
pi: client as unknown as AgentClient,
},
providerDefinitions: {
pi: { enabled: true },
},
registry: storage,
logger,
appendSystemPrompt: "Daemon instructions.",
idFactory: () => "00000000-0000-4000-8000-000000000104",
});
await manager.createAgent({
provider: "pi",
cwd: workdir,
systemPrompt: "Agent instructions.",
});
expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBe("Daemon instructions.");
});
test("setAgentMode persists the selected mode across session reload", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -173,6 +173,7 @@ export interface AgentManagerOptions {
durableTimelineStore?: AgentTimelineStore;
terminalManager?: TerminalManager | null;
mcpBaseUrl?: string;
appendSystemPrompt?: string;
agentStreamCoalesceWindowMs?: number;
rescueTimeouts?: AgentManagerRescueTimeouts;
logger: Logger;
@@ -438,6 +439,7 @@ export class AgentManager {
private readonly backgroundTasks = new Set<Promise<void>>();
private readonly agentStreamCoalescer: AgentStreamCoalescer;
private mcpBaseUrl: string | null;
private appendSystemPrompt: string;
private onAgentAttention?: AgentAttentionCallback;
private logger: Logger;
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
@@ -448,6 +450,7 @@ export class AgentManager {
this.durableTimelineStore = options?.durableTimelineStore;
this.onAgentAttention = options?.onAgentAttention;
this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
this.appendSystemPrompt = options.appendSystemPrompt ?? "";
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
this.rescueTimeouts = {
reloadSessionCloseMs:
@@ -502,6 +505,10 @@ export class AgentManager {
this.mcpBaseUrl = url;
}
setAppendSystemPrompt(prompt: string | null | undefined): void {
this.appendSystemPrompt = prompt ?? "";
}
public getMetricsSnapshot(): AgentMetricsSnapshot {
const byLifecycle: Record<string, number> = {};
let withActiveForegroundTurn = 0;
@@ -816,7 +823,9 @@ export class AgentManager {
},
};
this.requireEnabledProvider(injectedConfig.provider);
const normalizedConfig = await this.normalizeConfig(injectedConfig);
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
await this.normalizeConfig(injectedConfig),
);
const launchContext = this.buildLaunchContext(resolvedAgentId);
const client = await this.requireAvailableClient({
provider: normalizedConfig.provider,
@@ -860,7 +869,9 @@ export class AgentManager {
...overrides,
provider: handle.provider,
} as AgentSessionConfig;
const normalizedConfig = await this.normalizeConfig(mergedConfig);
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
await this.normalizeConfig(mergedConfig),
);
const resumeOverrides: Partial<AgentSessionConfig> = { ...overrides };
let hasResumeOverrides = overrides !== undefined;
@@ -874,6 +885,11 @@ export class AgentManager {
hasResumeOverrides = true;
}
if (metadata.daemonAppendSystemPrompt !== normalizedConfig.daemonAppendSystemPrompt) {
resumeOverrides.daemonAppendSystemPrompt = normalizedConfig.daemonAppendSystemPrompt;
hasResumeOverrides = true;
}
const launchContext = this.buildLaunchContext(resolvedAgentId);
const client = this.requireClient(handle.provider);
const available = await client.isAvailable();
@@ -919,7 +935,9 @@ export class AgentManager {
...overrides,
provider,
} as AgentSessionConfig;
const normalizedConfig = await this.normalizeConfig(refreshConfig);
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
await this.normalizeConfig(refreshConfig),
);
const launchContext = this.buildLaunchContext(agentId);
const session = handle
@@ -1055,6 +1073,7 @@ export class AgentManager {
}
const { archivedAt } = await this.markRecordArchived(stored);
agent.updatedAt = new Date(archivedAt);
await this.closeAgent(agentId);
await this.cascadeArchiveChildren(agentId);
@@ -3437,6 +3456,19 @@ export class AgentManager {
return normalized;
}
private applyDaemonAppendSystemPrompt(config: AgentSessionConfig): AgentSessionConfig {
const daemonAppendSystemPrompt = this.appendSystemPrompt.trim();
const next = { ...config };
delete next.daemonAppendSystemPrompt;
return daemonAppendSystemPrompt
? {
...next,
daemonAppendSystemPrompt,
}
: next;
}
private buildLaunchContext(agentId: string): AgentLaunchContext {
return {
agentId,
@@ -473,6 +473,11 @@ export interface AgentSessionConfig {
* Mapped by each provider to its native instruction field.
*/
systemPrompt?: string;
/**
* Daemon-level instructions appended at runtime. This is deliberately not
* persisted into agent config so daemon setting changes apply cleanly.
*/
daemonAppendSystemPrompt?: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
@@ -1,4 +1,7 @@
import { beforeEach, expect, test, vi } from "vitest";
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
import type { FetchRecentProviderSessionsRequestMessage } from "../../shared/messages.js";
@@ -10,6 +13,8 @@ import {
normalizeImportAgentRequest,
} from "./import-sessions.js";
const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
const TEST_CAPABILITIES = {
supportsStreaming: true,
supportsSessionPersistence: true,
@@ -275,6 +280,39 @@ test("listImportableProviderSessions filters out metadata generation sessions",
expect(result.filteredAlreadyImportedCount).toBe(0);
});
test("listImportableProviderSessions keeps realpath-equivalent cwd matches", async () => {
const root = mkdtempSync(path.join(tmpdir(), "paseo-import-cwd-"));
const realCwd = path.join(root, "real-project");
const linkedCwd = path.join(root, "linked-project");
mkdirSync(realCwd, { recursive: true });
symlinkSync(realCwd, linkedCwd, directorySymlinkType);
const persistedCwd = realpathSync(linkedCwd);
const result = await listImportableProviderSessions({
request: makeRequest({ cwd: linkedCwd, providers: ["pi"] }),
agentManager: {
listAgents: () => [],
listImportablePersistedAgents: async () => [
makeDescriptor({
provider: "pi",
sessionId: "pi-session",
nativeHandle: "pi-handle",
cwd: persistedCwd,
title: "Pi session",
lastActivityAt: "2026-04-30T12:00:00.000Z",
firstPrompt: "remember this",
}),
],
} satisfies Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">,
agentStorage: {
list: async () => [],
} satisfies Pick<AgentStorage, "list">,
providerRegistry: { pi: { label: "Pi" } },
});
expect(result.entries.map((entry) => entry.providerHandleId)).toEqual(["pi-handle"]);
});
test("listImportableProviderSessions rejects invalid since values", async () => {
await expect(
listImportableProviderSessions({
@@ -20,7 +20,7 @@ import type {
RecentProviderSessionDescriptorPayload,
} from "../../shared/messages.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import { createPathEquivalenceMatcher } from "../../utils/path.js";
import { createRealpathAwarePathMatcher } from "../../utils/path.js";
type ImportAgentRequestMessage = z.infer<typeof ImportAgentRequestMessageSchema>;
@@ -112,7 +112,7 @@ export async function listImportableProviderSessions(
});
let filteredAlreadyImportedCount = 0;
const candidates: PersistedAgentDescriptor[] = [];
const matchesRequestCwd = request.cwd ? createPathEquivalenceMatcher(request.cwd) : null;
const matchesRequestCwd = request.cwd ? createRealpathAwarePathMatcher(request.cwd) : null;
for (const descriptor of descriptors) {
if (matchesRequestCwd && !matchesRequestCwd(descriptor.cwd)) {
continue;
@@ -1987,6 +1987,7 @@ describe("provider listing MCP tool", () => {
});
const tool = registeredTool(server, "list_providers");
const response = await tool.handler({});
const modelVisibleText = String(response.content[0]?.text);
expect(response.structuredContent).toEqual({
providers: [
@@ -2008,6 +2009,9 @@ describe("provider listing MCP tool", () => {
},
],
});
expect(modelVisibleText).toContain("providers_count=2");
expect(modelVisibleText).toContain("providers_ids=claude,zai");
expect(modelVisibleText).toContain('"providers"');
});
it("returns disabled providers with metadata without checking availability", async () => {
+90 -34
View File
@@ -3,7 +3,11 @@ import { z } from "zod";
import { ensureValidJson } from "../json-utils.js";
import type { Logger } from "pino";
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";
import type {
CallToolResult,
ServerNotification,
ServerRequest,
} from "@modelcontextprotocol/sdk/types.js";
import type { AgentProvider } from "./agent-sdk-types.js";
import type { AgentManager, WaitForAgentResult } from "./agent-manager.js";
@@ -154,6 +158,54 @@ function mapModeAcrossProviders(
return sourceMode;
}
function addModelVisibleStructuredContent(result: CallToolResult): CallToolResult {
if (result.structuredContent === undefined || result.content.length > 0) {
return result;
}
return {
...result,
content: [
{
type: "text",
text: formatStructuredContentForModel(result.structuredContent),
},
],
};
}
function formatStructuredContentForModel(structuredContent: unknown): string {
if (
!structuredContent ||
typeof structuredContent !== "object" ||
Array.isArray(structuredContent)
) {
return JSON.stringify(structuredContent, null, 2);
}
const record = structuredContent as Record<string, unknown>;
const summary: string[] = [];
for (const [key, value] of Object.entries(record)) {
if (!Array.isArray(value)) {
continue;
}
summary.push(`${key}_count=${value.length}`);
const ids = value
.map((item) =>
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>).id
: null,
)
.filter((id): id is string => typeof id === "string" && id.length > 0);
if (ids.length === value.length && ids.length > 0) {
summary.push(`${key}_ids=${ids.join(",")}`);
}
}
const json = JSON.stringify(structuredContent, null, 2);
return summary.length > 0 ? `${summary.join("\n")}\n\n${json}` : json;
}
type McpToolContext = RequestHandlerExtra<ServerRequest, ServerNotification>;
function parseTimestamp(value: string | null | undefined): number {
@@ -454,6 +506,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
name: "agent-mcp",
version: "2.0.0",
});
const registerRawTool = server.registerTool.bind(server);
const registerTool: McpServer["registerTool"] = (name, config, handler) =>
registerRawTool(name, config, (async (args: never, extra: never) =>
addModelVisibleStructuredContent(await handler(args, extra))) as typeof handler);
const resolveCallerAgent = () => {
if (!callerAgentId) {
@@ -750,7 +806,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
};
if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) {
server.registerTool(
registerTool(
"speak",
{
title: "Speak",
@@ -954,7 +1010,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
};
};
server.registerTool(
registerTool(
"create_agent",
{
title: "Create agent",
@@ -1099,7 +1155,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"wait_for_agent",
{
title: "Wait for agent",
@@ -1176,7 +1232,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"send_agent_prompt",
{
title: "Send agent prompt",
@@ -1277,7 +1333,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"get_agent_status",
{
title: "Get agent status",
@@ -1327,7 +1383,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_agents",
{
title: "List agents",
@@ -1386,7 +1442,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"cancel_agent",
{
title: "Cancel agent run",
@@ -1410,7 +1466,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"archive_agent",
{
title: "Archive agent",
@@ -1433,7 +1489,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"kill_agent",
{
title: "Kill agent",
@@ -1455,7 +1511,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"update_agent",
{
title: "Update agent",
@@ -1513,7 +1569,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_terminals",
{
title: "List terminals",
@@ -1561,7 +1617,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"create_terminal",
{
title: "Create terminal",
@@ -1596,7 +1652,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"kill_terminal",
{
title: "Kill terminal",
@@ -1627,7 +1683,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"capture_terminal",
{
title: "Capture terminal",
@@ -1671,7 +1727,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"send_terminal_keys",
{
title: "Send terminal keys",
@@ -1707,7 +1763,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"create_schedule",
{
title: "Create schedule",
@@ -1788,7 +1844,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_schedules",
{
title: "List schedules",
@@ -1813,7 +1869,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"inspect_schedule",
{
title: "Inspect schedule",
@@ -1836,7 +1892,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"pause_schedule",
{
title: "Pause schedule",
@@ -1861,7 +1917,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"resume_schedule",
{
title: "Resume schedule",
@@ -1886,7 +1942,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"delete_schedule",
{
title: "Delete schedule",
@@ -1911,7 +1967,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"update_schedule",
{
title: "Update schedule",
@@ -1973,7 +2029,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"schedule_logs",
{
title: "Schedule logs",
@@ -1998,7 +2054,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_providers",
{
title: "List providers",
@@ -2021,7 +2077,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_models",
{
title: "List models",
@@ -2058,7 +2114,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"inspect_provider",
{
title: "Inspect provider",
@@ -2122,7 +2178,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_worktrees",
{
title: "List worktrees",
@@ -2153,7 +2209,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"create_worktree",
{
title: "Create worktree",
@@ -2218,7 +2274,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"archive_worktree",
{
title: "Archive worktree",
@@ -2310,7 +2366,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"get_agent_activity",
{
title: "Get agent activity",
@@ -2366,7 +2422,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"set_agent_mode",
{
title: "Set agent session mode",
@@ -2390,7 +2446,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_pending_permissions",
{
title: "List pending permissions",
@@ -2424,7 +2480,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"respond_to_permission",
{
title: "Respond to permission",
@@ -1,50 +0,0 @@
/**
* Returns orchestrator mode instructions to append to the system prompt.
* These instructions are from CLAUDE.md and guide agents on how to work
* effectively in this repository.
*/
export function getOrchestratorModeInstructions(): string {
return `
<orchestrator-mode>
Activation:
- Only activate if the user explicitly says "go into orchestrator mode" (or similar).
- Otherwise, do work directly yourself; do not spawn agents.
Core rules:
- In orchestrator mode, you accomplish tasks only by managing agents; do not perform the work yourself.
- Always prefix agent titles (e.g., "🎭 Feature Implementation", "🎭 Design Discussion").
- Set cwd to the repository root and choose the most permissive mode available.
- If an agent control tool call fails, list agents before launching another; it may just be a wait timeout.
Context management:
- Reuse an existing agent when the next step needs the same context (same files/module/folder or immediate follow-up like investigate → fix in the same area).
- Start a new agent when switching to a different area/module, or when an agent has run long and its context feels stale.
- Prefer sending follow-up prompts to an existing agent to avoid reloading context.
- Use multiple agents when roles diverge (e.g., one for refactor, one for external validation), but default to reuse when context overlaps.
Conversation with agents:
- Engage actively: ask pointed questions, probe risks, and request clarifications before accepting proposals.
- Encourage agents to validate assumptions, consider edge cases, and describe how they will test/verify.
Agent selection guidance:
- Codex: methodical and slower; great for deep debugging, tracing code paths, refactoring, complex features, and design discussions.
- Claude: fast; strong at tool use, agentic control, and managing other agents; may jump to conclusions—ask it to verify.
Clarifying ambiguous requests:
- Research first to understand the current state.
- Ask clarifying questions about what the user wants.
- Present options with trade-offs.
- Get explicit confirmation; never assume.
Investigation vs Implementation:
- Investigate only unless explicitly asked to implement.
- Report findings clearly.
- After investigation, ask for direction before implementing.
Tool usage discipline:
- Do not ask users to run commands—run them yourself.
- Do not repeat the user’s instructions verbatim—summarize them in your own words.
- Be explicit about results—tell the user what happened after every command.
</orchestrator-mode>
`;
}
@@ -193,8 +193,8 @@ vi.mock("./providers/copilot-acp-agent.js", () => ({
},
}));
vi.mock("./providers/pi-direct-agent.js", () => ({
PiDirectAgentClient: class PiDirectAgentClient {
vi.mock("./providers/pi/agent.js", () => ({
PiRpcAgentClient: class PiRpcAgentClient {
readonly capabilities = {
supportsStreaming: true,
supportsSessionPersistence: true,
@@ -29,7 +29,7 @@ import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js";
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
import { OpenCodeServerManager } from "./providers/opencode/server-manager.js";
import { PiDirectAgentClient } from "./providers/pi-direct-agent.js";
import { PiRpcAgentClient } from "./providers/pi/agent.js";
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
import {
AGENT_PROVIDER_DEFINITIONS,
@@ -121,7 +121,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
}),
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings),
pi: (logger, runtimeSettings) =>
new PiDirectAgentClient({
new PiRpcAgentClient({
logger,
runtimeSettings,
}),
@@ -36,7 +36,7 @@ import {
transformCopilotSessionResponse,
writeCopilotProviderMode,
} from "./copilot-acp-agent.js";
import { transformPiModels } from "./pi-direct-agent.js";
import { transformPiModels } from "./pi/agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals } from "../../test-utils/class-mocks.js";
@@ -81,7 +81,7 @@ import {
import { findExecutable, isCommandAvailable } from "../../../../utils/executable.js";
import { withTimeout } from "../../../../utils/promise-timeout.js";
import { execCommand } from "../../../../utils/spawn.js";
import { getOrchestratorModeInstructions } from "../../orchestrator-instructions.js";
import { composeSystemPromptParts } from "../../system-prompt.js";
const fsPromises = promises;
const CLAUDE_SETTING_SOURCES: NonNullable<ClaudeOptions["settingSources"]> = [
@@ -2314,9 +2314,9 @@ class ClaudeAgentSession implements AgentSession {
}
private buildAppendedSystemPrompt(): string {
return [getOrchestratorModeInstructions(), this.config.systemPrompt?.trim()]
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
.join("\n\n");
return (
composeSystemPromptParts(this.config.systemPrompt, this.config.daemonAppendSystemPrompt) ?? ""
);
}
private buildSdkEnv(extraClaudeOptions: Partial<ClaudeOptions> | undefined): NodeJS.ProcessEnv {
@@ -2365,7 +2365,7 @@ class ClaudeAgentSession implements AgentSession {
canUseTool: this.handlePermissionRequest,
pathToClaudeCodeExecutable: claudeBinary,
// Use Claude Code preset system prompt and load CLAUDE.md files
// Append provider-agnostic system prompt and orchestrator instructions for agents.
// Append provider-agnostic system prompts for agents.
systemPrompt: {
type: "preset",
preset: "claude_code",
@@ -41,6 +41,7 @@ import os from "node:os";
import path from "node:path";
import { z } from "zod";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { composeSystemPromptParts } from "../system-prompt.js";
import { curateAgentActivity } from "../activity-curator.js";
import {
mapCodexRolloutToolCall,
@@ -3026,12 +3027,11 @@ class CodexAppServerAgentSession implements AgentSession {
const settings: Record<string, unknown> = {};
if (match.model) settings.model = match.model;
if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort;
const developerInstructions = [
match.developer_instructions?.trim(),
this.config.systemPrompt?.trim(),
]
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
.join("\n\n");
const developerInstructions = composeSystemPromptParts(
match.developer_instructions,
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
if (developerInstructions) settings.developer_instructions = developerInstructions;
if (this.config.model) settings.model = this.config.model;
const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
@@ -3161,8 +3161,12 @@ class CodexAppServerAgentSession implements AgentSession {
return;
}
const params: Record<string, unknown> = { threadId: this.currentThreadId };
if (this.config.systemPrompt?.trim()) {
params.developerInstructions = this.config.systemPrompt.trim();
const developerInstructions = composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
if (developerInstructions) {
params.developerInstructions = developerInstructions;
}
const codexConfig = this.buildCodexInnerConfig();
if (codexConfig) {
@@ -3257,6 +3261,7 @@ class CodexAppServerAgentSession implements AgentSession {
approvalPolicy: string;
sandboxPolicyType: string;
hasOutputSchema: boolean;
hasDeveloperInstructions: boolean;
hasCodexConfig: boolean;
}> {
const input = await this.buildUserInput(prompt);
@@ -3299,8 +3304,12 @@ class CodexAppServerAgentSession implements AgentSession {
if (options?.outputSchema) {
params.outputSchema = normalizeCodexOutputSchema(options.outputSchema);
}
if (this.config.systemPrompt?.trim()) {
params.developerInstructions = this.config.systemPrompt.trim();
const developerInstructions = composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
if (developerInstructions) {
params.developerInstructions = developerInstructions;
}
const codexConfig = this.buildCodexInnerConfig();
if (codexConfig) {
@@ -3313,6 +3322,7 @@ class CodexAppServerAgentSession implements AgentSession {
approvalPolicy,
sandboxPolicyType,
hasOutputSchema: Boolean(options?.outputSchema),
hasDeveloperInstructions: Boolean(developerInstructions),
hasCodexConfig: Boolean(codexConfig),
};
}
@@ -3323,6 +3333,7 @@ class CodexAppServerAgentSession implements AgentSession {
approvalPolicy,
sandboxPolicyType,
hasOutputSchema,
hasDeveloperInstructions,
hasCodexConfig,
}: {
turnId: string;
@@ -3330,6 +3341,7 @@ class CodexAppServerAgentSession implements AgentSession {
approvalPolicy: string;
sandboxPolicyType: string;
hasOutputSchema: boolean;
hasDeveloperInstructions: boolean;
hasCodexConfig: boolean;
}): void {
this.logger.info(
@@ -3345,7 +3357,7 @@ class CodexAppServerAgentSession implements AgentSession {
sandboxPolicyType,
hasCollaborationMode: Boolean(this.resolvedCollaborationMode),
hasOutputSchema,
hasDeveloperInstructions: Boolean(this.config.systemPrompt?.trim()),
hasDeveloperInstructions,
hasCodexConfig,
},
"Starting Codex app-server turn",
@@ -3407,6 +3419,7 @@ class CodexAppServerAgentSession implements AgentSession {
approvalPolicy: turnStart.approvalPolicy,
sandboxPolicyType: turnStart.sandboxPolicyType,
hasOutputSchema: turnStart.hasOutputSchema,
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
hasCodexConfig: turnStart.hasCodexConfig,
});
await this.client.request("turn/start", turnStart.params, TURN_START_TIMEOUT_MS);
@@ -3952,14 +3965,16 @@ class CodexAppServerAgentSession implements AgentSession {
const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
const sandbox = this.config.sandboxMode ?? preset.sandbox;
const innerConfig = this.buildCodexInnerConfig();
const developerInstructions = composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
const params: Record<string, unknown> = {
model,
cwd: this.config.cwd ?? null,
approvalPolicy,
sandbox,
...(this.config.systemPrompt?.trim()
? { developerInstructions: this.config.systemPrompt.trim() }
: {}),
...(developerInstructions ? { developerInstructions } : {}),
...(innerConfig ? { config: innerConfig } : {}),
...(this.ephemeral ? { ephemeral: true } : {}),
};
@@ -60,6 +60,7 @@ import {
} from "./diagnostic-utils.js";
import { runProviderTurn } from "./provider-runner.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { composeSystemPromptParts } from "../system-prompt.js";
import {
createSdkOpenCodeClient,
type OpenCodeRuntime,
@@ -2589,6 +2590,10 @@ class OpenCodeAgentSession implements AgentSession {
partTypes: parts.map((p) => p.type),
});
try {
const systemPrompt = composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
const promptResponse = await this.client.session.promptAsync({
sessionID: this.sessionId,
directory: this.config.cwd,
@@ -2601,7 +2606,7 @@ class OpenCodeAgentSession implements AgentSession {
},
}
: {}),
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
...(systemPrompt ? { system: systemPrompt } : {}),
...(model ? { model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
@@ -1,373 +0,0 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, test, vi } from "vitest";
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
import pino from "pino";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import {
PiDirectAgentClient,
PiDirectAgentSession,
type PiDirectSessionRuntimeAdapter,
type PiDirectSessionAdapter,
} from "./pi-direct-agent.js";
function createPiAssistantErrorMessage(errorMessage: string): AssistantMessage {
return {
role: "assistant",
content: [],
api: "openai-responses",
provider: "github-copilot",
model: "gpt-5.4",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
},
stopReason: "error",
errorMessage,
timestamp: Date.now(),
};
}
function createPiSession(
prompt: () => Promise<void>,
options: {
compact?: () => Promise<void>;
messages?: PiDirectSessionAdapter["messages"];
errorMessage?: string | null;
} = {},
): PiDirectSessionAdapter {
return {
sessionId: "pi-session-1",
thinkingLevel: "medium",
model: undefined,
messages: options.messages ?? [],
extensionRunner: undefined,
promptTemplates: [],
resourceLoader: {
getSkills: () => ({ skills: [] }),
},
agent: {
state: {
systemPrompt: "",
errorMessage: options.errorMessage ?? null,
},
},
sessionManager: {
getSessionFile: () => "/tmp/pi-session.json",
getCwd: () => "/tmp/paseo-pi-test",
},
subscribe: vi.fn(),
prompt,
compact: options.compact ?? vi.fn(async () => undefined),
abort: vi.fn(),
dispose: vi.fn(),
getSessionStats: vi.fn(() => ({})),
setModel: vi.fn(),
setThinkingLevel: vi.fn(),
};
}
function createPiRuntime(
session: PiDirectSessionAdapter,
dispose: () => Promise<void> = vi.fn(async () => undefined),
): PiDirectSessionRuntimeAdapter {
return {
session,
dispose,
};
}
function createPiModel(provider: string, id: string): Model<Api> {
return {
provider,
id,
name: id,
api: "openai-completions",
baseUrl: "https://example.invalid/v1",
reasoning: true,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
} as Model<Api>;
}
describe("PiDirectAgentSession", () => {
test("treats SDK request abort rejections as turn cancellations", async () => {
const session = new PiDirectAgentSession(
createPiRuntime(createPiSession(() => Promise.reject(new Error("Request was aborted.")))),
{ find: vi.fn(), getAll: vi.fn(() => []) },
{
provider: "pi",
cwd: "/tmp/paseo-pi-test",
},
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const { turnId } = await session.startTurn("hello");
await Promise.resolve();
expect(events).toEqual([
{
type: "turn_canceled",
provider: "pi",
turnId,
reason: "Request was aborted.",
},
]);
});
test("compacts stale Copilot 413 sessions before prompting again", async () => {
const callOrder: string[] = [];
const sdkSession = createPiSession(
vi.fn(async () => {
callOrder.push("prompt");
}),
{
messages: [createPiAssistantErrorMessage("413 failed to parse request")],
errorMessage: "413 failed to parse request",
compact: vi.fn(async () => {
callOrder.push("compact");
}),
},
);
const session = new PiDirectAgentSession(
createPiRuntime(sdkSession),
{ find: vi.fn(), getAll: vi.fn(() => []) },
{
provider: "pi",
cwd: "/tmp/paseo-pi-test",
},
);
await session.startTurn("continue");
expect(sdkSession.compact).toHaveBeenCalledTimes(1);
expect(sdkSession.prompt).toHaveBeenCalledTimes(1);
expect(callOrder).toEqual(["compact", "prompt"]);
});
test("does not compact other Pi errors before prompting", async () => {
const sdkSession = createPiSession(
vi.fn(async () => undefined),
{
messages: [createPiAssistantErrorMessage("413 unrelated provider error")],
compact: vi.fn(async () => {
throw new Error("should not compact");
}),
},
);
const session = new PiDirectAgentSession(
createPiRuntime(sdkSession),
{ find: vi.fn(), getAll: vi.fn(() => []) },
{
provider: "pi",
cwd: "/tmp/paseo-pi-test",
},
);
await session.startTurn("continue");
expect(sdkSession.compact).not.toHaveBeenCalled();
expect(sdkSession.prompt).toHaveBeenCalledTimes(1);
});
test("setModel creates a minimal model for new ids under a known provider", async () => {
const sdkSession = createPiSession(async () => undefined);
const session = new PiDirectAgentSession(
createPiRuntime(sdkSession),
{
find: vi.fn(() => undefined),
getAll: vi.fn(() => [createPiModel("openrouter", "known-model")]),
},
{
provider: "pi",
cwd: "/tmp/paseo-pi-test",
},
);
await session.setModel("openrouter/blabal");
expect(sdkSession.setModel).toHaveBeenCalledWith({
id: "blabal",
name: "blabal",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://example.invalid/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16384,
compat: undefined,
});
});
});
describe("PiDirectAgentClient", () => {
test("lists only Pi models with configured auth", async () => {
const client = new PiDirectAgentClient({
logger: pino({ level: "silent" }),
});
const registry = {
find: vi.fn(),
getAll: vi.fn(() => [createPiModel("amazon-bedrock", "claude-sonnet-4")]),
getAvailable: vi.fn(() => [createPiModel("anthropic", "claude-opus-4-5")]),
};
(client as unknown as { modelRegistry: typeof registry }).modelRegistry = registry;
const models = await client.listModels({ cwd: "/tmp/paseo-pi-test", force: false });
expect(registry.getAvailable).toHaveBeenCalledTimes(1);
expect(registry.getAll).not.toHaveBeenCalled();
expect(models.map((model) => model.id)).toEqual(["anthropic/claude-opus-4-5"]);
});
test("loads project extensions before listing available models", async () => {
const testRoot = await mkdtemp(join(tmpdir(), "paseo-pi-extension-"));
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
try {
const agentDir = join(testRoot, "agent");
const cwd = join(testRoot, "project");
const extensionDir = join(cwd, ".pi", "extensions");
process.env.PI_CODING_AGENT_DIR = agentDir;
await mkdir(extensionDir, { recursive: true });
await writeFile(
join(extensionDir, "dummy-provider.ts"),
`
export default function(pi) {
pi.registerProvider("paseo-dummy", {
baseUrl: "https://example.invalid/v1",
apiKey: "paseo-test-key",
api: "openai-responses",
models: [
{
id: "extension-model",
name: "Extension Model",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096
}
]
});
}
`,
"utf-8",
);
const client = new PiDirectAgentClient({
logger: pino({ level: "silent" }),
});
const models = await client.listModels({ cwd, force: false });
expect(models.map((model) => model.id)).toContain("paseo-dummy/extension-model");
} finally {
if (previousAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousAgentDir;
}
await rm(testRoot, { recursive: true, force: true });
}
});
test("creates sessions with project extension models and exposes extension commands", async () => {
const testRoot = await mkdtemp(join(tmpdir(), "paseo-pi-extension-session-"));
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
try {
const agentDir = join(testRoot, "agent");
const cwd = join(testRoot, "project");
const extensionDir = join(cwd, ".pi", "extensions");
const shutdownMarker = join(testRoot, "shutdown.txt");
process.env.PI_CODING_AGENT_DIR = agentDir;
await mkdir(extensionDir, { recursive: true });
await writeFile(
join(extensionDir, "dummy-command.ts"),
`
import { writeFileSync } from "node:fs";
export default function(pi) {
pi.registerProvider("paseo-dummy", {
baseUrl: "https://example.invalid/v1",
apiKey: "paseo-test-key",
api: "openai-responses",
models: [
{
id: "extension-model",
name: "Extension Model",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096
}
]
});
pi.registerCommand("dummy-command", {
description: "Dummy extension command",
handler: async () => {}
});
pi.on("session_shutdown", async () => {
writeFileSync(${JSON.stringify(shutdownMarker)}, "closed");
});
}
`,
"utf-8",
);
const client = new PiDirectAgentClient({
logger: pino({ level: "silent" }),
});
const session = await client.createSession({
provider: "pi",
cwd,
model: "paseo-dummy/extension-model",
});
let closed = false;
try {
await expect(session.listCommands()).resolves.toContainEqual({
name: "dummy-command",
description: "Dummy extension command",
argumentHint: "",
});
await session.close();
closed = true;
await expect(readFile(shutdownMarker, "utf-8")).resolves.toBe("closed");
} finally {
if (!closed) {
await session.close();
}
}
} finally {
if (previousAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousAgentDir;
}
await rm(testRoot, { recursive: true, force: true });
}
});
});
File diff suppressed because it is too large Load Diff
@@ -1,115 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import {
applyPiSessionRecoveryPolicy,
type PiSessionRecoveryPolicySession,
} from "./pi-session-recovery-policy.js";
function createRecoverySession(
options: {
messages?: PiSessionRecoveryPolicySession["messages"];
errorMessage?: string | null;
compact?: () => Promise<void>;
} = {},
): PiSessionRecoveryPolicySession {
return {
messages: options.messages ?? [],
agent: {
state: {
errorMessage: options.errorMessage ?? null,
},
},
compact: options.compact ?? vi.fn(async () => undefined),
};
}
describe("applyPiSessionRecoveryPolicy", () => {
test("compacts sessions that ended on Pi Copilot short 413 overflow", async () => {
const compact = vi.fn(async () => undefined);
const session = createRecoverySession({
messages: [
{
role: "assistant",
stopReason: "error",
errorMessage: "413 failed to parse request",
},
],
compact,
});
const result = await applyPiSessionRecoveryPolicy(session);
expect(result).toEqual({ applied: true, policyId: "piCopilot413" });
expect(compact).toHaveBeenCalledTimes(1);
});
test("uses Pi agent error state when message history has no matching assistant error", async () => {
const compact = vi.fn(async () => undefined);
const session = createRecoverySession({
messages: [{ role: "user" }],
errorMessage: " 413 failed to parse request ",
compact,
});
const result = await applyPiSessionRecoveryPolicy(session);
expect(result).toEqual({ applied: true, policyId: "piCopilot413" });
expect(compact).toHaveBeenCalledTimes(1);
});
test("does not compact unrelated Pi errors", async () => {
const compact = vi.fn(async () => undefined);
const session = createRecoverySession({
messages: [
{
role: "assistant",
stopReason: "error",
errorMessage: "413 unrelated provider error",
},
],
compact,
});
const result = await applyPiSessionRecoveryPolicy(session);
expect(result).toEqual({ applied: false });
expect(compact).not.toHaveBeenCalled();
});
test("treats already-compacted sessions as recovered", async () => {
const session = createRecoverySession({
messages: [
{
role: "assistant",
stopReason: "error",
errorMessage: "413 failed to parse request",
},
],
compact: vi.fn(async () => {
throw new Error("Already compacted");
}),
});
await expect(applyPiSessionRecoveryPolicy(session)).resolves.toEqual({
applied: true,
policyId: "piCopilot413",
});
});
test("surfaces unexpected compaction failures", async () => {
const session = createRecoverySession({
messages: [
{
role: "assistant",
stopReason: "error",
errorMessage: "413 failed to parse request",
},
],
compact: vi.fn(async () => {
throw new Error("disk exploded");
}),
});
await expect(applyPiSessionRecoveryPolicy(session)).rejects.toThrow("disk exploded");
});
});
@@ -1,91 +0,0 @@
import { toDiagnosticErrorMessage } from "./diagnostic-utils.js";
export interface PiSessionRecoveryPolicySession {
readonly messages: readonly PiRecoveryMessage[];
readonly agent: {
readonly state: {
readonly errorMessage?: string | null;
};
};
compact(): Promise<unknown>;
}
interface PiRecoveryMessage {
readonly role: string;
readonly stopReason?: string;
readonly errorMessage?: string | null;
}
type PiSessionRecoveryPolicyId = "piCopilot413";
interface PiSessionRecoveryPolicy {
readonly id: PiSessionRecoveryPolicyId;
shouldRecover(session: PiSessionRecoveryPolicySession): boolean;
recover(session: PiSessionRecoveryPolicySession): Promise<void>;
}
export interface PiSessionRecoveryResult {
readonly applied: boolean;
readonly policyId?: PiSessionRecoveryPolicyId;
}
// COMPAT(piCopilot413): added 2026-05-13 for Pi <= 0.73.1; target removal
// 2026-11-13, once upstream @earendil-works/pi-ai recognizes this overflow.
const PI_COPILOT_SHORT_413_OVERFLOW_PATTERN = /^413\s+failed to parse request$/i;
const PI_SESSION_RECOVERY_POLICIES: readonly PiSessionRecoveryPolicy[] = [
{
id: "piCopilot413",
shouldRecover: shouldCompactForPiCopilot413,
recover: compactPiSession,
},
];
export async function applyPiSessionRecoveryPolicy(
session: PiSessionRecoveryPolicySession,
): Promise<PiSessionRecoveryResult> {
const policy = PI_SESSION_RECOVERY_POLICIES.find((entry) => entry.shouldRecover(session));
if (!policy) {
return { applied: false };
}
await policy.recover(session);
return { applied: true, policyId: policy.id };
}
function shouldCompactForPiCopilot413(session: PiSessionRecoveryPolicySession): boolean {
return (
isPiCopilotShort413Overflow(getLatestAssistantErrorMessage(session)) ||
isPiCopilotShort413Overflow(session.agent.state.errorMessage)
);
}
function isPiCopilotShort413Overflow(errorMessage: string | null | undefined): boolean {
const normalized = errorMessage?.trim();
return normalized ? PI_COPILOT_SHORT_413_OVERFLOW_PATTERN.test(normalized) : false;
}
function getLatestAssistantErrorMessage(session: PiSessionRecoveryPolicySession): string | null {
for (let index = session.messages.length - 1; index >= 0; index -= 1) {
const message = session.messages[index];
if (message.role !== "assistant") {
continue;
}
return message.stopReason === "error" ? (message.errorMessage?.trim() ?? null) : null;
}
return null;
}
async function compactPiSession(session: PiSessionRecoveryPolicySession): Promise<void> {
try {
await session.compact();
} catch (error) {
if (!isHarmlessPiCompactionError(error)) {
throw error;
}
}
}
function isHarmlessPiCompactionError(error: unknown): boolean {
return /already compacted|nothing to compact/i.test(toDiagnosticErrorMessage(error));
}
@@ -0,0 +1,489 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { describe, expect, test } from "vitest";
import type { AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js";
import { PiRpcAgentClient, PiRpcAgentSession, transformPiModels } from "./agent.js";
import { FakePi } from "./test-utils/fake-pi.js";
function createClient(pi = new FakePi()): PiRpcAgentClient {
return new PiRpcAgentClient({
logger: pino({ level: "silent" }),
runtime: pi,
});
}
function createClientWithPiAgentDir(agentDir: string): PiRpcAgentClient {
return new PiRpcAgentClient({
logger: pino({ level: "silent" }),
runtime: new FakePi(),
runtimeSettings: { env: { PI_CODING_AGENT_DIR: agentDir } },
});
}
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
return {
provider: "pi",
cwd: "/tmp/paseo-pi-rpc-test",
...overrides,
};
}
async function createSession(pi = new FakePi()): Promise<{
pi: FakePi;
session: PiRpcAgentSession;
events: SessionEvents;
}> {
const client = createClient(pi);
const session = (await client.createSession(createConfig())) as PiRpcAgentSession;
const events = new SessionEvents(session);
return { pi, session, events };
}
class SessionEvents {
private readonly events: AgentStreamEvent[] = [];
private readonly waiters: Array<{
predicate: (event: AgentStreamEvent) => boolean;
resolve: (event: AgentStreamEvent) => void;
}> = [];
constructor(session: PiRpcAgentSession) {
session.subscribe((event) => {
this.events.push(event);
for (let index = 0; index < this.waiters.length; index += 1) {
const waiter = this.waiters[index];
if (waiter.predicate(event)) {
this.waiters.splice(index, 1);
index -= 1;
waiter.resolve(event);
}
}
});
}
timelineItems() {
return this.events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline",
)
.map((event) => event.item);
}
nextTurnCompletion(): Promise<Extract<AgentStreamEvent, { type: "turn_completed" }>> {
return this.nextEvent(
(event): event is Extract<AgentStreamEvent, { type: "turn_completed" }> =>
event.type === "turn_completed",
);
}
nextTurnFailure(): Promise<Extract<AgentStreamEvent, { type: "turn_failed" }>> {
return this.nextEvent(
(event): event is Extract<AgentStreamEvent, { type: "turn_failed" }> =>
event.type === "turn_failed",
);
}
private nextEvent<T extends AgentStreamEvent>(
predicate: (event: AgentStreamEvent) => event is T,
): Promise<T> {
const existing = this.events.find(predicate);
if (existing) {
return Promise.resolve(existing);
}
return new Promise((resolve) => {
this.waiters.push({
predicate,
resolve: (event) => resolve(event as T),
});
});
}
}
describe("PiRpcAgentSession", () => {
test("streams assistant text, reasoning, and tool calls from Pi events", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
await session.startTurn("hello");
fakeSession.emit({
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: { type: "text_delta", delta: "hello" },
});
fakeSession.emit({
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: { type: "thinking_delta", delta: "thinking" },
});
fakeSession.emit({
type: "tool_execution_start",
toolCallId: "tool-1",
toolName: "bash",
args: { command: "echo hi" },
});
fakeSession.emit({
type: "tool_execution_end",
toolCallId: "tool-1",
toolName: "bash",
result: { output: "hi\n", exitCode: 0 },
isError: false,
});
fakeSession.finishTurn();
await events.nextTurnCompletion();
expect(events.timelineItems()).toEqual([
{ type: "assistant_message", text: "hello" },
{ type: "reasoning", text: "thinking" },
{
type: "tool_call",
callId: "tool-1",
name: "bash",
status: "running",
detail: { type: "shell", command: "echo hi" },
error: null,
},
{
type: "tool_call",
callId: "tool-1",
name: "bash",
status: "completed",
detail: { type: "shell", command: "echo hi", output: "hi\n", exitCode: 0 },
error: null,
},
]);
});
test("resumes by launching Pi with the persisted session file and cwd metadata", async () => {
const pi = new FakePi();
const client = createClient(pi);
await client.resumeSession(
{
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: "/tmp/native-pi-session.jsonl",
metadata: {
cwd: "/workspace/project",
model: "openrouter/model-a",
thinkingOptionId: "high",
},
},
{},
);
expect(pi.recordedLaunches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
session: "/tmp/native-pi-session.jsonl",
argv: [
"pi",
"--mode",
"rpc",
"--model",
"openrouter/model-a",
"--thinking",
"high",
"--session",
"/tmp/native-pi-session.jsonl",
],
}),
]);
});
test("creates Pi sessions with agent and daemon system prompts appended", async () => {
const pi = new FakePi();
const client = createClient(pi);
await client.createSession(
createConfig({
systemPrompt: "Agent prompt",
daemonAppendSystemPrompt: "Daemon prompt",
}),
);
expect(pi.recordedLaunches[0]).toEqual(
expect.objectContaining({
cwd: "/tmp/paseo-pi-rpc-test",
systemPrompt: "Agent prompt\n\nDaemon prompt",
argv: [
"pi",
"--mode",
"rpc",
"--thinking",
"medium",
"--append-system-prompt",
"Agent prompt\n\nDaemon prompt",
],
}),
);
});
test("resumes Pi sessions with daemon system prompts appended", async () => {
const pi = new FakePi();
const client = createClient(pi);
await client.resumeSession(
{
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: "/tmp/native-pi-session.jsonl",
metadata: {
cwd: "/workspace/project",
model: "openrouter/model-a",
thinkingOptionId: "high",
systemPrompt: "Agent prompt",
},
},
{
daemonAppendSystemPrompt: "Daemon prompt",
},
);
expect(pi.recordedLaunches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
session: "/tmp/native-pi-session.jsonl",
systemPrompt: "Agent prompt\n\nDaemon prompt",
argv: [
"pi",
"--mode",
"rpc",
"--model",
"openrouter/model-a",
"--thinking",
"high",
"--session",
"/tmp/native-pi-session.jsonl",
"--append-system-prompt",
"Agent prompt\n\nDaemon prompt",
],
}),
]);
});
test("updates model and thinking through Pi runtime commands", async () => {
const { pi, session } = await createSession();
const fakeSession = pi.latestSession();
fakeSession.setModelResult = { provider: "openrouter", id: "model-a", name: "Model A" };
await session.setModel("openrouter/model-a");
await session.setThinkingOption("high");
expect(fakeSession.setModelRequests).toEqual([{ provider: "openrouter", modelId: "model-a" }]);
expect(fakeSession.setThinkingLevelRequests).toEqual(["high"]);
});
test("fails the active turn when the Pi process exits mid-turn", async () => {
const { pi, session, events } = await createSession();
await session.startTurn("hello");
pi.latestSession().emit({ type: "process_exit", error: "Pi exited" });
await expect(events.nextTurnFailure()).resolves.toMatchObject({
error: "Pi exited",
});
});
});
describe("PiRpcAgentClient", () => {
test("lists models from a short-lived Pi session in the requested cwd", async () => {
const pi = new FakePi();
const client = createClient(pi);
const modelsPromise = client.listModels({ cwd: "/workspace/with-extension", force: false });
pi.latestSession().models = [
{
provider: "openrouter",
id: "google/gemini-2.5-flash-lite",
name: "google/gemini-2.5-flash-lite",
reasoning: true,
},
];
await expect(modelsPromise).resolves.toMatchObject([
{
provider: "pi",
id: "openrouter/google/gemini-2.5-flash-lite",
label: "gemini-2.5-flash-lite",
defaultThinkingOptionId: "medium",
},
]);
expect(pi.recordedLaunches[0]).toMatchObject({ cwd: "/workspace/with-extension" });
});
test("maps extension, prompt, and skill commands to Paseo slash commands", async () => {
const { pi, session } = await createSession();
pi.latestSession().commands = [
{ name: "review", description: "Review changes", source: "extension" },
{ name: "fix-tests", description: "Fix tests", source: "prompt" },
{ name: "skill:docs", description: "Read docs", source: "skill" },
];
await expect(session.listCommands()).resolves.toEqual([
{ name: "review", description: "Review changes", argumentHint: "" },
{ name: "fix-tests", description: "Fix tests", argumentHint: "" },
{ name: "skill:docs", description: "Read docs", argumentHint: "" },
]);
});
test("injects MCP servers through pi-mcp-adapter when the extension is loaded", async () => {
const pi = new FakePi();
pi.queueCommands([
{
name: "mcp",
description: "Show MCP server status",
source: "extension",
sourceInfo: { source: "npm:pi-mcp-adapter" },
},
]);
const client = createClient(pi);
const session = await client.createSession(
createConfig({
mcpServers: {
paseo: {
type: "http",
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
},
localSecret: {
type: "stdio",
command: "node",
args: ["secret-server.js"],
env: { SECRET_NUMBER: "314159" },
},
},
}),
);
expect(pi.recordedLaunches).toHaveLength(2);
expect(pi.recordedLaunches[0]).toMatchObject({
cwd: "/tmp/paseo-pi-rpc-test",
argv: ["pi", "--mode", "rpc"],
});
const actualLaunch = pi.recordedLaunches[1];
expect(actualLaunch.argv).toEqual([
"pi",
"--mode",
"rpc",
"--thinking",
"medium",
"--mcp-config",
actualLaunch.mcpConfigPath,
]);
expect(session.capabilities.supportsMcpServers).toBe(true);
const configPath = actualLaunch.mcpConfigPath;
expect(configPath).toEqual(expect.any(String));
const injectedConfig = JSON.parse(readFileSync(configPath!, "utf8")) as {
mcpServers: Record<string, unknown>;
};
expect(injectedConfig).toEqual({
mcpServers: {
paseo: {
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
auth: false,
oauth: false,
},
localSecret: {
command: "node",
args: ["secret-server.js"],
env: { SECRET_NUMBER: "314159" },
},
},
});
await session.close();
expect(existsSync(configPath!)).toBe(false);
});
test("does not pass MCP config when pi-mcp-adapter is not loaded", async () => {
const pi = new FakePi();
pi.queueCommands([]);
const client = createClient(pi);
const session = await client.createSession(
createConfig({
mcpServers: {
paseo: {
type: "http",
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
},
},
}),
);
expect(pi.recordedLaunches).toHaveLength(2);
expect(pi.recordedLaunches[1]?.argv).toEqual(["pi", "--mode", "rpc", "--thinking", "medium"]);
expect(pi.recordedLaunches[1]?.mcpConfigPath).toBeUndefined();
expect(session.capabilities.supportsMcpServers).toBe(false);
});
test("lists persisted Pi sessions from the configured Pi agent directory", async () => {
const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-client-"));
const cwd = path.join(root, "workspace");
const agentDir = path.join(root, "agent");
const sessionsDir = path.join(agentDir, "sessions", "--workspace--");
mkdirSync(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "20260101_session.jsonl");
writeFileSync(
sessionFile,
[
JSON.stringify({
type: "session",
version: 3,
id: "pi-session",
timestamp: "2026-01-01T00:00:00.000Z",
cwd,
}),
JSON.stringify({
type: "message",
id: "entry-1",
parentId: null,
timestamp: "2026-01-01T00:00:01.000Z",
message: { role: "user", content: "remember this" },
}),
].join("\n") + "\n",
"utf8",
);
const client = createClientWithPiAgentDir(agentDir);
await expect(client.listPersistedAgents({ cwd })).resolves.toMatchObject([
{
provider: "pi",
sessionId: "pi-session",
cwd,
persistence: {
provider: "pi",
sessionId: "pi-session",
nativeHandle: sessionFile,
metadata: { provider: "pi", cwd },
},
timeline: [{ type: "user_message", text: "remember this" }],
},
]);
});
});
describe("transformPiModels", () => {
test("normalizes labels that include the upstream provider prefix", () => {
expect(
transformPiModels([
{
provider: "pi",
id: "openrouter/google/gemini-2.5-flash-lite",
label: "openrouter/google/gemini_2.5 flash lite",
},
]),
).toEqual([
{
provider: "pi",
id: "openrouter/google/gemini-2.5-flash-lite",
label: "gemini 2.5 flash lite",
description: "openrouter/google/gemini_2.5 flash lite",
},
]);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,188 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import pino from "pino";
import { describe, expect, test } from "vitest";
import { PiCliRuntime } from "./cli-runtime.js";
import type { PiRuntimeLaunch } from "./runtime.js";
type PiChild = ChildProcessWithoutNullStreams & {
stdin: PassThrough;
stdout: PassThrough;
stderr: PassThrough;
killedSignals: Array<NodeJS.Signals | number | undefined>;
};
function createPiChild(): PiChild {
const child = Object.assign(new EventEmitter(), {
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
exitCode: null,
signalCode: null,
killedSignals: [],
}) as PiChild;
child.kill = ((signal?: NodeJS.Signals | number) => {
child.killedSignals.push(signal);
queueMicrotask(() => child.emit("exit", null, signal ?? null));
return true;
}) as ChildProcessWithoutNullStreams["kill"];
return child;
}
function createRuntime(child: PiChild, launches: PiRuntimeLaunch[] = []): PiCliRuntime {
return new PiCliRuntime({
logger: pino({ level: "silent" }),
command: ["pi"],
spawnProcess: (launch) => {
launches.push(launch);
return child;
},
});
}
function replyToCommands(
child: PiChild,
handler: (command: Record<string, unknown>) => unknown,
): void {
let buffer = "";
child.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) break;
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
const command = JSON.parse(line) as Record<string, unknown>;
const result = handler(command);
child.stdout.write(
`${JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: true,
data: result,
})}\n`,
);
}
});
}
describe("PiCliRuntime", () => {
test("starts pi in rpc mode and resolves command responses", async () => {
const child = createPiChild();
replyToCommands(child, (command) =>
command.type === "get_state"
? {
sessionId: "pi-session-1",
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
messageCount: 0,
pendingMessageCount: 0,
}
: {},
);
const launches: PiRuntimeLaunch[] = [];
const runtime = createRuntime(child, launches);
const session = await runtime.startSession({ cwd: "/workspace/project" });
await expect(session.getState()).resolves.toMatchObject({
sessionId: "pi-session-1",
thinkingLevel: "medium",
});
expect(launches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
argv: ["pi", "--mode", "rpc"],
}),
]);
});
test("passes an MCP config path to Pi", async () => {
const child = createPiChild();
replyToCommands(child, () => ({}));
const launches: PiRuntimeLaunch[] = [];
const runtime = createRuntime(child, launches);
await runtime.startSession({
cwd: "/workspace/project",
mcpConfigPath: "/tmp/paseo-pi-mcp/mcp.json",
});
expect(launches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
mcpConfigPath: "/tmp/paseo-pi-mcp/mcp.json",
argv: ["pi", "--mode", "rpc", "--mcp-config", "/tmp/paseo-pi-mcp/mcp.json"],
}),
]);
});
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: [] }));
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const events: unknown[] = [];
session.onEvent((event) => events.push(event));
child.stdout.write(`${JSON.stringify({ type: "turn_start" })}\n`);
await session.getAvailableModels();
expect(events).toEqual([{ type: "turn_start" }]);
});
test("keeps unicode line separators inside one JSONL record", async () => {
const child = createPiChild();
replyToCommands(child, () => ({}));
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const events: unknown[] = [];
session.onEvent((event) => events.push(event));
child.stdout.write(`${JSON.stringify({ type: "message", text: "a\u2028b\u2029c" })}\n`);
expect(events).toEqual([{ type: "message", text: "a\u2028b\u2029c" }]);
});
test("rejects pending commands when the Pi process exits", async () => {
const child = createPiChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const state = session.getState();
child.stderr.write("boom");
child.emit("exit", 1, null);
await expect(state).rejects.toThrow("boom");
});
test("disposes the Pi process", async () => {
const child = createPiChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await session.close();
expect(child.killedSignals).toContain("SIGTERM");
});
});
@@ -0,0 +1,282 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { Logger } from "pino";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import {
buildPiLaunch,
type PiRuntime,
type PiRuntimeLaunch,
type PiRuntimeSession,
type PiStartSessionInput,
} from "./runtime.js";
import type {
PiAgentMessage,
PiModel,
PiRpcCommand,
PiRpcResponse,
PiRpcSlashCommand,
PiRuntimeEvent,
PiSessionState,
PiSessionStats,
} from "./rpc-types.js";
const DEFAULT_PI_COMMAND: [string, ...string[]] = [
process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi",
];
const DEFAULT_TIMEOUT_MS = 30_000;
const STDERR_BUFFER_LIMIT = 8192;
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}
export interface PiCliRuntimeOptions {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
command?: [string, ...string[]];
spawnProcess?: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
}
export class PiCliRuntime implements PiRuntime {
private readonly command: [string, ...string[]];
private readonly spawnProcess: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
constructor(private readonly options: PiCliRuntimeOptions) {
this.command = options.command ?? DEFAULT_PI_COMMAND;
this.spawnProcess =
options.spawnProcess ??
((launch) => {
const [command, ...args] = launch.argv;
return spawn(command, args, {
cwd: launch.cwd,
env: launch.env ? { ...process.env, ...launch.env } : process.env,
stdio: ["pipe", "pipe", "pipe"],
});
});
}
async startSession(input: PiStartSessionInput): Promise<PiRuntimeSession> {
const launch = buildPiLaunch({
command: this.command,
runtimeSettings: this.options.runtimeSettings,
session: input,
});
return new PiCliRuntimeSession(launch, this.spawnProcess(launch), this.options.logger);
}
}
class PiCliRuntimeSession implements PiRuntimeSession {
private readonly pending = new Map<string, PendingRequest>();
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
private stderrBuffer = "";
private nextRequestId = 1;
private disposed = false;
private stdoutBuffer = "";
constructor(
_launch: PiRuntimeLaunch,
private readonly child: ChildProcessWithoutNullStreams,
private readonly logger: Logger,
) {
child.stdout.on("data", (chunk) => {
this.handleStdoutChunk(chunk.toString());
});
child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk.toString();
if (this.stderrBuffer.length > STDERR_BUFFER_LIMIT) {
this.stderrBuffer = this.stderrBuffer.slice(-STDERR_BUFFER_LIMIT);
}
});
child.on("error", (error) => {
this.failAll(error instanceof Error ? error : new Error(String(error)));
});
child.on("exit", (code, signal) => {
const error = new Error(
`Pi RPC process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
);
this.emit({ type: "process_exit", error: error.message });
this.failAll(error);
});
}
onEvent(callback: (event: PiRuntimeEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void> {
await this.request({ type: "prompt", message, ...(images?.length ? { images } : {}) });
}
async abort(): Promise<void> {
await this.request({ type: "abort" });
}
async getState(): Promise<PiSessionState> {
return (await this.request({ type: "get_state" })) as PiSessionState;
}
async getMessages(): Promise<PiAgentMessage[]> {
const data = (await this.request({ type: "get_messages" })) as { messages?: PiAgentMessage[] };
return data.messages ?? [];
}
async getAvailableModels(): Promise<PiModel[]> {
const data = (await this.request({ type: "get_available_models" })) as { models?: PiModel[] };
return data.models ?? [];
}
async setModel(provider: string, modelId: string): Promise<PiModel> {
return (await this.request({ type: "set_model", provider, modelId })) as PiModel;
}
async setThinkingLevel(level: string): Promise<void> {
await this.request({ type: "set_thinking_level", level: level as never });
}
async getSessionStats(): Promise<PiSessionStats> {
return (await this.request({ type: "get_session_stats" })) as PiSessionStats;
}
async getCommands(): Promise<PiRpcSlashCommand[]> {
const data = (await this.request({ type: "get_commands" })) as {
commands?: PiRpcSlashCommand[];
};
return data.commands ?? [];
}
cancelExtensionUiRequest(id: string): void {
this.writeJsonLine({ type: "extension_ui_response", id, cancelled: true });
}
async close(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
try {
this.child.stdin.end();
} catch {
// ignore
}
const result = await terminateWithTreeKill(this.child, {
gracefulTimeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS,
forceTimeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS,
onForceSignal: () => {
this.logger.warn(
{ timeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS },
"Pi RPC process did not exit after SIGTERM; sending SIGKILL",
);
},
});
if (result === "kill-timeout") {
this.logger.warn(
{ timeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS },
"Pi RPC process did not report exit after SIGKILL",
);
}
}
private request(command: PiRpcCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
if (this.disposed) {
return Promise.reject(new Error("Pi RPC session is closed"));
}
const id = `req_${this.nextRequestId}`;
this.nextRequestId += 1;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(
new Error(`Pi RPC request timed out for ${command.type}\n${this.stderrBuffer}`.trim()),
);
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
this.writeJsonLine({ ...command, id });
});
}
private writeJsonLine(value: unknown): void {
if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
return;
}
this.child.stdin.write(`${JSON.stringify(value)}\n`);
}
private handleStdoutChunk(chunk: string): void {
this.stdoutBuffer += chunk;
for (;;) {
const newlineIndex = this.stdoutBuffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = this.stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, "");
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
if (line.trim()) {
this.handleLine(line);
}
}
}
private handleLine(line: string): void {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (error) {
this.logger.warn({ error, line }, "Ignoring non-JSON Pi RPC stdout line");
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return;
}
const message = parsed as Record<string, unknown>;
if (message.type === "response") {
this.handleResponse(message as unknown as PiRpcResponse);
return;
}
this.emit(message as PiRuntimeEvent);
}
private handleResponse(response: PiRpcResponse): void {
const id = response.id;
if (!id) {
return;
}
const pending = this.pending.get(id);
if (!pending) {
return;
}
clearTimeout(pending.timer);
this.pending.delete(id);
if (!response.success) {
pending.reject(new Error(response.error ?? `Pi RPC ${response.command} failed`));
return;
}
pending.resolve(response.data);
}
private emit(event: PiRuntimeEvent): void {
for (const subscriber of this.subscribers) {
subscriber(event);
}
}
private failAll(error: Error): void {
if (this.disposed) {
return;
}
this.disposed = true;
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pending.clear();
}
}
@@ -0,0 +1,127 @@
import { describe, expect, test } from "vitest";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import type { PiAgentMessage } from "./rpc-types.js";
import { streamPiHistory } from "./history-mapper.js";
async function collectHistory(messages: PiAgentMessage[]): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
for await (const event of streamPiHistory("pi", messages)) {
events.push(event);
}
return events;
}
describe("Pi history mapper", () => {
test("replays user, assistant, reasoning, and completed tool calls", async () => {
await expect(
collectHistory([
{
role: "user",
content: [
{ type: "text", text: "read this" },
{ type: "image", data: "base64", mimeType: "image/png" },
{ type: "text", text: "then answer" },
],
},
{
role: "assistant",
content: [
{ type: "thinking", thinking: "checking file" },
{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "note.txt" } },
{ type: "text", text: "done" },
],
},
{
role: "toolResult",
toolCallId: "tool-1",
toolName: "read",
content: [{ type: "text", text: "file contents" }],
},
]),
).resolves.toEqual([
{
type: "timeline",
provider: "pi",
item: {
type: "user_message",
text: "read this\n\nthen answer",
messageId: "pi-user-0",
},
},
{
type: "timeline",
provider: "pi",
item: { type: "reasoning", text: "checking file" },
},
{
type: "timeline",
provider: "pi",
item: {
type: "tool_call",
callId: "tool-1",
name: "read",
status: "running",
detail: {
type: "read",
filePath: "note.txt",
content: undefined,
offset: undefined,
limit: undefined,
},
error: null,
},
},
{
type: "timeline",
provider: "pi",
item: { type: "assistant_message", text: "done" },
},
{
type: "timeline",
provider: "pi",
item: {
type: "tool_call",
callId: "tool-1",
name: "read",
status: "completed",
detail: {
type: "read",
filePath: "note.txt",
content: "file contents",
offset: undefined,
limit: undefined,
},
error: null,
},
},
]);
});
test("replays bash execution records as completed shell calls", async () => {
await expect(
collectHistory([
{
role: "bashExecution",
command: "echo hi",
output: "hi\n",
exitCode: 0,
timestamp: 123,
},
]),
).resolves.toEqual([
{
type: "timeline",
provider: "pi",
item: {
type: "tool_call",
callId: "pi-bash-123",
name: "bash",
status: "completed",
detail: { type: "shell", command: "echo hi", output: "hi\n", exitCode: 0 },
error: null,
},
},
]);
});
});
@@ -0,0 +1,169 @@
import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js";
import type { PiAgentMessage, PiImageContent, PiTextContent } from "./rpc-types.js";
import {
extractTextFromToolResult,
mapToolDetail,
parseToolArgs,
parseToolResult,
type PiTrackedToolCall,
} from "./tool-call-mapper.js";
function isTextContentBlock(block: unknown): block is PiTextContent {
return (
typeof block === "object" &&
block !== null &&
!Array.isArray(block) &&
Reflect.get(block, "type") === "text" &&
typeof Reflect.get(block, "text") === "string"
);
}
export function getUserMessageText(content: string | (PiTextContent | PiImageContent)[]): string {
if (typeof content === "string") {
return content;
}
const textParts: string[] = [];
for (const block of content) {
if (isTextContentBlock(block)) {
textParts.push(block.text);
}
}
return textParts.join("\n\n");
}
export async function* streamPiHistory(
provider: string,
messages: PiAgentMessage[],
): AsyncGenerator<AgentStreamEvent> {
const pendingToolCalls = new Map<string, PiTrackedToolCall>();
let userIndex = 0;
for (const message of messages) {
if (message.role === "user") {
const text = getUserMessageText(message.content);
if (text) {
yield {
type: "timeline",
provider,
item: {
type: "user_message",
text,
messageId: `pi-user-${userIndex}`,
},
};
}
userIndex += 1;
continue;
}
if (message.role === "assistant") {
for (const content of message.content) {
if (content.type === "text" && content.text) {
yield {
type: "timeline",
provider,
item: { type: "assistant_message", text: content.text },
};
continue;
}
if (content.type === "thinking" && content.thinking) {
yield {
type: "timeline",
provider,
item: { type: "reasoning", text: content.thinking },
};
continue;
}
if (content.type === "toolCall") {
const tracked = parseToolArgs(content.name, content.arguments);
pendingToolCalls.set(content.id, tracked);
yield {
type: "timeline",
provider,
item: {
type: "tool_call",
callId: content.id,
name: tracked.toolName,
status: "running",
detail: mapToolDetail(tracked, null),
error: null,
},
};
}
}
continue;
}
if (message.role === "toolResult") {
const tracked =
pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null);
pendingToolCalls.delete(message.toolCallId);
const result = parseToolResult({ content: message.content });
const detail = mapToolDetail(tracked, result);
yield {
type: "timeline",
provider,
item: toToolResultTimelineItem({
callId: message.toolCallId,
name: tracked.toolName,
isError: Boolean(message.isError),
detail,
errorText: extractTextFromToolResult(result) ?? "Tool call failed",
}),
};
continue;
}
if (message.role === "bashExecution") {
const callId = `pi-bash-${message.timestamp}`;
const detail: ToolCallDetail = {
type: "shell",
command: message.command,
output: message.output,
exitCode: message.exitCode ?? null,
};
yield {
type: "timeline",
provider,
item: {
type: "tool_call",
callId,
name: "bash",
status: message.cancelled ? "canceled" : "completed",
detail,
error: null,
},
};
}
}
}
function toToolResultTimelineItem(input: {
callId: string;
name: string;
isError: boolean;
detail: ToolCallDetail;
errorText: string;
}): AgentTimelineItem {
if (input.isError) {
return {
type: "tool_call",
callId: input.callId,
name: input.name,
status: "failed",
detail: input.detail,
error: input.errorText,
};
}
return {
type: "tool_call",
callId: input.callId,
name: input.name,
status: "completed",
detail: input.detail,
error: null,
};
}
@@ -0,0 +1,172 @@
export type PiThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
export interface PiImageContent {
type: "image";
data: string;
mimeType: string;
}
export interface PiTextContent {
type: "text";
text: string;
}
export interface PiThinkingContent {
type: "thinking";
thinking: string;
}
export interface PiToolCallContent {
type: "toolCall";
id: string;
name: string;
arguments: unknown;
}
export type PiAssistantContent = PiTextContent | PiThinkingContent | PiToolCallContent;
export type PiAgentMessage =
| {
role: "user";
content: string | Array<PiTextContent | PiImageContent>;
}
| {
role: "assistant";
content: PiAssistantContent[];
errorMessage?: string | null;
stopReason?: string;
}
| {
role: "toolResult";
toolCallId: string;
toolName: string;
content: unknown;
isError?: boolean;
}
| {
role: "bashExecution";
command: string;
output?: string;
exitCode?: number | null;
cancelled?: boolean;
timestamp: number;
};
export interface PiModel {
provider: string;
id: string;
name?: string;
reasoning?: boolean;
contextWindow?: number;
maxTokens?: number;
api?: string;
baseUrl?: string;
input?: string[];
cost?: Record<string, unknown>;
compat?: unknown;
}
export interface PiSessionState {
model?: PiModel | null;
thinkingLevel: PiThinkingLevel;
isStreaming: boolean;
isCompacting: boolean;
sessionFile?: string;
sessionId: string;
sessionName?: string;
messageCount: number;
pendingMessageCount: number;
}
export interface PiSessionStats {
tokens?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
cost?: number;
contextUsage?: {
tokens?: number | null;
contextWindow?: number | null;
percent?: number | null;
};
}
export interface PiRpcSlashCommand {
name: string;
description?: string;
source: "extension" | "prompt" | "skill";
sourceInfo?: Record<string, unknown>;
}
export type PiRpcCommand =
| { id?: string; type: "prompt"; message: string; images?: PiImageContent[] }
| { id?: string; type: "abort" }
| { id?: string; type: "get_state" }
| { id?: string; type: "get_messages" }
| { id?: string; type: "get_available_models" }
| { id?: string; type: "set_model"; provider: string; modelId: string }
| { id?: string; type: "set_thinking_level"; level: PiThinkingLevel }
| { id?: string; type: "get_session_stats" }
| { id?: string; type: "get_commands" };
export interface PiRpcResponse {
id?: string;
type: "response";
command: string;
success: boolean;
data?: unknown;
error?: string;
}
export type PiAssistantMessageEvent =
| { type: "text_delta"; delta?: string }
| { type: "thinking_delta"; delta?: string }
| { type: "start" | "text_start" | "text_end" | "thinking_start" | "thinking_end" | "done" };
export type PiAgentSessionEvent =
| { type: "agent_start" }
| { type: "turn_start" }
| {
type: "message_update";
message: PiAgentMessage;
assistantMessageEvent: PiAssistantMessageEvent;
}
| {
type: "tool_execution_start";
toolCallId: string;
toolName: string;
args: unknown;
}
| {
type: "tool_execution_update";
toolCallId: string;
toolName: string;
args?: unknown;
partialResult: unknown;
}
| {
type: "tool_execution_end";
toolCallId: string;
toolName: string;
result: unknown;
isError?: boolean;
}
| { type: "compaction_start"; reason?: "manual" | "threshold" | "overflow" | string }
| { type: "compaction_end"; reason?: string; errorMessage?: string; aborted?: boolean }
| { type: "agent_end"; messages?: PiAgentMessage[] };
export type PiRuntimeEvent =
| PiAgentSessionEvent
| {
type: "extension_ui_request";
id: string;
method: string;
[key: string]: unknown;
}
| {
type: "process_exit";
error: string;
};
@@ -0,0 +1,106 @@
import type {
PiAgentMessage,
PiModel,
PiRpcSlashCommand,
PiRuntimeEvent,
PiSessionState,
PiSessionStats,
} from "./rpc-types.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
export interface PiRuntimeLaunch {
cwd: string;
argv: string[];
env?: Record<string, string>;
model?: string;
thinkingOptionId?: string;
session?: string;
systemPrompt?: string;
mcpConfigPath?: string;
}
export interface PiStartSessionInput {
cwd: string;
model?: string;
thinkingOptionId?: string;
session?: string;
systemPrompt?: string;
mcpConfigPath?: string;
}
export interface PiRuntimeSession {
onEvent(callback: (event: PiRuntimeEvent) => void): () => void;
prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void>;
abort(): Promise<void>;
getState(): Promise<PiSessionState>;
getMessages(): Promise<PiAgentMessage[]>;
getAvailableModels(): Promise<PiModel[]>;
setModel(provider: string, modelId: string): Promise<PiModel>;
setThinkingLevel(level: string): Promise<void>;
getSessionStats(): Promise<PiSessionStats>;
getCommands(): Promise<PiRpcSlashCommand[]>;
cancelExtensionUiRequest(id: string): void;
close(): Promise<void>;
}
export interface PiRuntime {
startSession(input: PiStartSessionInput): Promise<PiRuntimeSession>;
}
export function buildPiLaunch(input: {
command: [string, ...string[]];
runtimeSettings?: ProviderRuntimeSettings;
session: PiStartSessionInput;
}): PiRuntimeLaunch {
const command =
input.runtimeSettings?.command?.mode === "replace" && input.runtimeSettings.command.argv[0]
? input.runtimeSettings.command.argv
: input.command;
const argv = [...command];
if (!hasModeRpc(argv)) {
argv.push("--mode", "rpc");
}
if (input.session.model) {
argv.push("--model", input.session.model);
}
if (input.session.thinkingOptionId) {
argv.push("--thinking", input.session.thinkingOptionId);
}
if (input.session.session) {
argv.push("--session", input.session.session);
}
const systemPrompt = input.session.systemPrompt?.trim();
if (systemPrompt) {
argv.push("--append-system-prompt", systemPrompt);
}
if (input.session.mcpConfigPath) {
argv.push("--mcp-config", input.session.mcpConfigPath);
}
return {
cwd: input.session.cwd,
argv,
env: input.runtimeSettings?.env,
model: input.session.model,
thinkingOptionId: input.session.thinkingOptionId,
session: input.session.session,
systemPrompt,
mcpConfigPath: input.session.mcpConfigPath,
};
}
function hasModeRpc(argv: string[]): boolean {
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--mode" && argv[i + 1] === "rpc") {
return true;
}
if (argv[i] === "--mode=rpc") {
return true;
}
}
return false;
}
@@ -0,0 +1,297 @@
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { listPiPersistedAgents } from "./session-descriptor.js";
const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
function tempRoot(): string {
return mkdtempSync(path.join(tmpdir(), "paseo-pi-sessions-"));
}
function writeJsonl(filePath: string, entries: unknown[]): string {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8");
return filePath;
}
function piSession(overrides: {
id: string;
cwd: string;
timestamp?: string;
entries?: unknown[];
}): unknown[] {
return [
{
type: "session",
version: 3,
id: overrides.id,
timestamp: overrides.timestamp ?? "2026-01-01T00:00:00.000Z",
cwd: overrides.cwd,
},
...(overrides.entries ?? []),
];
}
function message(input: {
role: "user" | "assistant";
content: unknown;
timestamp: string;
}): unknown {
return {
type: "message",
id: `entry-${input.timestamp}`,
parentId: null,
timestamp: input.timestamp,
message: {
role: input.role,
content: input.content,
},
};
}
function sessionInfo(name: string, timestamp: string): unknown {
return {
type: "session_info",
id: `info-${timestamp}`,
parentId: null,
timestamp,
name,
};
}
describe("listPiPersistedAgents", () => {
test("lists Pi sessions from the configured agent dir", async () => {
const root = tempRoot();
const cwd = path.join(root, "project");
const sessionsDir = path.join(root, "agent", "sessions", "--project--");
const sessionFile = writeJsonl(
path.join(sessionsDir, "20260101_session-a.jsonl"),
piSession({
id: "session-a",
cwd,
entries: [
message({
role: "user",
content: "first prompt",
timestamp: "2026-01-01T00:00:01.000Z",
}),
message({
role: "assistant",
content: [{ type: "text", text: "answer" }],
timestamp: "2026-01-01T00:00:02.000Z",
}),
message({
role: "user",
content: [{ type: "text", text: "last prompt" }],
timestamp: "2026-01-01T00:00:03.000Z",
}),
sessionInfo("Named Pi Session", "2026-01-01T00:00:10.000Z"),
],
}),
);
const descriptors = await listPiPersistedAgents({
cwd,
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
homeDir: root,
});
expect(descriptors).toEqual([
{
provider: "pi",
sessionId: "session-a",
cwd,
title: "Named Pi Session",
lastActivityAt: new Date("2026-01-01T00:00:03.000Z"),
persistence: {
provider: "pi",
sessionId: "session-a",
nativeHandle: sessionFile,
metadata: {
provider: "pi",
cwd,
},
},
timeline: [
{ type: "user_message", text: "first prompt" },
{ type: "user_message", text: "last prompt" },
],
},
]);
});
test("uses the runtime session-dir override before Pi settings", async () => {
const root = tempRoot();
const cwd = path.join(root, "project");
const agentDir = path.join(root, "agent");
const runtimeSessionsDir = path.join(root, "runtime-sessions");
const settingsSessionsDir = path.join(root, "settings-sessions");
mkdirSync(agentDir, { recursive: true });
writeFileSync(
path.join(agentDir, "settings.json"),
JSON.stringify({ sessionDir: settingsSessionsDir }),
"utf8",
);
writeJsonl(
path.join(settingsSessionsDir, "ignored.jsonl"),
piSession({ id: "settings-session", cwd }),
);
writeJsonl(
path.join(runtimeSessionsDir, "selected.jsonl"),
piSession({
id: "runtime-session",
cwd,
entries: [
message({
role: "user",
content: "from runtime dir",
timestamp: "2026-01-01T00:00:01.000Z",
}),
],
}),
);
const descriptors = await listPiPersistedAgents({
cwd,
runtimeSettings: {
env: {
PI_CODING_AGENT_DIR: agentDir,
PI_CODING_AGENT_SESSION_DIR: runtimeSessionsDir,
},
},
env: {},
homeDir: root,
});
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["runtime-session"]);
});
test("resolves project settings sessionDir relative to the requested cwd", async () => {
const root = tempRoot();
const cwd = path.join(root, "project");
const projectSettingsDir = path.join(cwd, ".pi");
const sessionsDir = path.join(cwd, "relative-sessions");
mkdirSync(projectSettingsDir, { recursive: true });
writeFileSync(
path.join(projectSettingsDir, "settings.json"),
JSON.stringify({ sessionDir: "relative-sessions" }),
"utf8",
);
writeJsonl(
path.join(sessionsDir, "relative.jsonl"),
piSession({
id: "relative-session",
cwd,
entries: [
message({
role: "user",
content: "relative settings",
timestamp: "2026-01-01T00:00:01.000Z",
}),
],
}),
);
const descriptors = await listPiPersistedAgents({
cwd,
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
homeDir: root,
});
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["relative-session"]);
});
test("matches sessions stored with the real cwd when the requested cwd is symlinked", async () => {
const root = tempRoot();
const realCwd = path.join(root, "real-project");
const linkedCwd = path.join(root, "linked-project");
mkdirSync(realCwd, { recursive: true });
symlinkSync(realCwd, linkedCwd, directorySymlinkType);
const persistedCwd = realpathSync(linkedCwd);
const sessionsDir = path.join(root, "agent", "sessions");
writeJsonl(
path.join(sessionsDir, "symlinked.jsonl"),
piSession({
id: "symlinked-session",
cwd: persistedCwd,
entries: [
message({
role: "user",
content: "through symlink",
timestamp: "2026-01-01T00:00:01.000Z",
}),
],
}),
);
const descriptors = await listPiPersistedAgents({
cwd: linkedCwd,
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
homeDir: root,
});
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["symlinked-session"]);
});
test("filters by cwd, sorts by activity, and ignores invalid session files", async () => {
const root = tempRoot();
const cwd = path.join(root, "project");
const otherCwd = path.join(root, "other");
const sessionsDir = path.join(root, "agent", "sessions");
writeJsonl(
path.join(sessionsDir, "older.jsonl"),
piSession({
id: "older",
cwd,
entries: [
message({
role: "user",
content: "older",
timestamp: "2026-01-01T00:00:01.000Z",
}),
],
}),
);
writeJsonl(
path.join(sessionsDir, "newer.jsonl"),
piSession({
id: "newer",
cwd,
entries: [
message({
role: "user",
content: "newer",
timestamp: "2026-01-01T00:00:02.000Z",
}),
],
}),
);
writeJsonl(
path.join(sessionsDir, "outside.jsonl"),
piSession({
id: "outside",
cwd: otherCwd,
entries: [
message({
role: "user",
content: "outside",
timestamp: "2026-01-01T00:00:03.000Z",
}),
],
}),
);
writeFileSync(path.join(sessionsDir, "broken.jsonl"), '{"type":"nope"}\n', "utf8");
const descriptors = await listPiPersistedAgents({
cwd,
limit: 1,
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
homeDir: root,
});
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["newer"]);
});
});
@@ -0,0 +1,372 @@
import { open, readdir, readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import type {
AgentPersistenceHandle,
AgentTimelineItem,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "../../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import { createRealpathAwarePathMatcher } from "../../../../utils/path.js";
const PI_PROVIDER = "pi";
const PI_CONFIG_DIR_NAME = ".pi";
const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
const HEAD_BYTES = 64 * 1024;
const TAIL_BYTES = 256 * 1024;
const FULL_SCAN_LINE_LIMIT = 2_000;
interface PiSessionDescriptorOptions extends ListPersistedAgentsOptions {
runtimeSettings?: ProviderRuntimeSettings;
env?: NodeJS.ProcessEnv;
homeDir?: string;
}
interface PiSessionHeader {
sessionId: string;
cwd: string;
createdAt: Date | null;
}
interface PiSessionTail {
title: string | null;
lastActivityAt: Date | null;
lastUserMessage: string | null;
}
export async function listPiPersistedAgents(
options: PiSessionDescriptorOptions = {},
): Promise<PersistedAgentDescriptor[]> {
const sessionsDir = await resolvePiSessionsDir(options);
const files = await walkJsonlFiles(sessionsDir);
const matchesCwd = options.cwd ? createRealpathAwarePathMatcher(options.cwd) : null;
const limit = options.limit ?? 20;
const descriptors: PersistedAgentDescriptor[] = [];
for (const file of files) {
const descriptor = await readPiSessionDescriptor(file);
if (!descriptor) continue;
if (matchesCwd && !matchesCwd(descriptor.cwd)) continue;
descriptors.push(descriptor);
}
return descriptors
.sort((left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime())
.slice(0, limit);
}
async function resolvePiSessionsDir(options: PiSessionDescriptorOptions): Promise<string> {
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? homedir();
const baseDir = options.cwd ?? process.cwd();
const agentDir = resolvePiAgentDir({ runtimeSettings: options.runtimeSettings, env, homeDir });
const envSessionDir =
options.runtimeSettings?.env?.[PI_SESSION_DIR_ENV] ?? env[PI_SESSION_DIR_ENV];
if (envSessionDir?.trim()) {
return resolveConfigPath(envSessionDir, { baseDir, homeDir });
}
const settingsSessionDir = await readConfiguredSessionDir({
agentDir,
cwd: options.cwd,
});
if (settingsSessionDir?.trim()) {
return resolveConfigPath(settingsSessionDir, { baseDir, homeDir });
}
return path.join(agentDir, "sessions");
}
function resolvePiAgentDir(input: {
runtimeSettings?: ProviderRuntimeSettings;
env: NodeJS.ProcessEnv;
homeDir: string;
}): string {
const configured = input.runtimeSettings?.env?.[PI_AGENT_DIR_ENV] ?? input.env[PI_AGENT_DIR_ENV];
if (configured?.trim()) {
return resolveConfigPath(configured, { baseDir: process.cwd(), homeDir: input.homeDir });
}
return path.join(input.homeDir, PI_CONFIG_DIR_NAME, "agent");
}
async function readConfiguredSessionDir(input: {
agentDir: string;
cwd: string | undefined;
}): Promise<string | null> {
const values = await Promise.all([
readSessionDirFromSettings(path.join(input.agentDir, "settings.json")),
input.cwd
? readSessionDirFromSettings(path.join(input.cwd, PI_CONFIG_DIR_NAME, "settings.json"))
: null,
]);
return values[1] ?? values[0] ?? null;
}
async function readSessionDirFromSettings(settingsPath: string): Promise<string | null> {
try {
const parsed = JSON.parse(await readFile(settingsPath, "utf8")) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const sessionDir = Reflect.get(parsed, "sessionDir");
return typeof sessionDir === "string" && sessionDir.trim() ? sessionDir : null;
} catch {
return null;
}
}
function resolveConfigPath(value: string, options: { baseDir: string; homeDir: string }): string {
if (value === "~") {
return options.homeDir;
}
if (value.startsWith("~/")) {
return path.join(options.homeDir, value.slice(2));
}
return path.isAbsolute(value) ? value : path.resolve(options.baseDir, value);
}
async function walkJsonlFiles(root: string): Promise<string[]> {
let entries: import("node:fs").Dirent[];
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
return [];
}
const files = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(root, entry.name);
if (entry.isDirectory()) {
return await walkJsonlFiles(entryPath);
}
return entry.isFile() && entry.name.endsWith(".jsonl") ? [entryPath] : [];
}),
);
return files.flat();
}
async function readPiSessionDescriptor(filePath: string): Promise<PersistedAgentDescriptor | null> {
const firstLine = await readFirstLine(filePath);
if (!firstLine) return null;
const header = parseSessionHeader(firstLine);
if (!header) return null;
const tail = await readTail(filePath).catch(() => "");
const tailInfo = parseSessionTail(tail);
const headInfo = await scanSessionHead(filePath);
const title = tailInfo.title ?? headInfo.title ?? headInfo.firstUserMessage;
const lastActivityAt =
tailInfo.lastActivityAt ?? (await readFileMtime(filePath)) ?? header.createdAt ?? new Date(0);
const timeline = buildPreviewTimeline({
firstUserMessage: headInfo.firstUserMessage,
lastUserMessage: tailInfo.lastUserMessage,
});
const persistence: AgentPersistenceHandle = {
provider: PI_PROVIDER,
sessionId: header.sessionId,
nativeHandle: filePath,
metadata: {
provider: PI_PROVIDER,
cwd: header.cwd,
},
};
return {
provider: PI_PROVIDER,
sessionId: header.sessionId,
cwd: header.cwd,
title,
lastActivityAt,
persistence,
timeline,
};
}
async function readFirstLine(filePath: string): Promise<string | null> {
const handle = await open(filePath, "r").catch(() => null);
if (!handle) return null;
try {
const buffer = Buffer.alloc(HEAD_BYTES);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
if (bytesRead <= 0) return null;
const chunk = buffer.subarray(0, bytesRead).toString("utf8");
const newlineIndex = chunk.indexOf("\n");
return (newlineIndex === -1 ? chunk : chunk.slice(0, newlineIndex)).trim();
} finally {
await handle.close().catch(() => undefined);
}
}
async function readTail(filePath: string): Promise<string> {
const fileStats = await stat(filePath);
const start = Math.max(0, fileStats.size - TAIL_BYTES);
const length = fileStats.size - start;
const handle = await open(filePath, "r");
try {
const buffer = Buffer.alloc(length);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
await handle.close().catch(() => undefined);
}
}
async function readFileMtime(filePath: string): Promise<Date | null> {
try {
return (await stat(filePath)).mtime;
} catch {
return null;
}
}
function parseSessionHeader(firstLine: string): PiSessionHeader | null {
const entry = parseJsonRecord(firstLine);
if (!entry || entry.type !== "session") return null;
const sessionId = typeof entry.id === "string" ? entry.id : null;
const cwd = typeof entry.cwd === "string" ? entry.cwd : null;
if (!sessionId || !cwd) return null;
const createdAt = parseDate(entry.timestamp);
return { sessionId, cwd, createdAt };
}
function parseSessionTail(tail: string): PiSessionTail {
const lines = tail.split(/\r?\n/);
let title: string | null = null;
let lastActivityAt: Date | null = null;
let fallbackTimestamp: Date | null = null;
let lastUserMessage: string | null = null;
for (let index = lines.length - 1; index >= 0; index -= 1) {
const entry = parseJsonRecord(lines[index].trim());
if (!entry) continue;
if (!title && entry.type === "session_info") {
title = readNonEmptyString(entry.name);
}
const entryTimestamp = parseDate(entry.timestamp);
if (!fallbackTimestamp && entryTimestamp) {
fallbackTimestamp = entryTimestamp;
}
if (entry.type !== "message") {
continue;
}
if (!lastActivityAt && entryTimestamp) {
lastActivityAt = entryTimestamp;
}
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 };
}
async function scanSessionHead(filePath: string): Promise<{
title: string | null;
firstUserMessage: string | null;
}> {
let content: string;
try {
content = await readFile(filePath, "utf8");
} catch {
return { title: null, firstUserMessage: null };
}
let title: string | null = null;
let firstUserMessage: string | null = null;
let lineCount = 0;
for (const rawLine of content.split(/\r?\n/)) {
lineCount += 1;
const entry = parseJsonRecord(rawLine.trim());
if (!entry) continue;
if (entry.type === "session_info") {
title = readNonEmptyString(entry.name) ?? title;
}
if (!firstUserMessage && entry.type === "message" && isRecord(entry.message)) {
if (entry.message.role === "user") {
firstUserMessage = extractMessageText(entry.message.content);
}
}
if (title && firstUserMessage) {
break;
}
if (lineCount >= FULL_SCAN_LINE_LIMIT && firstUserMessage) {
break;
}
}
return { title, firstUserMessage };
}
function buildPreviewTimeline(input: {
firstUserMessage: string | null;
lastUserMessage: string | null;
}): AgentTimelineItem[] {
const items: AgentTimelineItem[] = [];
if (input.firstUserMessage) {
items.push({ type: "user_message", text: input.firstUserMessage });
}
if (input.lastUserMessage && input.lastUserMessage !== input.firstUserMessage) {
items.push({ type: "user_message", text: input.lastUserMessage });
}
return items;
}
function parseJsonRecord(line: string): Record<string, unknown> | null {
if (!line) return null;
try {
const parsed = JSON.parse(line) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function parseDate(value: unknown): Date | null {
if (typeof value !== "string" && typeof value !== "number") {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function extractMessageText(content: unknown): string | null {
if (typeof content === "string") {
return content.trim() || null;
}
if (!Array.isArray(content)) {
return null;
}
const text = content
.flatMap((part) =>
isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [],
)
.join("\n\n")
.trim();
return text || null;
}
@@ -0,0 +1,149 @@
import type {
PiRuntime,
PiRuntimeLaunch,
PiRuntimeSession,
PiStartSessionInput,
} from "../runtime.js";
import type {
PiAgentMessage,
PiModel,
PiRpcSlashCommand,
PiRuntimeEvent,
PiSessionState,
PiSessionStats,
} from "../rpc-types.js";
import { buildPiLaunch } from "../runtime.js";
export class FakePi implements PiRuntime {
readonly recordedLaunches: PiRuntimeLaunch[] = [];
private readonly sessions: FakePiSession[] = [];
private readonly command: [string, ...string[]];
private readonly queuedCommands: PiRpcSlashCommand[][] = [];
constructor(command: [string, ...string[]] = ["pi"]) {
this.command = command;
}
async startSession(input: PiStartSessionInput): Promise<FakePiSession> {
const launch = buildPiLaunch({
command: this.command,
session: input,
});
this.recordedLaunches.push(launch);
const session = new FakePiSession(launch);
session.commands = this.queuedCommands.shift() ?? [];
this.sessions.push(session);
return session;
}
queueCommands(commands: PiRpcSlashCommand[]): void {
this.queuedCommands.push(commands);
}
latestSession(): FakePiSession {
const session = this.sessions.at(-1);
if (!session) {
throw new Error("FakePi has no sessions");
}
return session;
}
}
export class FakePiSession implements PiRuntimeSession {
readonly prompts: Array<{ message: string; imageCount: number }> = [];
readonly setModelRequests: Array<{ provider: string; modelId: string }> = [];
readonly setThinkingLevelRequests: string[] = [];
abortRequested = false;
readonly canceledExtensionUiRequests: string[] = [];
setModelResult: PiModel | null = null;
models: PiModel[] = [];
messages: PiAgentMessage[] = [];
stats: PiSessionStats = {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
};
commands: PiRpcSlashCommand[] = [];
state: PiSessionState;
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
constructor(launch: PiRuntimeLaunch) {
this.state = {
model: null,
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
sessionFile: launch.session ?? "/tmp/pi-session.jsonl",
sessionId: "pi-session-1",
messageCount: 0,
pendingMessageCount: 0,
};
}
onEvent(callback: (event: PiRuntimeEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void> {
this.prompts.push({ message, imageCount: images?.length ?? 0 });
}
async abort(): Promise<void> {
this.abortRequested = true;
}
async getState(): Promise<PiSessionState> {
return this.state;
}
async getMessages(): Promise<PiAgentMessage[]> {
return this.messages;
}
async getAvailableModels(): Promise<PiModel[]> {
return this.models;
}
async setModel(provider: string, modelId: string): Promise<PiModel> {
this.setModelRequests.push({ provider, modelId });
if (!this.setModelResult) {
throw new Error("FakePi setModel requires setModelResult to be scripted");
}
return this.setModelResult;
}
async setThinkingLevel(level: string): Promise<void> {
this.setThinkingLevelRequests.push(level);
}
async getSessionStats(): Promise<PiSessionStats> {
return this.stats;
}
async getCommands(): Promise<PiRpcSlashCommand[]> {
return this.commands;
}
cancelExtensionUiRequest(id: string): void {
this.canceledExtensionUiRequests.push(id);
}
async close(): Promise<void> {}
emit(event: PiRuntimeEvent): void {
for (const subscriber of this.subscribers) {
subscriber(event);
}
}
finishTurn(message: PiAgentMessage = { role: "assistant", content: [] }): void {
this.messages = [...this.messages, message];
this.emit({ type: "agent_end", messages: this.messages });
}
}
@@ -0,0 +1,76 @@
import { describe, expect, test } from "vitest";
import {
mapToolDetail,
parseToolArgs,
parseToolResult,
resolveToolCallName,
} from "./tool-call-mapper.js";
describe("Pi tool call mapper", () => {
test("maps bash args and result to shell detail", () => {
const toolCall = parseToolArgs("bash", { command: "echo hello" });
const result = parseToolResult({ output: "hello\n", exitCode: 0 });
expect(mapToolDetail(toolCall, result)).toEqual({
type: "shell",
command: "echo hello",
output: "hello\n",
exitCode: 0,
});
});
test("maps legacy edit args to edit detail with diff", () => {
const toolCall = parseToolArgs("edit", {
path: "app.ts",
old_string: "before",
new_string: "after",
});
const result = parseToolResult({ details: { diff: "-before\n+after" } });
expect(mapToolDetail(toolCall, result)).toEqual({
type: "edit",
filePath: "app.ts",
oldString: "before",
newString: "after",
unifiedDiff: "-before\n+after",
});
});
test("preserves unknown tool input and parsed output", () => {
const toolCall = parseToolArgs("custom_tool", { value: 42 });
const result = parseToolResult({ text: "custom result" });
expect(mapToolDetail(toolCall, result)).toEqual({
type: "unknown",
input: { value: 42 },
output: { text: "custom result" },
});
});
test("normalizes Pi MCP proxy calls from requested tool args while running", () => {
const toolCall = parseToolArgs("mcp", {
tool: "paseo_list_models",
args: '{"provider":"pi"}',
});
expect(resolveToolCallName(toolCall, null)).toBe("paseo.list_models");
});
test("normalizes Pi MCP proxy calls from result details when completed", () => {
const toolCall = parseToolArgs("mcp", {
tool: "paseo_list_models",
args: '{"provider":"pi"}',
});
const result = parseToolResult({
content: [{ type: "text", text: "(empty result)" }],
details: {
mode: "call",
server: "paseo",
tool: "list_models",
},
});
expect(resolveToolCallName(toolCall, result)).toBe("paseo.list_models");
});
});
@@ -0,0 +1,469 @@
import { z } from "zod";
import type { ToolCallDetail } from "../../agent-sdk-types.js";
interface BashToolInput {
command: string;
timeout?: number;
}
interface ReadToolInput {
path: string;
offset?: number;
limit?: number;
}
interface EditToolInput {
path: string;
edits: Array<{
oldText: string;
newText: string;
}>;
}
interface WriteToolInput {
path: string;
content: string;
}
interface FindToolInput {
pattern: string;
path?: string;
limit?: number;
}
interface GrepToolInput {
pattern: string;
path?: string;
glob?: string;
ignoreCase?: boolean;
literal?: boolean;
context?: number;
limit?: number;
}
interface LsToolInput {
path?: string;
limit?: number;
}
interface PiToolResultObject {
output?: string;
stdout?: string;
text?: string;
content?: PiToolResultContent[];
exitCode?: number;
code?: number;
details?: PiToolResultDetails;
}
interface PiToolResultDetails {
diff?: string;
mode?: string;
server?: string;
tool?: string;
mcpResult?: unknown;
}
interface PiToolResultTextContent {
type: "text";
text: string;
}
interface PiToolResultUnknownContent {
type: string;
}
type PiToolResultContent = PiToolResultTextContent | PiToolResultUnknownContent;
export type PiToolResult = string | PiToolResultObject | null;
interface PiBashToolCall {
kind: "bash";
toolName: "bash";
args: BashToolInput;
}
interface PiReadToolCall {
kind: "read";
toolName: "read";
args: ReadToolInput;
}
interface PiEditToolCall {
kind: "edit";
toolName: "edit";
args: EditToolInput;
}
interface PiWriteToolCall {
kind: "write";
toolName: "write";
args: WriteToolInput;
}
interface PiFindToolCall {
kind: "find";
toolName: "find";
args: FindToolInput;
}
interface PiGrepToolCall {
kind: "grep";
toolName: "grep";
args: GrepToolInput;
}
interface PiLsToolCall {
kind: "ls";
toolName: "ls";
args: LsToolInput;
}
interface PiUnknownToolCall {
kind: "unknown";
toolName: string;
args: unknown;
}
export type PiTrackedToolCall =
| PiBashToolCall
| PiReadToolCall
| PiEditToolCall
| PiWriteToolCall
| PiFindToolCall
| PiGrepToolCall
| PiLsToolCall
| PiUnknownToolCall;
interface ToolCallOutputSummary {
output?: string;
exitCode?: number | null;
}
const PiToolResultTextContentSchema = z.object({
type: z.literal("text"),
text: z.string(),
});
const PiToolResultUnknownContentSchema = z
.object({
type: z.string(),
})
.passthrough();
const PiToolResultContentSchema = z.union([
PiToolResultTextContentSchema,
PiToolResultUnknownContentSchema,
]);
const PiToolResultDetailsSchema = z
.object({
diff: z.string().optional(),
})
.passthrough();
const PiToolResultObjectSchema = z
.object({
output: z.string().optional(),
stdout: z.string().optional(),
text: z.string().optional(),
content: z.array(PiToolResultContentSchema).optional(),
exitCode: z.number().optional(),
code: z.number().optional(),
details: PiToolResultDetailsSchema.optional(),
})
.passthrough();
const PiToolResultSchema = z.union([z.string(), PiToolResultObjectSchema, z.null()]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
const BashToolInputSchema: z.ZodType<BashToolInput> = z.object({
command: z.string(),
timeout: z.number().optional(),
});
const ReadToolInputSchema: z.ZodType<ReadToolInput> = z.object({
path: z.string(),
offset: z.number().optional(),
limit: z.number().optional(),
});
const EditToolInputSchema: z.ZodType<EditToolInput> = z.object({
path: z.string(),
edits: z.array(
z.object({
oldText: z.string(),
newText: z.string(),
}),
),
});
const LegacyEditToolInputSchema = z.object({
path: z.string(),
old_string: z.string().optional(),
oldString: z.string().optional(),
new_string: z.string().optional(),
newString: z.string().optional(),
});
const WriteToolInputSchema: z.ZodType<WriteToolInput> = z.object({
path: z.string(),
content: z.string(),
});
const FindToolInputSchema: z.ZodType<FindToolInput> = z.object({
pattern: z.string(),
path: z.string().optional(),
limit: z.number().optional(),
});
const GrepToolInputSchema: z.ZodType<GrepToolInput> = z.object({
pattern: z.string(),
path: z.string().optional(),
glob: z.string().optional(),
ignoreCase: z.boolean().optional(),
literal: z.boolean().optional(),
context: z.number().optional(),
limit: z.number().optional(),
});
const LsToolInputSchema: z.ZodType<LsToolInput> = z.object({
path: z.string().optional(),
limit: z.number().optional(),
});
export function parseToolResult(rawResult: unknown): PiToolResult {
const parsed = PiToolResultSchema.safeParse(rawResult);
if (parsed.success) {
return parsed.data;
}
return null;
}
export function extractTextFromToolResult(result: PiToolResult): string | undefined {
if (typeof result === "string") {
return result;
}
if (!result) {
return undefined;
}
const directText = result.output ?? result.stdout ?? result.text;
if (directText) {
return directText;
}
if (!result.content) {
return undefined;
}
const textParts: string[] = [];
for (const block of result.content) {
if (block.type === "text" && "text" in block) {
textParts.push(block.text);
}
}
return textParts.length > 0 ? textParts.join("\n") : undefined;
}
export function parseToolArgs(toolName: string, rawArgs: unknown): PiTrackedToolCall {
if (toolName === "edit") {
return parseEditToolArgs(rawArgs);
}
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
if (schema) {
const parsed = schema.safeParse(rawArgs);
if (parsed.success) {
return { kind: toolName as SimpleToolKind, toolName, args: parsed.data } as PiTrackedToolCall;
}
}
return { kind: "unknown", toolName, args: rawArgs ?? null };
}
function stripMcpProxyPrefix(toolName: string, serverName: string): string {
const prefix = `${serverName}_`;
return toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName;
}
export function resolveToolCallName(toolCall: PiTrackedToolCall, result?: PiToolResult): string {
if (toolCall.toolName !== "mcp") {
return toolCall.toolName;
}
if (result && typeof result !== "string") {
const serverName = readNonEmptyString(result.details?.server);
const toolName = readNonEmptyString(result.details?.tool);
if (serverName && toolName) {
return `${serverName}.${toolName}`;
}
}
if (isRecord(toolCall.args)) {
const requestedTool = readNonEmptyString(toolCall.args.tool);
const requestedServer = readNonEmptyString(toolCall.args.server);
if (requestedTool && requestedServer) {
return `${requestedServer}.${stripMcpProxyPrefix(requestedTool, requestedServer)}`;
}
if (requestedTool) {
const [serverName, ...toolParts] = requestedTool.split("_");
if (serverName && toolParts.length > 0) {
return `${serverName}.${toolParts.join("_")}`;
}
}
}
return toolCall.toolName;
}
export function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult): ToolCallDetail {
const parsedResult = result ?? null;
switch (toolCall.kind) {
case "bash": {
const summary = resolveToolCallOutput(parsedResult);
return {
type: "shell",
command: toolCall.args.command,
output: summary.output,
exitCode: summary.exitCode,
};
}
case "read":
return {
type: "read",
filePath: toolCall.args.path,
content: extractTextFromToolResult(parsedResult),
offset: toolCall.args.offset,
limit: toolCall.args.limit,
};
case "edit": {
const firstEdit = toolCall.args.edits[0];
const unifiedDiff =
parsedResult && typeof parsedResult !== "string" ? parsedResult.details?.diff : undefined;
return {
type: "edit",
filePath: toolCall.args.path,
oldString: firstEdit?.oldText,
newString: firstEdit?.newText,
unifiedDiff,
};
}
case "write":
return {
type: "write",
filePath: toolCall.args.path,
content: toolCall.args.content,
};
case "find":
return mapFindToolDetail(toolCall.args, parsedResult);
case "grep":
return mapGrepToolDetail(toolCall.args, parsedResult);
case "ls":
return mapLsToolDetail(toolCall.args, parsedResult);
default:
return {
type: "unknown",
input: toolCall.args,
output: parsedResult,
};
}
}
function resolveToolCallOutput(result: PiToolResult): ToolCallOutputSummary {
if (typeof result === "string") {
return { output: result };
}
if (!result) {
return {};
}
const summary: ToolCallOutputSummary = {
output: extractTextFromToolResult(result),
};
if (typeof result.exitCode === "number") {
summary.exitCode = result.exitCode;
return summary;
}
if (typeof result.code === "number") {
summary.exitCode = result.code;
return summary;
}
summary.exitCode = null;
return summary;
}
function normalizeLegacyEditArgs(rawArgs: unknown): EditToolInput | null {
const parsed = LegacyEditToolInputSchema.safeParse(rawArgs);
if (!parsed.success) {
return null;
}
const oldText = parsed.data.old_string ?? parsed.data.oldString;
const newText = parsed.data.new_string ?? parsed.data.newString;
if (!oldText || newText === undefined) {
return null;
}
return {
path: parsed.data.path,
edits: [{ oldText, newText }],
};
}
function parseEditToolArgs(rawArgs: unknown): PiTrackedToolCall {
const parsed = EditToolInputSchema.safeParse(rawArgs);
if (parsed.success) {
return { kind: "edit", toolName: "edit", args: parsed.data };
}
const legacyArgs = normalizeLegacyEditArgs(rawArgs);
if (legacyArgs) {
return { kind: "edit", toolName: "edit", args: legacyArgs };
}
return { kind: "unknown", toolName: "edit", args: rawArgs ?? null };
}
type SimpleToolKind = "bash" | "read" | "write" | "find" | "grep" | "ls";
const SIMPLE_TOOL_SCHEMAS: {
[K in SimpleToolKind]: { safeParse: (data: unknown) => { success: boolean; data?: unknown } };
} = {
bash: BashToolInputSchema,
read: ReadToolInputSchema,
write: WriteToolInputSchema,
find: FindToolInputSchema,
grep: GrepToolInputSchema,
ls: LsToolInputSchema,
};
function mapFindToolDetail(args: FindToolInput, result: PiToolResult): ToolCallDetail {
return {
type: "search",
query: args.pattern,
toolName: "search",
content: typeof result === "string" ? result : undefined,
};
}
function mapGrepToolDetail(args: GrepToolInput, result: PiToolResult): ToolCallDetail {
return {
type: "search",
query: args.pattern,
toolName: "grep",
content: typeof result === "string" ? result : undefined,
};
}
function mapLsToolDetail(args: LsToolInput, result: PiToolResult): ToolCallDetail {
return {
type: "search",
query: args.path ?? "ls",
content: typeof result === "string" ? result : undefined,
};
}
@@ -0,0 +1,10 @@
export function composeSystemPromptParts(
...parts: Array<string | null | undefined>
): string | undefined {
const prompt = parts
.map((part) => part?.trim())
.filter((part): part is string => typeof part === "string" && part.length > 0)
.join("\n\n");
return prompt.length > 0 ? prompt : undefined;
}
+6
View File
@@ -230,6 +230,7 @@ export interface PaseoDaemonConfig {
mcpEnabled?: boolean;
mcpInjectIntoAgents?: boolean;
autoArchiveAfterMerge?: boolean;
appendSystemPrompt?: string;
staticDir: string;
mcpDebug: boolean;
isDev?: boolean;
@@ -290,6 +291,7 @@ export async function createPaseoDaemon(
]),
),
autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false,
appendSystemPrompt: config.appendSystemPrompt ?? "",
},
logger,
);
@@ -516,6 +518,7 @@ export async function createPaseoDaemon(
},
providerDefinitions: providerRegistry,
registry: agentStorage,
appendSystemPrompt: config.appendSystemPrompt,
logger,
});
@@ -862,6 +865,9 @@ export async function createPaseoDaemon(
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
});
daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
});
const relayEnabled = config.relayEnabled ?? true;
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
+14 -2
View File
@@ -256,6 +256,10 @@ function resolveAuthConfig(
: undefined;
}
function resolveAppendSystemPrompt(persisted: ReturnType<typeof loadPersistedConfig>): string {
return persisted.daemon?.appendSystemPrompt ?? "";
}
function resolveStaticLoadConfigSettings(
env: NodeJS.ProcessEnv,
cli: CliConfigOverrides | undefined,
@@ -266,6 +270,7 @@ function resolveStaticLoadConfigSettings(
mcpInjectIntoAgents:
cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false,
autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false,
appendSystemPrompt: resolveAppendSystemPrompt(persisted),
hostnames: mergeHostnames([
persisted.daemon?.hostnames,
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
@@ -286,8 +291,14 @@ export function loadConfig(
const persisted = loadPersistedConfig(paseoHome);
const listen = resolveListenAddress(env, options?.cli, persisted);
const { mcpEnabled, mcpInjectIntoAgents, autoArchiveAfterMerge, hostnames, appBaseUrl } =
resolveStaticLoadConfigSettings(env, options?.cli, persisted);
const {
mcpEnabled,
mcpInjectIntoAgents,
autoArchiveAfterMerge,
appendSystemPrompt,
hostnames,
appBaseUrl,
} = resolveStaticLoadConfigSettings(env, options?.cli, persisted);
const relay = resolveRelayConfig({
env,
@@ -315,6 +326,7 @@ export function loadConfig(
mcpEnabled,
mcpInjectIntoAgents,
autoArchiveAfterMerge,
appendSystemPrompt,
mcpDebug: env.MCP_DEBUG === "1",
isDev: resolvePaseoNodeEnv(env) === "development",
agentStoragePath: path.join(paseoHome, "agents"),
@@ -107,6 +107,28 @@ describe("DaemonConfigStore", () => {
});
});
test("patch persists append system prompt into config.json", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(
paseoHome,
{
mcp: { injectIntoAgents: false },
providers: {},
appendSystemPrompt: "",
},
undefined,
);
store.patch({
appendSystemPrompt: "Prefer terse replies.",
});
const persisted = loadPersistedConfig(paseoHome);
expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
});
test("patch persists provider additional models into config.json", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -144,6 +166,28 @@ describe("DaemonConfigStore", () => {
});
});
test("patch persists daemon append system prompt into config.json", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(
paseoHome,
{
mcp: { injectIntoAgents: false },
providers: {},
appendSystemPrompt: "",
},
undefined,
);
store.patch({
appendSystemPrompt: "Prefer terse replies.",
});
const persisted = loadPersistedConfig(paseoHome);
expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
});
test("patch persists custom ACP provider overrides into config.json", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -186,6 +186,7 @@ function mergeMutableConfigIntoPersistedConfig(params: {
injectIntoAgents: mutable.mcp.injectIntoAgents,
},
autoArchiveAfterMerge: mutable.autoArchiveAfterMerge,
appendSystemPrompt: mutable.appendSystemPrompt,
},
agents:
providerOverrides && Object.keys(providerOverrides).length > 0
@@ -10,7 +10,7 @@ import type {
AgentStreamEvent,
AgentTimelineItem,
} from "../agent/agent-sdk-types.js";
import { PiDirectAgentClient } from "../agent/providers/pi-direct-agent.js";
import { PiRpcAgentClient } from "../agent/providers/pi/agent.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { isProviderAvailable } from "./agent-configs.js";
@@ -26,28 +26,18 @@ function tmpCwd(prefix = "daemon-real-pi-"): string {
return mkdtempSync(path.join(tmpdir(), prefix));
}
function createPiClient(): PiDirectAgentClient {
return new PiDirectAgentClient({ logger: pino({ level: "silent" }) });
function createPiClient(): PiRpcAgentClient {
return new PiRpcAgentClient({ logger: pino({ level: "silent" }) });
}
function createPiToolDaemon() {
const logger = pino({ level: "silent" });
return createTestPaseoDaemon({
agentClients: { pi: new PiDirectAgentClient({ logger }) },
agentClients: { pi: new PiRpcAgentClient({ logger }) },
logger,
});
}
function extractAssistantText(items: AgentTimelineItem[]): string {
return items
.filter(
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
item.type === "assistant_message",
)
.map((item) => item.text)
.join("\n");
}
function extractCompletedToolCalls(items: AgentTimelineItem[]): ToolCallItem[] {
return items.filter(
(item): item is ToolCallItem => item.type === "tool_call" && item.status === "completed",
@@ -77,7 +67,10 @@ async function withConnectedPiDaemon(
run: (context: { client: DaemonClient }) => Promise<void>,
): Promise<void> {
const daemon = await createPiToolDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.45",
});
try {
await client.connect();
@@ -344,14 +337,16 @@ test(
await client.deleteAgent(agent.id);
const resumed = await client.resumeAgent(handle);
await client.sendMessage(resumed.id, "What was the code I asked you to remember?");
expect(resumed.provider).toBe("pi");
expect(resumed.cwd).toBe(cwd);
await client.sendMessage(resumed.id, "Reply with exactly: resumed");
const resumedFinish = await client.waitForFinish(resumed.id, PI_TEST_TIMEOUT_MS);
expect(resumedFinish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, resumed.id);
const assistantText = extractAssistantText(items).toUpperCase();
expect(assistantText.includes(rememberedToken) || assistantText.includes("42")).toBe(true);
expect(resumedFinish.final?.persistence).toBeTruthy();
expect(resumedFinish.final?.persistence?.provider).toBe("pi");
expect(resumedFinish.final?.persistence?.nativeHandle).toBe(handle.nativeHandle);
});
} finally {
rmSync(cwd, { recursive: true, force: true });
@@ -361,7 +356,7 @@ test(
);
test(
"streamHistory replays user/assistant/tool_call timeline after resume",
"streamHistory replays user and assistant timeline after resume",
async () => {
const cwd = tmpCwd("pi-history-prime-");
const marker = "HISTORY_PRIME_MARKER_4242";
@@ -376,18 +371,8 @@ test(
let handle: AgentPersistenceHandle | null = null;
try {
const result = await session.run(
`Use the bash tool to run this exact bash command: echo ${marker}`,
);
const hadShellToolCall = result.timeline.some(
(item) =>
item.type === "tool_call" &&
item.status === "completed" &&
item.detail.type === "shell" &&
item.detail.command.includes(`echo ${marker}`),
);
expect(hadShellToolCall).toBe(true);
const result = await session.run(`Reply with exactly this token and nothing else: ${marker}`);
expect(result.finalText).toContain(marker);
handle = session.describePersistence();
expect(handle).toBeTruthy();
@@ -423,15 +408,7 @@ test(
item.type === "assistant_message",
);
expect(assistantItems.length).toBeGreaterThan(0);
const toolCallItem = findCompletedToolCall(
items,
(item) => item.detail.type === "shell" && item.detail.command.includes(`echo ${marker}`),
);
expect(toolCallItem).toBeDefined();
if (toolCallItem?.detail.type === "shell") {
expect(toolCallItem.detail.output).toContain(marker);
}
expect(assistantItems.some((item) => item.text.includes(marker))).toBe(true);
} finally {
await resumed.close();
rmSync(cwd, { recursive: true, force: true });
@@ -441,7 +418,7 @@ test(
);
test(
"PiDirectAgentClient.listModels returns non-empty Pi model definitions",
"PiRpcAgentClient.listModels returns non-empty Pi model definitions",
async () => {
const client = createPiClient();
const cwd = tmpCwd("pi-list-models-");
@@ -34,6 +34,18 @@ describe("PersistedConfigSchema daemon auth config", () => {
});
});
describe("PersistedConfigSchema daemon append system prompt config", () => {
test("accepts optional append system prompt", () => {
const parsed = PersistedConfigSchema.parse({
daemon: {
appendSystemPrompt: "Prefer terse replies.",
},
});
expect(parsed.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
});
});
describe("PersistedConfigSchema daemon relay config", () => {
test("accepts optional relay TLS setting", () => {
const parsed = PersistedConfigSchema.parse({
@@ -51,6 +63,18 @@ describe("PersistedConfigSchema daemon relay config", () => {
});
});
describe("PersistedConfigSchema daemon append system prompt", () => {
test("accepts optional append system prompt", () => {
const parsed = PersistedConfigSchema.parse({
daemon: {
appendSystemPrompt: "Prefer terse replies.",
},
});
expect(parsed.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
});
});
describe("PersistedConfigSchema agent provider runtime settings", () => {
test("legacy append entries are skipped during migration", () => {
const parsed = PersistedConfigSchema.parse({
@@ -251,6 +251,7 @@ export const PersistedConfigSchema = z
.passthrough()
.optional(),
autoArchiveAfterMerge: z.boolean().optional(),
appendSystemPrompt: z.string().optional(),
cors: z
.object({
allowedOrigins: z.array(z.string()).optional(),
@@ -269,7 +269,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
vi.useRealTimers();
});
test("getSnapshot returns cached warm value without shelling out within consumer TTL", async () => {
test("getSnapshot returns the current snapshot without shelling out", async () => {
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService({
@@ -411,7 +411,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
service.dispose();
});
test("warm non-forced getSnapshot calls attach to an in-flight refresh instead of returning stale cache", async () => {
test("non-forced getSnapshot returns the current snapshot during an in-flight refresh", async () => {
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
const refreshStatus = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi
@@ -434,24 +434,34 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
}),
);
nowMs += 16_000;
const first = service.getSnapshot(REPO_CWD);
await flushPromises();
const second = service.getSnapshot(REPO_CWD);
const initialSnapshot = createSnapshot(REPO_CWD, {
git: { diffStat: { additions: 4, deletions: 2 } },
});
nowMs += 3_000;
const refresh = service.refresh(REPO_CWD);
await flushPromises();
const directRead = service.getSnapshot(REPO_CWD);
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
expect(getCheckoutShortstat).toHaveBeenCalledTimes(1);
await expect(directRead).resolves.toEqual(initialSnapshot);
refreshStatus.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" }));
const freshSnapshot = createSnapshot(REPO_CWD, {
git: {
currentBranch: "feature",
diffStat: { additions: 4, deletions: 2 },
},
});
await expect(Promise.all([first, second])).resolves.toEqual([freshSnapshot, freshSnapshot]);
await refresh;
expect(service.peekSnapshot(REPO_CWD)).toEqual(
createSnapshot(REPO_CWD, {
git: {
currentBranch: "feature",
diffStat: { additions: 4, deletions: 2 },
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
},
}),
);
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
expect(getCheckoutShortstat).toHaveBeenCalledTimes(2);
@@ -543,10 +553,9 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
.fn<() => Promise<CheckoutStatusGit>>()
.mockImplementationOnce(async () => forcedGitRefresh.promise)
.mockImplementation(async () => createCheckoutStatus(REPO_CWD));
const getPullRequestStatus = vi
.fn()
.mockResolvedValueOnce(createPullRequestStatusResult("Stale cached PR"))
.mockResolvedValueOnce(createPullRequestStatusResult("Fresh validation PR"));
const getPullRequestStatus = vi.fn(async () =>
createPullRequestStatusResult("Fresh validation PR"),
);
const service = createService({ getCheckoutStatus, getPullRequestStatus });
const gitRefresh = service.getSnapshot(REPO_CWD, {
@@ -584,7 +593,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
await gitRefresh;
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
expect(getPullRequestStatus).toHaveBeenNthCalledWith(2, REPO_CWD, expect.anything(), {
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
expect(getPullRequestStatus).toHaveBeenCalledWith(REPO_CWD, expect.anything(), {
force: true,
reason: "merge-pr-validation",
});
@@ -634,7 +644,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
service.dispose();
});
test("consumer TTL triggers cold-load after expiry on getSnapshot", async () => {
test("non-forced getSnapshot keeps returning the current snapshot after time passes", async () => {
let nowMs = 0;
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService({
@@ -646,12 +656,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
nowMs = 16_000;
await service.getSnapshot(REPO_CWD);
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
service.dispose();
});
test("self-heal timer fires at the interval and calls getSnapshot without force", async () => {
test("self-heal timer refreshes git without refreshing GitHub", async () => {
let nowMs = 0;
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
@@ -668,9 +678,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
expect(getPullRequestStatus).toHaveBeenLastCalledWith(REPO_CWD, expect.anything(), {
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
expect(getPullRequestStatus).toHaveBeenCalledWith(REPO_CWD, expect.anything(), {
force: false,
reason: "self-heal-git",
reason: "initial",
});
subscription.unsubscribe();
@@ -803,8 +814,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
github,
now: () => new Date(nowMs),
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
const listener = vi.fn();
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
await flushPromises();
await vi.advanceTimersByTimeAsync(0);
await flushPromises();
const gitReadsAfterInitialSnapshot = getCheckoutStatus.mock.calls.length;
nowMs = 20_000;
await vi.advanceTimersByTimeAsync(20_000);
@@ -814,6 +829,73 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
reason: "self-heal-github",
tickMs: 20_000,
});
expect(getCheckoutStatus).toHaveBeenCalledTimes(gitReadsAfterInitialSnapshot);
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
github: expect.objectContaining({
pullRequest: expect.objectContaining({
checksStatus: "pending",
}),
}),
}),
);
subscription.unsubscribe();
service.dispose();
github.dispose?.();
});
test("settled GitHub self-heal reads stay on the slow poll window without refreshing git", async () => {
let nowMs = 0;
const githubReadCalls: Array<{ reason: string | undefined; tickMs: number }> = [];
const github = createGitHubService({
ttlMs: 0,
runner: vi.fn(async () => ({
stdout: currentPullRequestJson(),
stderr: "",
})),
resolveGhPath: async () => "/usr/bin/gh",
now: () => nowMs,
});
const getCurrentPullRequestStatus = github.getCurrentPullRequestStatus.bind(github);
github.getCurrentPullRequestStatus = vi.fn(
async (options): Promise<GitHubCurrentPullRequestStatus | null> => {
githubReadCalls.push({ reason: options.reason, tickMs: nowMs });
return getCurrentPullRequestStatus(options);
},
);
const getCheckoutStatus = vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, { currentBranch: "feature" }),
);
const service = createService({
getCheckoutStatus,
github,
now: () => new Date(nowMs),
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises();
await vi.advanceTimersByTimeAsync(0);
await flushPromises();
const gitReadsAfterInitialSnapshot = getCheckoutStatus.mock.calls.length;
nowMs = 20_000;
await vi.advanceTimersByTimeAsync(20_000);
await flushPromises();
expect(githubReadCalls).not.toContainEqual({
reason: "self-heal-github",
tickMs: 20_000,
});
expect(getCheckoutStatus).toHaveBeenCalledTimes(gitReadsAfterInitialSnapshot);
nowMs = 120_000;
await vi.advanceTimersByTimeAsync(100_000);
await flushPromises();
expect(githubReadCalls).toContainEqual({
reason: "self-heal-github",
tickMs: 120_000,
});
subscription.unsubscribe();
service.dispose();
@@ -929,7 +1011,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(0);
});
test("self-heal poll coalesces with a concurrent direct getSnapshot call", async () => {
test("direct getSnapshot returns current snapshot during a self-heal refresh", async () => {
let nowMs = 0;
const selfHealRefresh = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi
@@ -950,9 +1032,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
await expect(directRead).resolves.toEqual(createSnapshot(REPO_CWD));
selfHealRefresh.resolve(createCheckoutStatus(REPO_CWD));
await directRead;
await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
@@ -341,7 +341,7 @@ describe("WorkspaceGitServiceImpl", () => {
);
});
test("non-forced GitHub refresh does not emit when pull request state is unchanged", async () => {
test("non-forced workspace refresh does not reload GitHub or emit when state is unchanged", async () => {
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
const service = createService({
@@ -355,7 +355,7 @@ describe("WorkspaceGitServiceImpl", () => {
nowMs += 3_000;
await service.refresh(REPO_CWD);
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
expect(listener).not.toHaveBeenCalled();
subscription.unsubscribe();
@@ -401,7 +401,7 @@ describe("WorkspaceGitServiceImpl", () => {
service.dispose();
});
test("multiple listeners on the same workspace share one GitHub pull request lookup", async () => {
test("multiple listeners on the same workspace share one observation setup", async () => {
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
@@ -416,7 +416,7 @@ describe("WorkspaceGitServiceImpl", () => {
const second = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises();
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
expect(getPullRequestStatus).toHaveBeenCalledTimes(0);
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
first.unsubscribe();
@@ -448,7 +448,7 @@ describe("WorkspaceGitServiceImpl", () => {
createSnapshot(REPO_CWD),
);
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
subscription.unsubscribe();
@@ -489,7 +489,7 @@ describe("WorkspaceGitServiceImpl", () => {
service.dispose();
});
test("explicit refresh recomputes github state and notifies listeners", async () => {
test("explicit forced snapshot refresh recomputes github state and notifies listeners", async () => {
const getPullRequestStatus = vi
.fn<() => Promise<PullRequestStatusResult>>()
.mockResolvedValueOnce(
@@ -529,7 +529,10 @@ describe("WorkspaceGitServiceImpl", () => {
expect(initialSnapshot.github.pullRequest?.title).toBe("Before refresh");
await service.refresh(REPO_CWD);
await service.getSnapshot(REPO_CWD, {
force: true,
reason: "test-force-github-refresh",
});
await flushPromises();
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
@@ -41,8 +41,8 @@ const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000;
const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000;
// Consumer reads may reuse cached values within this window; older peeks cold-load through the service.
const WORKSPACE_GIT_CONSUMER_TTL_MS = 15_000;
// Auxiliary reads may reuse cached values within this window; snapshots do not expire on read.
const WORKSPACE_GIT_AUXILIARY_READ_TTL_MS = 15_000;
// Non-forced refresh triggers share this minimum gap to absorb watcher/self-heal bursts; force bypasses it.
const WORKSPACE_GIT_INTERNAL_MIN_GAP_MS = 2_000;
const LINUX_WATCH_MAX_DIRS = 5_000;
@@ -262,6 +262,10 @@ interface WorkspaceGitTarget {
githubPollSubscription: { unsubscribe: () => void } | null;
githubPollHeadRef: string | null;
refreshState: WorkspaceGitRefreshState;
latestGit: WorkspaceGitRuntimeSnapshot["git"] | null;
latestGitLoadedAtMs: number | null;
latestGithub: WorkspaceGitRuntimeSnapshot["github"] | null;
latestGithubLoadedAtMs: number | null;
latestSnapshot: WorkspaceGitRuntimeSnapshot | null;
latestSnapshotLoadedAtMs: number | null;
latestFingerprint: string | null;
@@ -409,7 +413,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
cwd = normalizeWorkspaceId(cwd);
const request = this.normalizeRefreshRequest(options, "getSnapshot", true);
const target = this.ensureWorkspaceTarget(cwd);
if (!request.force && this.isSnapshotWarm(target)) {
if (!request.force && target.latestSnapshot) {
return target.latestSnapshot;
}
@@ -698,7 +702,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const nowMs = this.deps.now().getTime();
if (!options?.force && entry.value !== null && entry.loadedAtMs !== null) {
const ageMs = nowMs - entry.loadedAtMs;
if (ageMs <= WORKSPACE_GIT_CONSUMER_TTL_MS) {
if (ageMs <= WORKSPACE_GIT_AUXILIARY_READ_TTL_MS) {
return Promise.resolve(entry.value);
}
if (
@@ -773,6 +777,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
githubPollSubscription: null,
githubPollHeadRef: null,
refreshState: { status: "idle" },
latestGit: null,
latestGitLoadedAtMs: null,
latestGithub: null,
latestGithubLoadedAtMs: null,
latestSnapshot: null,
latestSnapshotLoadedAtMs: null,
latestFingerprint: null,
@@ -1045,7 +1053,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (!target.selfHealTimer) {
target.selfHealTimer = setInterval(() => {
this.scheduleWorkspaceObservationSetup(target);
this.getSnapshot(target.cwd, { reason: "self-heal-git" }).catch((error) => {
this.refreshWorkspaceTarget(target, {
force: false,
includeGitHub: false,
reason: "self-heal-git",
notify: true,
}).catch((error) => {
this.logger.warn(
{ err: error, cwd: target.cwd, reason: "self-heal-git" },
"Failed to run workspace git self-heal refresh",
@@ -1063,15 +1076,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return;
}
const snapshot = target.latestSnapshot;
if (!snapshot || !this.deps.github.retainCurrentPullRequestStatusPoll) {
const git = target.latestGit;
if (!git || !this.deps.github.retainCurrentPullRequestStatusPoll) {
this.stopGitHubPollForTarget(target);
return;
}
const headRef = snapshot.git.currentBranch;
const headRef = git.currentBranch;
const hasGitHubRemote =
target.cachedGitHubRemote?.remoteUrl === snapshot.git.remoteUrl &&
target.cachedGitHubRemote?.remoteUrl === git.remoteUrl &&
target.cachedGitHubRemote.identity !== null;
if (!headRef || !hasGitHubRemote) {
this.stopGitHubPollForTarget(target);
@@ -1086,14 +1099,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target.githubPollSubscription = this.deps.github.retainCurrentPullRequestStatusPoll({
cwd: target.cwd,
headRef,
onStatus: () => {
onStatus: (status) => {
if (!this.isActiveObservedWorkspaceTarget(target)) {
return;
}
void this.refreshWorkspaceTarget(target, {
force: false,
includeGitHub: true,
reason: "self-heal-github",
this.rememberGitHubSnapshot(target, buildGitHubSnapshotFromStatus(status), {
notify: true,
});
},
@@ -1381,25 +1391,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const force = options?.force === true;
return {
force,
includeGitHub: options?.includeGitHub ?? force,
includeGitHub: options?.includeGitHub ?? true,
reason: options?.reason ?? defaultReason,
notify,
};
}
private isSnapshotWarm(target: WorkspaceGitTarget): target is WorkspaceGitTarget & {
latestSnapshot: WorkspaceGitRuntimeSnapshot;
latestSnapshotLoadedAtMs: number;
} {
if (!target.latestSnapshot || target.latestSnapshotLoadedAtMs === null) {
return false;
}
return (
this.deps.now().getTime() - target.latestSnapshotLoadedAtMs <= WORKSPACE_GIT_CONSUMER_TTL_MS
);
}
private async resolveGitHubRemoteForTarget(
target: WorkspaceGitTarget,
remoteUrl: string | null,
@@ -1484,57 +1481,128 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
): Promise<WorkspaceGitRuntimeSnapshot> {
await this.refreshGitSnapshot(target, request);
if (request.includeGitHub) {
await this.refreshGitHubSnapshot(target, request);
}
const snapshot = this.combineSnapshot(target);
target.latestSnapshotLoadedAtMs = this.deps.now().getTime();
return snapshot;
}
private async refreshGitSnapshot(
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
): Promise<void> {
const now = this.deps.now();
target.lastShellOutAtMs = now.getTime();
const cwd = target.cwd;
const previousGitHubPollKey = this.getGitHubPollKey(target);
const context: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger };
const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context);
if (!checkoutStatus.isGit) {
target.latestGit = buildNotGitSnapshot(cwd).git;
target.latestGitLoadedAtMs = this.deps.now().getTime();
target.cachedGitHubRemote = null;
target.latestGithub = buildGitHubUnavailableSnapshot();
target.latestGithubLoadedAtMs = target.latestGitLoadedAtMs;
return;
}
await this.resolveGitHubRemoteForTarget(target, checkoutStatus.remoteUrl);
const diffStat = await this.deps
.getCheckoutShortstat(cwd, context, { force: request.force })
.catch(() => null);
target.latestGit = {
isGit: true,
repoRoot: checkoutStatus.repoRoot,
mainRepoRoot: checkoutStatus.mainRepoRoot,
currentBranch: checkoutStatus.currentBranch,
remoteUrl: checkoutStatus.remoteUrl,
isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree,
isDirty: checkoutStatus.isDirty,
baseRef: checkoutStatus.baseRef,
aheadBehind: checkoutStatus.aheadBehind,
aheadOfOrigin: checkoutStatus.aheadOfOrigin,
behindOfOrigin: checkoutStatus.behindOfOrigin,
hasRemote: checkoutStatus.hasRemote,
diffStat,
};
target.latestGitLoadedAtMs = this.deps.now().getTime();
if (previousGitHubPollKey !== this.getGitHubPollKey(target)) {
target.latestGithub = buildGitHubUnavailableSnapshot();
target.latestGithubLoadedAtMs = target.latestGitLoadedAtMs;
}
}
private async refreshGitHubSnapshot(
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
): Promise<void> {
const githubRemote = target.cachedGitHubRemote?.identity ?? null;
const forceGitHub = request.force && request.includeGitHub;
if (forceGitHub) {
this.deps.github.invalidate({ cwd: target.cwd });
}
const cwd = target.cwd;
const context: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger };
const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context);
if (!checkoutStatus.isGit) {
target.latestSnapshotLoadedAtMs = now.getTime();
return buildNotGitSnapshot(cwd);
target.latestGithub = await loadGitHubSnapshot({
cwd: target.cwd,
githubRemote,
now: this.deps.now(),
deps: this.deps,
force: forceGitHub,
reason: request.reason,
});
target.latestGithubLoadedAtMs = this.deps.now().getTime();
}
private combineSnapshot(target: WorkspaceGitTarget): WorkspaceGitRuntimeSnapshot {
if (!target.latestGit) {
return target.latestSnapshot ?? buildNotGitSnapshot(target.cwd);
}
const githubRemote = await this.resolveGitHubRemoteForTarget(target, checkoutStatus.remoteUrl);
const [diffStat, github] = await Promise.all([
this.deps.getCheckoutShortstat(cwd, context, { force: request.force }).catch(() => null),
loadGitHubSnapshot({
cwd,
githubRemote,
now,
deps: this.deps,
force: forceGitHub,
reason: request.reason,
}),
]);
target.latestSnapshotLoadedAtMs = now.getTime();
return {
cwd,
git: {
isGit: true,
repoRoot: checkoutStatus.repoRoot,
mainRepoRoot: checkoutStatus.mainRepoRoot,
currentBranch: checkoutStatus.currentBranch,
remoteUrl: checkoutStatus.remoteUrl,
isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree,
isDirty: checkoutStatus.isDirty,
baseRef: checkoutStatus.baseRef,
aheadBehind: checkoutStatus.aheadBehind,
aheadOfOrigin: checkoutStatus.aheadOfOrigin,
behindOfOrigin: checkoutStatus.behindOfOrigin,
hasRemote: checkoutStatus.hasRemote,
diffStat,
},
github,
cwd: target.cwd,
git: target.latestGit,
github: target.latestGithub ?? buildGitHubUnavailableSnapshot(),
};
}
private getGitHubPollKey(target: WorkspaceGitTarget): string | null {
const git = target.latestGit;
if (!git?.currentBranch || !git.remoteUrl) {
return null;
}
const githubRemote = target.cachedGitHubRemote;
if (!githubRemote || githubRemote.remoteUrl !== git.remoteUrl || !githubRemote.identity) {
return null;
}
return JSON.stringify([git.remoteUrl, git.currentBranch]);
}
private rememberGitHubSnapshot(
target: WorkspaceGitTarget,
github: WorkspaceGitRuntimeSnapshot["github"],
options?: { notify?: boolean },
): void {
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return;
}
target.latestGithub = github;
target.latestGithubLoadedAtMs = this.deps.now().getTime();
this.rememberSnapshot(target, this.combineSnapshot(target), {
notify: options?.notify,
forceEmit: false,
});
}
private rememberSnapshot(
target: WorkspaceGitTarget,
snapshot: WorkspaceGitRuntimeSnapshot,
@@ -1794,11 +1862,25 @@ function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
hasRemote: false,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
},
github: buildGitHubUnavailableSnapshot(),
};
}
function buildGitHubUnavailableSnapshot(): WorkspaceGitRuntimeSnapshot["github"] {
return {
featuresEnabled: false,
pullRequest: null,
error: null,
};
}
function buildGitHubSnapshotFromStatus(
status: WorkspaceGitRuntimeSnapshot["github"]["pullRequest"],
): WorkspaceGitRuntimeSnapshot["github"] {
return {
featuresEnabled: true,
pullRequest: status,
error: null,
};
}
@@ -3,4 +3,4 @@
* providers (gemini, copilot, generic acp) are excluded because they either
* don't expose persisted history quickly or duplicate other providers.
*/
export const IMPORTABLE_PROVIDERS = ["claude", "codex", "opencode"] as const;
export const IMPORTABLE_PROVIDERS = ["claude", "codex", "opencode", "pi"] as const;
+2
View File
@@ -114,6 +114,7 @@ export const MutableDaemonConfigSchema = z
.passthrough(),
providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}),
autoArchiveAfterMerge: z.boolean().default(false),
appendSystemPrompt: z.string().default(""),
})
.passthrough();
@@ -124,6 +125,7 @@ export const MutableDaemonConfigPatchSchema = z
.record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough())
.optional(),
autoArchiveAfterMerge: z.boolean().optional(),
appendSystemPrompt: z.string().optional(),
})
.partial()
.passthrough();
+35
View File
@@ -1,4 +1,5 @@
import os from "node:os";
import { realpathSync } from "node:fs";
import nodePath from "node:path";
/**
@@ -46,6 +47,40 @@ export function createPathEquivalenceMatcher(target: string): (candidate: string
};
}
export function createRealpathAwarePathMatcher(target: string): (candidate: string) => boolean {
const targetMatchers = collectPathVariants(target).map((variant) =>
createPathEquivalenceMatcher(variant),
);
return (candidate) => {
const candidateVariants = collectPathVariants(candidate);
return candidateVariants.some((variant) => targetMatchers.some((matches) => matches(variant)));
};
}
function collectPathVariants(value: string): string[] {
const variants = new Set<string>([value]);
for (const realpath of resolveRealpathVariants(value)) {
variants.add(realpath);
}
return Array.from(variants);
}
function resolveRealpathVariants(value: string): string[] {
const variants: string[] = [];
try {
variants.push(realpathSync.native(value));
} catch {
// Path may not exist, or the platform may reject this link flavor.
}
try {
variants.push(realpathSync(value));
} catch {
// Keep string-only comparison as the fallback.
}
return variants;
}
function shouldCompareAsWindows(left: string, right: string): boolean {
return looksLikeDefiniteWindowsPath(left) || looksLikeDefiniteWindowsPath(right);
}