feat(server): replace Pi ACP with direct SDK provider (#202)

* feat(server): replace Pi ACP integration with direct SDK provider

Replace the ACP-based Pi provider (which spawned a `pi-acp` subprocess
and talked JSON-RPC over stdio) with a direct in-process integration
using `@mariozechner/pi-coding-agent` as a library.

The new `PiDirectAgentClient` and `PiDirectAgentSession` use the Pi SDK
directly — creating sessions via `createAgentSession()`, managing models
via `ModelRegistry`, and mapping Pi's `AgentSessionEvent` stream to
Paseo's `AgentStreamEvent` types including:

- Thread/turn lifecycle events
- Assistant text and thinking/reasoning deltas
- Tool execution (bash, read, write, edit, find, grep, ls) with arg
  caching across start/update/end events
- Compaction events
- Session persistence and resume via `SessionManager`

Thinking levels flow through the existing `thinkingOptionId` /
`setThinkingOption()` infrastructure, not through features.

Includes 9 real e2e tests against a live Pi agent covering tool calls
(bash/read/write/edit), reasoning chunks, session persistence/resume,
model listing, and thinking option management.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: update lockfile signatures and Nix hash

* refactor(server): improve Pi direct provider code quality

Address coding standards violations in pi-direct-agent.ts:

- Replace all `as Record<string, unknown>` / `as any` / `as unknown as`
  casts with properly typed boundaries using Pi SDK's exported tool
  input types (BashToolInput, ReadToolInput, EditToolInput, etc.)
- Introduce named interfaces at all boundaries: PiPromptPayload,
  ToolCallOutputSummary, PiModelReference, PiPersistenceMetadata
- Parse tool args once at boundary into typed discriminated union,
  then trust types internally — no more typeof probing in mappers
- Break all dense ternary chains into named steps / helper functions
- Import SDK types directly (RegisteredCommand, ResourceLoader, Skill)
  instead of casting through anonymous inline shapes
- Isolate the system prompt SDK escape hatch into a minimal boundary
  helper with a named interface

Behavior is preserved — all 9 real e2e tests still pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: regenerate package-lock.json with resolved/integrity fields

The previous lockfile regeneration ran under an offline/cached npm
context and stripped resolved URLs and integrity hashes from existing
entries, which would break npm ci and Nix builds.

* fix: update Nix npmDepsHash for regenerated lockfile

* chore(server): bump @mariozechner/pi-coding-agent to ^0.67.68

* fix: update lockfile signatures and Nix hash

* fix(app): surface agent setter RPC errors as toasts

setAgentMode/Model/ThinkingOption/Feature errors from the daemon were
only logged to console, so failures like missing API keys silently
vanished. Add toast.error fallbacks at all call sites and hoist
ToastProvider above SessionProvider so session context can use it.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Mohamed Boudra
2026-04-19 14:45:01 +08:00
committed by GitHub
co-authored by Claude Opus 4.6 github-actions[bot] <github-actions[bot]@users.noreply.github.com>
parent f8815bf1c2
commit fbc2371b95
14 changed files with 4605 additions and 810 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-wiHIGfS9gEPq0CTjx8O+wbQezEB9HYWEia4vnJ5/VAQ=";
npmDepsHash = "sha256-n1Q7uZumewPUZZFacSkSXeLotTApjxq+BpDEQ8V2MQs=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+2684 -59
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -867,18 +867,18 @@ export default function RootLayout() {
<BottomSheetModalProvider>
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<ToastProvider>
<ToastProvider>
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AppWithSidebar>
<RootStack />
</AppWithSidebar>
</ToastProvider>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</ProvidersWrapper>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</ProvidersWrapper>
</ToastProvider>
</HostRuntimeBootstrapProvider>
</BottomSheetModalProvider>
</QueryProvider>
@@ -52,6 +52,8 @@ import {
resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
type StatusOption = {
id: string;
@@ -863,6 +865,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
);
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const toast = useToast();
const {
entries: snapshotEntries,
@@ -953,6 +956,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
}
void client.setAgentMode(agentId, modeId).catch((error) => {
console.warn("[AgentStatusBar] setAgentMode failed", error);
toast.error(toErrorMessage(error));
});
}}
modelOptions={modelOptions}
@@ -974,6 +978,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
});
void client.setAgentModel(agentId, modelId).catch((error) => {
console.warn("[AgentStatusBar] setAgentModel failed", error);
toast.error(toErrorMessage(error));
});
}}
favoriteKeys={favoriteKeys}
@@ -1009,6 +1014,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
}
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
toast.error(toErrorMessage(error));
});
}}
features={agent.features}
@@ -1031,6 +1037,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
});
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
console.warn("[AgentStatusBar] setAgentFeature failed", error);
toast.error(toErrorMessage(error));
});
}}
isModelLoading={snapshotIsLoading}
@@ -57,6 +57,8 @@ import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachm
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
import { isNative } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -258,6 +260,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const voiceAudioEngine = useVoiceAudioEngineOptional();
const queryClient = useQueryClient();
const isConnected = useHostRuntimeIsConnected(serverId);
const toast = useToast();
// Zustand store actions
const initializeSession = useSessionStore((state) => state.initializeSession);
@@ -1769,9 +1772,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
void client.setAgentMode(agentId, modeId).catch((error) => {
console.error("[Session] Failed to set agent mode:", error);
toast.error(toErrorMessage(error));
});
},
[client],
[client, toast],
);
const setAgentModel = useCallback(
@@ -1782,9 +1786,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
void client.setAgentModel(agentId, modelId).catch((error) => {
console.error("[Session] Failed to set agent model:", error);
toast.error(toErrorMessage(error));
});
},
[client],
[client, toast],
);
const setAgentThinkingOption = useCallback(
@@ -1795,9 +1800,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.error("[Session] Failed to set agent thinking option:", error);
toast.error(toErrorMessage(error));
});
},
[client],
[client, toast],
);
const respondToPermission = useCallback(
+1 -1
View File
@@ -64,6 +64,7 @@
"@getpaseo/relay": "0.1.60-rc.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@mariozechner/pi-coding-agent": "^0.67.68",
"@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0",
"@xterm/headless": "^6.0.0",
@@ -79,7 +80,6 @@
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
"p-limit": "^7.3.0",
"pi-acp": "^0.0.24",
"pino": "^10.2.0",
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
@@ -226,8 +226,8 @@ vi.mock("./providers/opencode-agent.js", () => ({
},
}));
vi.mock("./providers/pi-acp-agent.js", () => ({
PiACPAgentClient: class PiACPAgentClient {
vi.mock("./providers/pi-direct-agent.js", () => ({
PiDirectAgentClient: class PiDirectAgentClient {
readonly capabilities = {
supportsStreaming: true,
supportsSessionPersistence: true,
@@ -25,7 +25,7 @@ import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js";
import { PiACPAgentClient } from "./providers/pi-acp-agent.js";
import { PiDirectAgentClient } from "./providers/pi-direct-agent.js";
import {
AGENT_PROVIDER_DEFINITIONS,
BUILTIN_PROVIDER_IDS,
@@ -75,7 +75,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
}),
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings),
pi: (logger, runtimeSettings) =>
new PiACPAgentClient({
new PiDirectAgentClient({
logger,
runtimeSettings,
}),
@@ -8,7 +8,7 @@ import {
deriveModesFromACP,
mapACPUsage,
} from "./acp-agent.js";
import { transformPiModels, transformPiSessionResponse, wrapPiSession } from "./pi-acp-agent.js";
import { transformPiModels } from "./pi-direct-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
function createSession(): ACPAgentSession {
@@ -246,7 +246,7 @@ describe("ACPAgentClient modelTransformer", () => {
const client = new TestACPAgentClient({
provider: "pi",
logger: createTestLogger(),
defaultCommand: ["pi-acp"],
defaultCommand: ["test-acp"],
modelTransformer: transformPiModels,
});
@@ -270,23 +270,10 @@ describe("ACPAgentClient sessionResponseTransformer", () => {
const response: SessionStateResponse = {
sessionId: "session-1",
modes: {
availableModes: [
{ id: "off", name: "Thinking: Off", description: "No extra reasoning" },
{ id: "medium", name: "Thinking: Medium", description: "Balanced reasoning" },
{ id: "high", name: "Thinking: High", description: "Deeper reasoning" },
],
currentModeId: "medium",
},
models: {
availableModels: [
{
modelId: "openrouter/openai/gpt-4.1-mini",
name: "openrouter/openai/gpt-4.1-mini",
description: null,
},
],
currentModelId: "openrouter/openai/gpt-4.1-mini",
availableModes: [{ id: "raw", name: "Raw", description: "Before transform" }],
currentModeId: "raw",
},
models: null,
configOptions: [],
};
@@ -302,48 +289,26 @@ describe("ACPAgentClient sessionResponseTransformer", () => {
protected override async closeProbe(): Promise<void> {}
}
test("remaps Pi thinking modes into thinking options for list probes", async () => {
test("applies sessionResponseTransformer before deriving list probe modes", async () => {
const client = new TestACPAgentClient({
provider: "pi",
provider: "claude-acp",
logger: createTestLogger(),
defaultCommand: ["pi-acp"],
defaultCommand: ["claude", "--acp"],
defaultModes: [],
modelTransformer: transformPiModels,
sessionResponseTransformer: transformPiSessionResponse,
sessionResponseTransformer: (response) => ({
...response,
modes: {
availableModes: [{ id: "review", name: "Review", description: "After transform" }],
currentModeId: "review",
},
}),
});
await expect(client.listModes()).resolves.toEqual([]);
await expect(client.listModels()).resolves.toEqual([
await expect(client.listModes()).resolves.toEqual([
{
provider: "pi",
id: "openrouter/openai/gpt-4.1-mini",
label: "gpt-4.1-mini",
description: "openrouter/openai/gpt-4.1-mini",
isDefault: true,
thinkingOptions: [
{
id: "off",
label: "Off",
description: "No extra reasoning",
isDefault: false,
metadata: undefined,
},
{
id: "medium",
label: "Medium",
description: "Balanced reasoning",
isDefault: true,
metadata: undefined,
},
{
id: "high",
label: "High",
description: "Deeper reasoning",
isDefault: false,
metadata: undefined,
},
],
defaultThinkingOptionId: "medium",
id: "review",
label: "Review",
description: "After transform",
},
]);
});
@@ -384,7 +349,7 @@ describe("ACPAgentClient listModes", () => {
const client = new TestACPAgentClient({
provider: "pi",
logger: createTestLogger(),
defaultCommand: ["pi-acp"],
defaultCommand: ["test-acp"],
defaultModes: [],
});
@@ -456,21 +421,19 @@ describe("ACPAgentSession slash commands", () => {
test("waits for async available_commands_update when enabled", async () => {
const session = new ACPAgentSession(
{
provider: "pi",
provider: "claude-acp",
cwd: "/tmp/paseo-acp-test",
},
{
provider: "pi",
provider: "claude-acp",
logger: createTestLogger(),
defaultCommand: ["pi-acp"],
defaultCommand: ["claude", "--acp"],
defaultModes: [],
modelTransformer: transformPiModels,
sessionResponseTransformer: transformPiSessionResponse,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: false,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
@@ -524,158 +487,6 @@ describe("ACPAgentSession slash commands", () => {
});
describe("ACPAgentSession", () => {
test("applies sessionResponseTransformer before deriving modes and thinking state", () => {
const session = new ACPAgentSession(
{
provider: "pi",
cwd: "/tmp/paseo-acp-test",
},
{
provider: "pi",
logger: createTestLogger(),
defaultCommand: ["pi-acp"],
defaultModes: [],
modelTransformer: transformPiModels,
sessionResponseTransformer: transformPiSessionResponse,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: false,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
},
);
(session as any).applySessionState({
sessionId: "session-1",
modes: {
availableModes: [
{ id: "low", name: "Thinking: Low", description: "Faster" },
{ id: "medium", name: "Thinking: Medium", description: "Balanced" },
],
currentModeId: "medium",
},
models: {
availableModels: [
{
modelId: "openrouter/openai/gpt-4.1-mini",
name: "openrouter/openai/gpt-4.1-mini",
description: null,
},
],
currentModelId: "openrouter/openai/gpt-4.1-mini",
},
configOptions: [],
} satisfies SessionStateResponse);
expect((session as any).availableModes).toEqual([]);
expect((session as any).thinkingOptionId).toBe("medium");
expect((session as any).availableModels).toEqual([
{
provider: "pi",
id: "openrouter/openai/gpt-4.1-mini",
label: "gpt-4.1-mini",
description: "openrouter/openai/gpt-4.1-mini",
isDefault: true,
thinkingOptions: [
{
id: "low",
label: "Low",
description: "Faster",
isDefault: false,
metadata: undefined,
},
{
id: "medium",
label: "Medium",
description: "Balanced",
isDefault: true,
metadata: undefined,
},
],
defaultThinkingOptionId: "medium",
},
]);
});
test("Pi session wrapper hides synthetic modes and exposes them as thinking", async () => {
const wrapped = wrapPiSession(
{
provider: "pi",
id: "session-1",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: false,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
features: [
{
type: "select",
id: "thought_level",
label: "Thinking",
value: "medium",
options: [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
],
},
],
run: vi.fn(),
startTurn: vi.fn(),
subscribe: vi.fn(() => () => {}),
streamHistory: async function* () {},
getRuntimeInfo: vi.fn(async () => ({
provider: "pi",
sessionId: "session-1",
model: "gpt-4.1-mini",
thinkingOptionId: null,
modeId: "xhigh",
})),
getAvailableModes: vi.fn(async () => [{ id: "xhigh", label: "xhigh" }]),
getCurrentMode: vi.fn(async () => "xhigh"),
setMode: vi.fn(),
getPendingPermissions: vi.fn(() => []),
respondToPermission: vi.fn(),
describePersistence: vi.fn(() => null),
interrupt: vi.fn(),
close: vi.fn(),
setThinkingOption: vi.fn(),
},
{
provider: "pi",
cwd: "/tmp/paseo-acp-test",
thinkingOptionId: "medium",
},
);
await expect(wrapped.getAvailableModes()).resolves.toEqual([]);
await expect(wrapped.getCurrentMode()).resolves.toBeNull();
await expect(wrapped.getRuntimeInfo()).resolves.toEqual({
provider: "pi",
sessionId: "session-1",
model: "gpt-4.1-mini",
thinkingOptionId: "xhigh",
modeId: null,
});
expect(wrapped.features).toEqual([
{
type: "select",
id: "thought_level",
label: "Thinking",
value: "xhigh",
options: [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
],
},
]);
});
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
const session = createSession();
const events: Array<{ type: string; item?: { type: string; text?: string } }> = [];
@@ -819,55 +630,4 @@ describe("ACPAgentSession", () => {
});
expect((session as any).activeForegroundTurnId).toBeNull();
});
test("auto-approves Copilot ACP permissions in autopilot mode without emitting prompt events", async () => {
const session = new ACPAgentSession(
{
provider: "copilot",
cwd: "/tmp/paseo-acp-test",
modeId: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
},
{
provider: "copilot",
logger: createTestLogger(),
defaultCommand: ["copilot", "--acp"],
defaultModes: [],
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
},
);
const events: Array<{ type: string }> = [];
session.subscribe((event) => {
events.push(event as { type: string });
});
const response = await session.requestPermission({
toolCall: {
toolCallId: "tool-1",
title: "Edit file",
kind: "edit",
status: "pending",
} as any,
options: [
{ optionId: "allow-once", name: "Allow Once", kind: "allow_once" },
{ optionId: "reject-once", name: "Reject Once", kind: "reject_once" },
],
} as any);
expect(response).toEqual({
outcome: {
outcome: "selected",
optionId: "allow-once",
},
});
expect(session.getPendingPermissions()).toEqual([]);
expect(events.find((event) => event.type === "permission_requested")).toBeUndefined();
});
});
@@ -778,7 +778,7 @@ class TimelineAssembler {
runId: string | null,
messageIdHint: string | null,
): AgentTimelineItem[] {
const event = message.event as Record<string, unknown>;
const event = message.event as unknown as Record<string, unknown>;
const eventType = readTrimmedString(event.type);
const streamEventMessageId = this.readMessageIdFromStreamEvent(event) ?? messageIdHint;
@@ -2226,7 +2226,14 @@ class ClaudeAgentSession implements AgentSession {
private toSdkUserMessage(prompt: AgentPromptInput): SDKUserMessage {
const content: Array<
| { type: "text"; text: string }
| { type: "image"; source: { type: "base64"; media_type: string; data: string } }
| {
type: "image";
source: {
type: "base64";
media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp";
data: string;
};
}
> = [];
if (Array.isArray(prompt)) {
for (const chunk of prompt) {
@@ -2237,7 +2244,7 @@ class ClaudeAgentSession implements AgentSession {
type: "image",
source: {
type: "base64",
media_type: chunk.mimeType,
media_type: chunk.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp",
data: chunk.data,
},
});
@@ -1,406 +0,0 @@
import { createRequire } from "node:module";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import type { Logger } from "pino";
import type { ClientSideConnection, SessionConfigOption, ToolKind } from "@agentclientprotocol/sdk";
import type {
AgentLaunchContext,
AgentCapabilityFlags,
AgentFeature,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentRuntimeInfo,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentFeatureSelect,
} from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import { ACPAgentClient, type ACPToolSnapshot, type SessionStateResponse } from "./acp-agent.js";
import {
formatDiagnosticStatus,
formatProviderDiagnostic,
formatProviderDiagnosticError,
resolveBinaryVersion,
toDiagnosticErrorMessage,
} from "./diagnostic-utils.js";
const require = createRequire(import.meta.url);
const resolvedPiAcpPath = require.resolve("pi-acp");
const PI_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: false,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
// Pi tool kind corrections: pi-acp maps 'bash' to kind 'other' instead of 'execute'.
const PI_TOOL_KIND_MAP: Record<string, ToolKind> = {
bash: "execute",
};
type PiACPAgentClientOptions = {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
};
function normalizePiModelLabel(label: string): string {
return label.trim().replace(/[_\s]+/g, " ");
}
export function transformPiModels(models: AgentModelDefinition[]): AgentModelDefinition[] {
return models.map((model) => {
if (!model.label.includes("/")) {
return model;
}
const segments = model.label.split("/").filter((segment) => segment.length > 0);
const rawLabel = segments.at(-1);
if (!rawLabel) {
return model;
}
return {
...model,
label: normalizePiModelLabel(rawLabel),
description: model.description ?? model.label,
};
});
}
function transformPiToolSnapshot(snapshot: ACPToolSnapshot): ACPToolSnapshot {
if (snapshot.kind === "other" && snapshot.title && PI_TOOL_KIND_MAP[snapshot.title]) {
return { ...snapshot, kind: PI_TOOL_KIND_MAP[snapshot.title] };
}
return snapshot;
}
/**
* Pi-acp reports thinking levels (off/minimal/low/medium/high/xhigh) as ACP
* session modes rather than as configOptions with category 'thought_level'.
* This transformer remaps them so the base ACP class treats them as thinking
* options instead of permission modes.
*/
export function transformPiSessionResponse(response: SessionStateResponse): SessionStateResponse {
const modes = response.modes;
if (!modes?.availableModes?.length) {
return response;
}
const thinkingOption: SessionConfigOption = {
id: "thought_level",
name: "Thinking",
type: "select",
category: "thought_level",
currentValue: modes.currentModeId ?? "medium",
options: modes.availableModes.map((mode) => ({
value: mode.id,
name: mode.name.replace(/^Thinking:\s*/i, ""),
description: mode.description,
})),
};
return {
...response,
modes: undefined,
configOptions: [thinkingOption, ...(response.configOptions ?? [])],
};
}
function isThoughtLevelFeature(feature: AgentFeature): feature is AgentFeatureSelect {
return feature.type === "select" && feature.id === "thought_level";
}
function normalizePiFeatures(
features: AgentFeature[] | undefined,
thinkingOptionId: string | null | undefined,
): AgentFeature[] | undefined {
if (!features) {
return features;
}
return features.map((feature) => {
if (!isThoughtLevelFeature(feature)) {
return feature;
}
return {
...feature,
value: thinkingOptionId ?? feature.value,
};
});
}
class PiACPAgentSession implements AgentSession {
readonly provider: AgentSession["provider"];
readonly capabilities: AgentSession["capabilities"];
get id(): string | null {
return this.inner.id;
}
get features(): AgentFeature[] | undefined {
return normalizePiFeatures(this.inner.features, this.thinkingOptionId);
}
private thinkingOptionId: string | null;
constructor(
private readonly inner: AgentSession,
config: AgentSessionConfig,
) {
this.provider = inner.provider;
this.capabilities = inner.capabilities;
this.thinkingOptionId = config.thinkingOptionId ?? null;
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
return this.inner.run(prompt, options);
}
async startTurn(
prompt: AgentPromptInput,
options?: AgentRunOptions,
): Promise<{ turnId: string }> {
return this.inner.startTurn(prompt, options);
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
return this.inner.subscribe(callback);
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield* this.inner.streamHistory();
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
const runtimeInfo = await this.inner.getRuntimeInfo();
const thinkingOptionId =
runtimeInfo.modeId ?? runtimeInfo.thinkingOptionId ?? this.thinkingOptionId;
this.thinkingOptionId = thinkingOptionId ?? null;
return {
...runtimeInfo,
modeId: null,
thinkingOptionId: thinkingOptionId ?? null,
};
}
async getAvailableModes(): Promise<AgentMode[]> {
return [];
}
async getCurrentMode(): Promise<string | null> {
return null;
}
async setMode(modeId: string): Promise<void> {
void modeId;
throw new Error("Pi does not expose selectable modes");
}
getPendingPermissions(): AgentPermissionRequest[] {
return this.inner.getPendingPermissions();
}
async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void> {
await this.inner.respondToPermission(requestId, response);
}
describePersistence(): AgentPersistenceHandle | null {
return this.inner.describePersistence();
}
async interrupt(): Promise<void> {
await this.inner.interrupt();
}
async close(): Promise<void> {
await this.inner.close();
}
async listCommands(): Promise<AgentSlashCommand[]> {
return this.inner.listCommands ? this.inner.listCommands() : [];
}
async setModel(modelId: string | null): Promise<void> {
if (this.inner.setModel) {
await this.inner.setModel(modelId);
}
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
this.thinkingOptionId = thinkingOptionId ?? null;
if (this.inner.setThinkingOption) {
await this.inner.setThinkingOption(thinkingOptionId);
}
}
async setFeature(featureId: string, value: unknown): Promise<void> {
if (!this.inner.setFeature) {
throw new Error("Agent session does not support setting features");
}
await this.inner.setFeature(featureId, value);
}
}
export function wrapPiSession(
session: AgentSession,
config: Pick<AgentSessionConfig, "provider" | "cwd" | "thinkingOptionId">,
): AgentSession {
return new PiACPAgentSession(session, config);
}
export class PiACPAgentClient extends ACPAgentClient {
constructor(options: PiACPAgentClientOptions) {
super({
provider: "pi",
logger: options.logger,
runtimeSettings: options.runtimeSettings,
defaultCommand: [process.execPath, resolvedPiAcpPath],
defaultModes: [],
modelTransformer: transformPiModels,
sessionResponseTransformer: transformPiSessionResponse,
toolSnapshotTransformer: transformPiToolSnapshot,
thinkingOptionWriter: async (
connection: ClientSideConnection,
sessionId: string,
thinkingOptionId: string,
) => {
await connection.setSessionMode({ sessionId, modeId: thinkingOptionId });
},
capabilities: PI_CAPABILITIES,
waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: 1500,
});
}
override async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const session = await super.createSession(config, launchContext);
return wrapPiSession(session, config);
}
override async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const session = await super.resumeSession(handle, overrides, launchContext);
return wrapPiSession(session, {
provider: "pi",
cwd: overrides?.cwd ?? process.cwd(),
thinkingOptionId: overrides?.thinkingOptionId,
});
}
override async isAvailable(): Promise<boolean> {
if (!existsSync(resolvedPiAcpPath)) {
return false;
}
if (!(await isCommandAvailable(process.env.PI_ACP_PI_COMMAND ?? "pi"))) {
return false;
}
return (
Boolean(process.env.OPENAI_API_KEY) ||
Boolean(process.env.ANTHROPIC_API_KEY) ||
Boolean(process.env.OPENROUTER_API_KEY) ||
existsSync(join(homedir(), ".pi", "agent", "auth.json"))
);
}
async getDiagnostic(): Promise<{ diagnostic: string }> {
try {
const piCommand = process.env.PI_ACP_PI_COMMAND ?? "pi";
const piCliPath = await findExecutable(piCommand);
const piVersion = piCliPath ? await resolveBinaryVersion(piCliPath) : "unknown";
const authConfigPath = join(homedir(), ".pi", "agent", "auth.json");
const available = await this.isAvailable();
let modelsValue = "Not checked";
let status = formatDiagnosticStatus(available);
if (available) {
try {
const models = await this.listModels();
modelsValue = String(models.length);
} catch (error) {
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
status = formatDiagnosticStatus(available, {
source: "model fetch",
cause: error,
});
}
if (!modelsValue.startsWith("Error -")) {
try {
await this.listModes();
} catch (error) {
status = formatDiagnosticStatus(available, {
source: "mode fetch",
cause: error,
});
}
}
}
return {
diagnostic: formatProviderDiagnostic("Pi", [
{
label: "pi-acp module",
value: existsSync(resolvedPiAcpPath) ? "found" : "not found",
},
{
label: "Binary",
value: piCliPath ?? "not found",
},
{
label: "Version",
value: piVersion,
},
{
label: "OPENAI_API_KEY",
value: process.env.OPENAI_API_KEY ? "set" : "not set",
},
{
label: "ANTHROPIC_API_KEY",
value: process.env.ANTHROPIC_API_KEY ? "set" : "not set",
},
{
label: "OPENROUTER_API_KEY",
value: process.env.OPENROUTER_API_KEY ? "set" : "not set",
},
{
label: "Auth config (~/.pi/agent/auth.json)",
value: existsSync(authConfigPath) ? "found" : "not found",
},
{
label: "Models",
value: modelsValue,
},
{
label: "Status",
value: status,
},
]),
};
} catch (error) {
return {
diagnostic: formatProviderDiagnosticError("Pi", error),
};
}
}
}
File diff suppressed because it is too large Load Diff
@@ -126,7 +126,9 @@ export function isProviderAvailable(provider: AgentProvider): Promise<boolean> {
return await isCommandAvailable("opencode");
case "pi":
return (
(await isCommandAvailable(process.env.PI_ACP_PI_COMMAND ?? "pi")) &&
(await isCommandAvailable(
process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi",
)) &&
(Boolean(process.env.OPENAI_API_KEY) ||
Boolean(process.env.ANTHROPIC_API_KEY) ||
Boolean(process.env.OPENROUTER_API_KEY) ||
@@ -1,84 +1,436 @@
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import pino from "pino";
import { PiACPAgentClient } from "../agent/providers/pi-acp-agent.js";
import type { AgentPersistenceHandle, AgentTimelineItem } from "../agent/agent-sdk-types.js";
import { PiDirectAgentClient } from "../agent/providers/pi-direct-agent.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { isProviderAvailable } from "./agent-configs.js";
process.env.PASEO_SUPERVISED = "0";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-real-pi-"));
const PI_TEST_TIMEOUT_MS = 240_000;
const PI_SUITE_TIMEOUT_MS = 600_000;
const PI_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite";
type ToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
function tmpCwd(prefix = "daemon-real-pi-"): string {
return mkdtempSync(path.join(tmpdir(), prefix));
}
describe("daemon E2E (real pi)", () => {
let canRun = false;
function createPiClient(): PiDirectAgentClient {
return new PiDirectAgentClient({ logger: pino({ level: "silent" }) });
}
beforeAll(async () => {
canRun = await isProviderAvailable("pi");
function createPiToolDaemon() {
const logger = pino({ level: "silent" });
return createTestPaseoDaemon({
agentClients: { pi: new PiDirectAgentClient({ logger }) },
logger,
});
}
beforeEach((context) => {
if (!canRun) {
context.skip();
}
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",
);
}
function findCompletedToolCall(
items: AgentTimelineItem[],
predicate: (item: ToolCallItem) => boolean,
): ToolCallItem | undefined {
return extractCompletedToolCalls(items).find(predicate);
}
async function fetchCanonicalTimeline(
client: DaemonClient,
agentId: string,
): Promise<AgentTimelineItem[]> {
const timeline = await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: 0,
projection: "canonical",
});
return timeline.entries.map((entry) => entry.item);
}
test("smoke test with thinking option configured separately from modes", async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { pi: new PiACPAgentClient({ logger }) },
logger,
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` });
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: `pi-real-${randomUUID()}` },
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await run({ client });
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
}
}
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "pi-real-smoke" },
});
describe(
"daemon E2E (real pi)",
() => {
let canRun = false;
const agent = await client.createAgent({
cwd,
title: "pi-real-smoke",
provider: "pi",
thinkingOptionId: "medium",
});
beforeAll(async () => {
canRun = await isProviderAvailable("pi");
});
await client.sendMessage(agent.id, "Reply with exactly: PINEAPPLE");
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
const finish = await client.waitForFinish(agent.id, 240_000);
expect(finish.status).toBe("idle");
expect(finish.final?.persistence).toBeTruthy();
expect(finish.final?.persistence?.provider).toBe("pi");
expect(finish.final?.persistence?.sessionId).toBeTruthy();
test(
"bash tool call records completed shell detail and output",
async () => {
const cwd = tmpCwd();
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const assistantText = timeline.entries
.filter(
(
entry,
): entry is typeof entry & {
item: { type: "assistant_message"; text: string };
} => entry.item.type === "assistant_message",
)
.map((entry) => entry.item.text)
.join("\n");
try {
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-bash-tool-call",
provider: "pi",
model: PI_REAL_TEST_MODEL,
});
expect(assistantText.replace(/\s+/g, "")).toContain("PINEAPPLE");
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
}
}, 420_000);
});
await client.sendMessage(
agent.id,
"Use the bash tool and run this exact bash command: echo HELLO_PI_TEST",
);
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, agent.id);
const toolCall = findCompletedToolCall(
items,
(item) =>
item.detail.type === "shell" && item.detail.command.includes("echo HELLO_PI_TEST"),
);
expect(toolCall).toBeDefined();
expect(toolCall?.status).toBe("completed");
expect(toolCall?.detail.type).toBe("shell");
if (toolCall?.detail.type === "shell") {
expect(toolCall.detail.command).toContain("echo HELLO_PI_TEST");
expect(toolCall.detail.output).toContain("HELLO_PI_TEST");
}
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"file read tool call captures read detail and content",
async () => {
const cwd = tmpCwd();
const filename = "pi-read.txt";
const expectedContent = "PI_READ_CONTENT_12345";
try {
writeFileSync(path.join(cwd, filename), expectedContent, "utf8");
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-file-read",
provider: "pi",
model: PI_REAL_TEST_MODEL,
});
await client.sendMessage(
agent.id,
`Use the read tool to read the file ${filename} and tell me its contents exactly.`,
);
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, agent.id);
const toolCall = findCompletedToolCall(
items,
(item) =>
item.detail.type === "read" &&
item.detail.filePath.includes(filename) &&
item.detail.content?.includes(expectedContent) === true,
);
expect(toolCall).toBeDefined();
expect(toolCall?.detail.type).toBe("read");
if (toolCall?.detail.type === "read") {
expect(toolCall.detail.filePath).toContain(filename);
expect(toolCall.detail.content).toContain(expectedContent);
}
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"file write tool call captures write detail and writes to disk",
async () => {
const cwd = tmpCwd();
const filename = "pi-test-write.txt";
const expectedContent = "PI_WRITE_CONTENT_67890";
try {
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-file-write",
provider: "pi",
model: PI_REAL_TEST_MODEL,
});
await client.sendMessage(
agent.id,
`Use the write tool to write a file called ${filename} in the current directory with the exact content ${expectedContent}`,
);
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, agent.id);
const toolCall = findCompletedToolCall(
items,
(item) => item.detail.type === "write" && item.detail.filePath.includes(filename),
);
expect(toolCall).toBeDefined();
expect(toolCall?.detail.type).toBe("write");
expect(existsSync(path.join(cwd, filename))).toBe(true);
expect(readFileSync(path.join(cwd, filename), "utf8")).toBe(expectedContent);
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"file edit tool call captures edit detail and updates the file on disk",
async () => {
const cwd = tmpCwd();
const filename = "pi-edit.txt";
const filePath = path.join(cwd, filename);
try {
writeFileSync(filePath, "BEFORE_EDIT", "utf8");
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-file-edit",
provider: "pi",
model: PI_REAL_TEST_MODEL,
});
await client.sendMessage(
agent.id,
`Use the edit tool on the file ${filename} and replace BEFORE_EDIT with AFTER_EDIT. Do not just describe the change.`,
);
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, agent.id);
const toolCall = findCompletedToolCall(
items,
(item) => item.detail.type === "edit" && item.detail.filePath.includes(filename),
);
expect(toolCall).toBeDefined();
expect(toolCall?.detail.type).toBe("edit");
expect(readFileSync(filePath, "utf8")).toContain("AFTER_EDIT");
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"thinking-enabled runs emit reasoning timeline chunks",
async () => {
const cwd = tmpCwd();
try {
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-reasoning",
provider: "pi",
model: PI_REAL_TEST_MODEL,
thinkingOptionId: "high",
});
await client.sendMessage(
agent.id,
"Think step by step about what 7 * 13 equals, and give the final answer at the end.",
);
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, agent.id);
const reasoningItems = items.filter(
(item): item is Extract<AgentTimelineItem, { type: "reasoning" }> =>
item.type === "reasoning" && item.text.trim().length > 0,
);
expect(reasoningItems.length).toBeGreaterThan(0);
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"session persistence survives delete and resume",
async () => {
const cwd = tmpCwd();
const rememberedToken = "PERSISTENCE_TOKEN_42";
try {
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-persistence",
provider: "pi",
model: PI_REAL_TEST_MODEL,
});
await client.sendMessage(agent.id, `Remember this code: ${rememberedToken}`);
const initialFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(initialFinish.status).toBe("idle");
expect(initialFinish.final?.persistence).toBeTruthy();
const handle = initialFinish.final?.persistence as AgentPersistenceHandle;
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?");
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,
);
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"PiDirectAgentClient.listModels returns non-empty Pi model definitions",
async () => {
const client = createPiClient();
const models = await client.listModels();
expect(models.length).toBeGreaterThan(0);
for (const model of models) {
expect(model.provider).toBe("pi");
expect(typeof model.id).toBe("string");
expect(model.id.length).toBeGreaterThan(0);
expect(typeof model.label).toBe("string");
expect(model.label.length).toBeGreaterThan(0);
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"session getRuntimeInfo reflects configured high thinking level",
async () => {
const cwd = tmpCwd("pi-runtime-info-");
const client = createPiClient();
try {
const session = await client.createSession({
provider: "pi",
cwd,
thinkingOptionId: "high",
});
try {
const runtimeInfo = await session.getRuntimeInfo();
expect(runtimeInfo.thinkingOptionId).toBe("high");
} finally {
await session.close();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"session setThinkingOption('low') updates runtime thinking level",
async () => {
const cwd = tmpCwd("pi-feature-");
const client = createPiClient();
try {
const session = await client.createSession({
provider: "pi",
cwd,
});
try {
await session.setThinkingOption?.("low");
const runtimeInfo = await session.getRuntimeInfo();
expect(runtimeInfo.thinkingOptionId).toBe("low");
} finally {
await session.close();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
},
PI_SUITE_TIMEOUT_MS,
);