From 65573499aff6d99ac0740ea4c153d4f8f46f28a8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 4 Apr 2026 14:42:24 +0700 Subject: [PATCH] feat(server): add Pi agent provider and re-enable Copilot (#191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(app): reorder settings sections for better grouping * feat(app): add setup hint and paseo.sh link on mobile welcome screen New app store users land on the welcome screen with no context. Show a brief explanation that the desktop app or server is needed, plus a link to paseo.sh — only on mobile (iOS/Android), hidden on web/desktop. * docs(release): add pre-release sanity check and clarify changelog scope Add a Codex 5.4 review step before cutting releases to catch breaking changes and backward-compatibility issues (mobile apps lag behind desktop/daemon updates). Clarify that the changelog always covers the delta from the previous stable release, not from the last RC. * feat(server): add Pi agent provider and re-enable Copilot Add Pi (pi.dev) as a new ACP-based agent provider with bundled pi-acp adapter. Pi-acp reports thinking levels as ACP modes and bash tool calls with kind "other", so the Pi provider uses generic ACP extension hooks to remap these to the correct Paseo concepts: - sessionResponseTransformer: remaps ACP modes → configOptions[thought_level] - thinkingOptionWriter: writes thinking via setSessionMode instead of set_config_option (which pi-acp doesn't support) - toolSnapshotTransformer: remaps bash tool kind from "other" to "execute" - modelTransformer: cleans slash-prefixed model labels Also re-enables the Copilot provider (disabled since 44da0c67) and removes the claude-acp provider. * fix: update lockfile signatures and Nix hash --------- Co-authored-by: github-actions[bot] --- nix/package.nix | 2 +- package-lock.json | 35 ++ packages/app/src/components/icons/pi-icon.tsx | 19 + packages/app/src/components/provider-icons.ts | 3 +- packages/server/package.json | 1 + .../src/server/agent/provider-manifest.ts | 38 ++ .../src/server/agent/provider-registry.ts | 8 + .../server/agent/providers/acp-agent.test.ts | 371 ++++++++++++++++++ .../src/server/agent/providers/acp-agent.ts | 158 +++++++- .../agent/providers/claude-acp-agent.ts | 62 --- .../server/agent/providers/pi-acp-agent.ts | 147 +++++++ .../src/server/daemon-e2e/agent-configs.ts | 36 +- .../daemon-e2e/claude-acp.real.e2e.test.ts | 156 -------- .../src/server/daemon-e2e/pi.real.e2e.test.ts | 76 ++++ 14 files changed, 861 insertions(+), 251 deletions(-) create mode 100644 packages/app/src/components/icons/pi-icon.tsx delete mode 100644 packages/server/src/server/agent/providers/claude-acp-agent.ts create mode 100644 packages/server/src/server/agent/providers/pi-acp-agent.ts delete mode 100644 packages/server/src/server/daemon-e2e/claude-acp.real.e2e.test.ts create mode 100644 packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts diff --git a/nix/package.nix b/nix/package.nix index ddf5dc6bf..4ccab141f 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -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-rbw+tXNKO7lNRNG6cdlaLyLLaEEdMzDD/Ob7/8noeKw="; + npmDepsHash = "sha256-pZGDc2JVlxKz6TvuM7ALD2RzXq/ANzvmJiV+bge/SL4="; # 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). diff --git a/package-lock.json b/package-lock.json index b07de6976..20b31864d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28264,6 +28264,40 @@ "dev": true, "license": "MIT" }, + "node_modules/pi-acp": { + "version": "0.0.24", + "resolved": "https://registry.npmjs.org/pi-acp/-/pi-acp-0.0.24.tgz", + "integrity": "sha512-iFoQLH9nd3e2fpvemFV/0SUPeT9ecGHyhBiAe1JW7kHBWfmBQREeRn7O+hQyWA7heMVv+C9CObk4HDLVRy7JaQ==", + "license": "MIT", + "dependencies": { + "@agentclientprotocol/sdk": "^0.12.0", + "zod": "^3.25.0" + }, + "bin": { + "pi-acp": "dist/index.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/pi-acp/node_modules/@agentclientprotocol/sdk": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.12.0.tgz", + "integrity": "sha512-V8uH/KK1t7utqyJmTA7y7DzKu6+jKFIXM+ZVouz8E55j8Ej2RV42rEvPKn3/PpBJlliI5crcGk1qQhZ7VwaepA==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/pi-acp/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -35471,6 +35505,7 @@ "node-pty": "1.2.0-beta.11", "onnxruntime-node": "^1.23.0", "openai": "^4.20.0", + "pi-acp": "^0.0.24", "pino": "^10.2.0", "pino-pretty": "^13.1.3", "qrcode": "^1.5.4", diff --git a/packages/app/src/components/icons/pi-icon.tsx b/packages/app/src/components/icons/pi-icon.tsx new file mode 100644 index 000000000..6dc97fa29 --- /dev/null +++ b/packages/app/src/components/icons/pi-icon.tsx @@ -0,0 +1,19 @@ +import Svg, { Path } from "react-native-svg"; + +interface PiIconProps { + size?: number; + color?: string; +} + +export function PiIcon({ size = 16, color = "currentColor" }: PiIconProps) { + return ( + + + + + ); +} diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index a8bd2ec04..6c8a689c5 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -3,13 +3,14 @@ import { ClaudeIcon } from "@/components/icons/claude-icon"; import { CodexIcon } from "@/components/icons/codex-icon"; import { CopilotIcon } from "@/components/icons/copilot-icon"; import { OpenCodeIcon } from "@/components/icons/opencode-icon"; +import { PiIcon } from "@/components/icons/pi-icon"; const PROVIDER_ICONS: Record = { claude: ClaudeIcon as unknown as typeof Bot, - "claude-acp": ClaudeIcon as unknown as typeof Bot, codex: CodexIcon as unknown as typeof Bot, copilot: CopilotIcon as unknown as typeof Bot, opencode: OpenCodeIcon as unknown as typeof Bot, + pi: PiIcon as unknown as typeof Bot, }; export function getProviderIcon(provider: string): typeof Bot { diff --git a/packages/server/package.json b/packages/server/package.json index 1ecfc3244..e430ef3ed 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -81,6 +81,7 @@ "node-pty": "1.2.0-beta.11", "onnxruntime-node": "^1.23.0", "openai": "^4.20.0", + "pi-acp": "^0.0.24", "pino": "^10.2.0", "pino-pretty": "^13.1.3", "qrcode": "^1.5.4", diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index 687a90bb8..f3a39dbe1 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -83,6 +83,30 @@ const CODEX_MODES: AgentProviderModeDefinition[] = [ }, ]; +const COPILOT_MODES: AgentProviderModeDefinition[] = [ + { + id: "https://agentclientprotocol.com/protocol/session-modes#agent", + label: "Agent", + description: "Default agent mode for conversational interactions", + icon: "ShieldAlert", + colorTier: "moderate", + }, + { + id: "https://agentclientprotocol.com/protocol/session-modes#plan", + label: "Plan", + description: "Plan mode for creating and executing multi-step plans", + icon: "ShieldCheck", + colorTier: "planning", + }, + { + id: "https://agentclientprotocol.com/protocol/session-modes#autopilot", + label: "Autopilot", + description: "Autonomous mode that runs until task completion without user interaction", + icon: "ShieldOff", + colorTier: "dangerous", + }, +]; + const OPENCODE_MODES: AgentProviderModeDefinition[] = [ { id: "build", @@ -125,6 +149,13 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ defaultModel: "gpt-5.1-codex-mini", }, }, + { + id: "copilot", + label: "Copilot", + description: "GitHub Copilot via Agent Client Protocol with dynamic modes and session support", + defaultModeId: "https://agentclientprotocol.com/protocol/session-modes#agent", + modes: COPILOT_MODES, + }, { id: "opencode", label: "OpenCode", @@ -136,6 +167,13 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ defaultModeId: "build", }, }, + { + id: "pi", + label: "Pi", + description: "Minimal terminal-based coding agent with multi-provider LLM support", + defaultModeId: null, + modes: [], + }, ]; export function getAgentProviderDefinition(provider: string): AgentProviderDefinition { diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 10c534d74..65bccc859 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -12,6 +12,8 @@ import type { Logger } from "pino"; import { ClaudeAgentClient } from "./providers/claude-agent.js"; import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js"; import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js"; +import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js"; +import { PiACPAgentClient } from "./providers/pi-acp-agent.js"; import { AGENT_PROVIDER_DEFINITIONS, @@ -45,8 +47,14 @@ const PROVIDER_CLIENT_FACTORIES: Record = { runtimeSettings: runtimeSettings?.claude, }), codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings?.codex), + copilot: (logger, runtimeSettings) => + new CopilotACPAgentClient({ + logger, + runtimeSettings: runtimeSettings?.copilot, + }), opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings?.opencode), + pi: (logger, runtimeSettings) => new PiACPAgentClient({ logger, runtimeSettings: runtimeSettings?.pi }), }; function getProviderClientFactory(provider: string): ProviderClientFactory { diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 55fbac92b..4fc9365f4 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1,11 +1,14 @@ import { describe, expect, test, vi } from "vitest"; import { + ACPAgentClient, ACPAgentSession, + type SessionStateResponse, deriveModelDefinitionsFromACP, deriveModesFromACP, mapACPUsage, } from "./acp-agent.js"; +import { transformPiModels, transformPiSessionResponse } from "./pi-acp-agent.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; function createSession(): ACPAgentSession { @@ -98,6 +101,32 @@ describe("deriveModesFromACP", () => { currentModeId: "acceptEdits", }); }); + + test("returns an empty mode list when fallback modes are empty and config only exposes thought levels", () => { + const result = deriveModesFromACP( + [], + null, + [ + { + id: "thought_level", + name: "Thinking", + category: "thought_level", + type: "select", + currentValue: "medium", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + ], + }, + ], + ); + + expect(result).toEqual({ + modes: [], + currentModeId: null, + }); + }); }); describe("deriveModelDefinitionsFromACP", () => { @@ -154,7 +183,349 @@ describe("deriveModelDefinitionsFromACP", () => { }); }); +describe("ACPAgentClient modelTransformer", () => { + test("applies modelTransformer after deriving ACP models", async () => { + class TestACPAgentClient extends ACPAgentClient { + protected override async spawnProcess(): Promise { + return { + child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, + connection: { + newSession: vi.fn().mockResolvedValue({ + 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: [], + }), + }, + initialize: { agentCapabilities: {} }, + }; + } + + protected override async closeProbe(): Promise {} + } + + const client = new TestACPAgentClient({ + provider: "pi", + logger: createTestLogger(), + defaultCommand: ["pi-acp"], + modelTransformer: transformPiModels, + }); + + await expect(client.listModels()).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: undefined, + defaultThinkingOptionId: undefined, + }, + ]); + }); +}); + +describe("ACPAgentClient sessionResponseTransformer", () => { + class TestACPAgentClient extends ACPAgentClient { + protected override async spawnProcess(): Promise { + 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", + }, + configOptions: [], + }; + + return { + child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, + connection: { + newSession: vi.fn().mockResolvedValue(response), + }, + initialize: { agentCapabilities: {} }, + }; + } + + protected override async closeProbe(): Promise {} + } + + test("remaps Pi thinking modes into thinking options for list probes", async () => { + const client = new TestACPAgentClient({ + provider: "pi", + logger: createTestLogger(), + defaultCommand: ["pi-acp"], + defaultModes: [], + modelTransformer: transformPiModels, + sessionResponseTransformer: transformPiSessionResponse, + }); + + await expect(client.listModes()).resolves.toEqual([]); + await expect(client.listModels()).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", + }, + ]); + }); +}); + +describe("ACPAgentClient listModes", () => { + test("returns an empty array when no ACP modes are reported and fallback modes are empty", async () => { + class TestACPAgentClient extends ACPAgentClient { + protected override async spawnProcess(): Promise { + return { + child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, + connection: { + newSession: vi.fn().mockResolvedValue({ + modes: null, + configOptions: [ + { + id: "thought_level", + name: "Thinking", + category: "thought_level", + type: "select", + currentValue: "medium", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + ], + }, + ], + }), + }, + initialize: { agentCapabilities: {} }, + }; + } + + protected override async closeProbe(): Promise {} + } + + const client = new TestACPAgentClient({ + provider: "pi", + logger: createTestLogger(), + defaultCommand: ["pi-acp"], + defaultModes: [], + }); + + await expect(client.listModes()).resolves.toEqual([]); + }); +}); + +describe("transformPiModels", () => { + test("keeps slash-free labels unchanged", () => { + expect( + transformPiModels([ + { + provider: "pi", + id: "gpt-4.1-mini", + label: "GPT 4.1 Mini", + description: "Fast", + }, + ]), + ).toEqual([ + { + provider: "pi", + id: "gpt-4.1-mini", + label: "GPT 4.1 Mini", + description: "Fast", + }, + ]); + }); + + test("uses the last path segment as label and preserves existing descriptions", () => { + expect( + transformPiModels([ + { + provider: "pi", + id: "openrouter/openai/gpt-4.1-mini", + label: "openrouter/openai/gpt-4.1-mini", + description: undefined, + }, + { + provider: "pi", + id: "anthropic/claude-sonnet-4", + label: "anthropic/claude-sonnet-4", + description: "Balanced", + }, + ]), + ).toEqual([ + { + provider: "pi", + id: "openrouter/openai/gpt-4.1-mini", + label: "gpt-4.1-mini", + description: "openrouter/openai/gpt-4.1-mini", + }, + { + provider: "pi", + id: "anthropic/claude-sonnet-4", + label: "claude-sonnet-4", + description: "Balanced", + }, + ]); + }); +}); + +describe("ACPAgentSession slash commands", () => { + test("caches ACP available commands for listCommands", async () => { + const session = createSession(); + + expect(await session.listCommands()).toEqual([]); + + (session as any).translateSessionUpdate({ + sessionUpdate: "available_commands_update", + availableCommands: [ + { + name: "research_codebase", + description: "Search the workspace for relevant files", + }, + { + name: "create_plan", + description: "Draft a plan for the requested work", + }, + ], + }); + + expect(await session.listCommands()).toEqual([ + { + name: "research_codebase", + description: "Search the workspace for relevant files", + argumentHint: "", + }, + { + name: "create_plan", + description: "Draft a plan for the requested work", + argumentHint: "", + }, + ]); + }); +}); + 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("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 } }> = []; diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index c288a9315..269d8ced2 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -71,9 +71,11 @@ import type { AgentRuntimeInfo, AgentSession, AgentSessionConfig, + AgentSlashCommand, AgentStreamEvent, AgentTimelineItem, AgentUsage, + ListModesOptions, ListModelsOptions, ListPersistedAgentsOptions, McpServerConfig, @@ -111,6 +113,14 @@ type ACPAgentClientOptions = { runtimeSettings?: ProviderRuntimeSettings; defaultCommand: [string, ...string[]]; defaultModes?: AgentMode[]; + modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[]; + sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse; + toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; + thinkingOptionWriter?: ( + connection: ClientSideConnection, + sessionId: string, + thinkingOptionId: string, + ) => Promise; capabilities?: AgentCapabilityFlags; }; @@ -120,6 +130,14 @@ type ACPAgentSessionOptions = { runtimeSettings?: ProviderRuntimeSettings; defaultCommand: [string, ...string[]]; defaultModes: AgentMode[]; + modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[]; + sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse; + toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; + thinkingOptionWriter?: ( + connection: ClientSideConnection, + sessionId: string, + thinkingOptionId: string, + ) => Promise; capabilities: AgentCapabilityFlags; handle?: AgentPersistenceHandle; launchEnv?: Record; @@ -131,7 +149,7 @@ type SpawnedACPProcess = { initialize: InitializeResponse; }; -type ACPToolSnapshot = { +export type ACPToolSnapshot = { toolCallId: string; title: string; kind?: ToolKind | null; @@ -154,7 +172,7 @@ type MessageAssemblyState = { text: string; }; -type SessionStateResponse = NewSessionResponse | LoadSessionResponse | ResumeSessionResponse; +export type SessionStateResponse = NewSessionResponse | LoadSessionResponse | ResumeSessionResponse; type TerminalExit = { exitCode?: number | null; @@ -271,6 +289,16 @@ export class ACPAgentClient implements AgentClient { protected readonly runtimeSettings?: ProviderRuntimeSettings; protected readonly defaultCommand: [string, ...string[]]; protected readonly defaultModes: AgentMode[]; + private readonly modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[]; + private readonly sessionResponseTransformer?: ( + response: SessionStateResponse, + ) => SessionStateResponse; + private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; + private readonly thinkingOptionWriter?: ( + connection: ClientSideConnection, + sessionId: string, + thinkingOptionId: string, + ) => Promise; constructor(options: ACPAgentClientOptions) { this.provider = options.provider; @@ -279,6 +307,10 @@ export class ACPAgentClient implements AgentClient { this.runtimeSettings = options.runtimeSettings; this.defaultCommand = options.defaultCommand; this.defaultModes = options.defaultModes ?? []; + this.modelTransformer = options.modelTransformer; + this.sessionResponseTransformer = options.sessionResponseTransformer; + this.toolSnapshotTransformer = options.toolSnapshotTransformer; + this.thinkingOptionWriter = options.thinkingOptionWriter; } async createSession( @@ -294,6 +326,10 @@ export class ACPAgentClient implements AgentClient { runtimeSettings: this.runtimeSettings, defaultCommand: this.defaultCommand, defaultModes: this.defaultModes, + modelTransformer: this.modelTransformer, + sessionResponseTransformer: this.sessionResponseTransformer, + toolSnapshotTransformer: this.toolSnapshotTransformer, + thinkingOptionWriter: this.thinkingOptionWriter, capabilities: this.capabilities, launchEnv: launchContext?.env, }, @@ -329,6 +365,10 @@ export class ACPAgentClient implements AgentClient { runtimeSettings: this.runtimeSettings, defaultCommand: this.defaultCommand, defaultModes: this.defaultModes, + modelTransformer: this.modelTransformer, + sessionResponseTransformer: this.sessionResponseTransformer, + toolSnapshotTransformer: this.toolSnapshotTransformer, + thinkingOptionWriter: this.thinkingOptionWriter, capabilities: this.capabilities, handle, launchEnv: launchContext?.env, @@ -345,7 +385,33 @@ export class ACPAgentClient implements AgentClient { cwd, mcpServers: [], }); - return deriveModelDefinitionsFromACP(this.provider, response.models, response.configOptions); + const transformed = this.transformSessionResponse(response); + const models = deriveModelDefinitionsFromACP( + this.provider, + transformed.models, + transformed.configOptions, + ); + return this.modelTransformer ? this.modelTransformer(models) : models; + } finally { + await this.closeProbe(probe); + } + } + + async listModes(options?: ListModesOptions): Promise { + const cwd = options?.cwd ?? process.cwd(); + const probe = await this.spawnProcess(undefined); + try { + const response = await probe.connection.newSession({ + cwd, + mcpServers: [], + }); + const transformed = this.transformSessionResponse(response); + const modeInfo = deriveModesFromACP( + this.defaultModes, + transformed.modes, + transformed.configOptions, + ); + return modeInfo.modes; } finally { await this.closeProbe(probe); } @@ -502,6 +568,10 @@ export class ACPAgentClient implements AgentClient { throw new Error(`Expected ${this.provider} config, received ${config.provider}`); } } + + protected transformSessionResponse(response: SessionStateResponse): SessionStateResponse { + return this.sessionResponseTransformer ? this.sessionResponseTransformer(response) : response; + } } export class ACPAgentSession implements AgentSession, ACPClient { @@ -512,6 +582,16 @@ export class ACPAgentSession implements AgentSession, ACPClient { private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly defaultCommand: [string, ...string[]]; private readonly defaultModes: AgentMode[]; + protected readonly modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[]; + private readonly sessionResponseTransformer?: ( + response: SessionStateResponse, + ) => SessionStateResponse; + private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; + private readonly thinkingOptionWriter?: ( + connection: ClientSideConnection, + sessionId: string, + thinkingOptionId: string, + ) => Promise; private readonly launchEnv?: Record; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly pendingPermissions = new Map(); @@ -528,11 +608,13 @@ export class ACPAgentSession implements AgentSession, ACPClient { private sessionId: string | null = null; private currentMode: string | null = null; private availableModes: AgentMode[]; + private availableModels: AgentModelDefinition[] = []; private currentModel: string | null = null; private thinkingOptionId: string | null = null; private currentTitle: string | null = null; private lastActivityAt: string | null = null; private configOptions: SessionConfigOption[] = []; + private cachedCommands: AgentSlashCommand[] = []; private currentTurnUsage: AgentUsage | undefined; private activeForegroundTurnId: string | null = null; private closed = false; @@ -549,6 +631,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.runtimeSettings = options.runtimeSettings; this.defaultCommand = options.defaultCommand; this.defaultModes = options.defaultModes; + this.modelTransformer = options.modelTransformer; + this.sessionResponseTransformer = options.sessionResponseTransformer; + this.toolSnapshotTransformer = options.toolSnapshotTransformer; + this.thinkingOptionWriter = options.thinkingOptionWriter; this.availableModes = options.defaultModes; this.launchEnv = options.launchEnv; this.initialHandle = options.handle; @@ -788,6 +874,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { return this.currentMode; } + async listCommands(): Promise { + return this.cachedCommands; + } + async setMode(modeId: string): Promise { if (!this.connection || !this.sessionId) { throw new Error("ACP session not initialized"); @@ -825,7 +915,12 @@ export class ACPAgentSession implements AgentSession, ACPClient { return; } - if (this.agentCapabilities?.sessionCapabilities && "unstable_setSessionModel" in this.connection) { + const modelExists = this.availableModels.some((model) => model.id === modelId); + if (!modelExists && this.availableModels.length > 0) { + throw new Error(`Unknown ${this.provider} model '${modelId}'`); + } + + if ("unstable_setSessionModel" in this.connection) { try { await this.connection.unstable_setSessionModel({ sessionId: this.sessionId, @@ -859,6 +954,12 @@ export class ACPAgentSession implements AgentSession, ACPClient { return; } + if (this.thinkingOptionWriter) { + await this.thinkingOptionWriter(this.connection, this.sessionId, thinkingOptionId); + this.thinkingOptionId = thinkingOptionId; + return; + } + const option = this.getSelectConfigOption("thought_level"); if (!option) { throw new Error(`${this.provider} does not expose ACP thought-level selection`); @@ -984,11 +1085,17 @@ export class ACPAgentSession implements AgentSession, ACPClient { params: RequestPermissionRequest, ): Promise { const requestId = randomUUID(); + let toolSnapshot = + this.toolCalls.get(params.toolCall.toolCallId) ?? + mergeToolSnapshot(params.toolCall.toolCallId, params.toolCall); + if (this.toolSnapshotTransformer) { + toolSnapshot = this.toolSnapshotTransformer(toolSnapshot); + } const request = mapPermissionRequest( this.provider, requestId, params, - this.toolCalls.get(params.toolCall.toolCallId) ?? mergeToolSnapshot(params.toolCall.toolCallId, params.toolCall), + toolSnapshot, ); const promise = new Promise((resolve, reject) => { @@ -1182,13 +1289,19 @@ export class ACPAgentSession implements AgentSession, ACPClient { } private applySessionState(response: SessionStateResponse): void { - this.configOptions = response.configOptions ?? []; + const transformed = this.sessionResponseTransformer + ? this.sessionResponseTransformer(response) + : response; - const modeInfo = deriveModesFromACP(this.defaultModes, response.modes, this.configOptions); - this.availableModes = modeInfo.modes.length > 0 ? modeInfo.modes : this.defaultModes; + this.configOptions = transformed.configOptions ?? []; + + const modeInfo = deriveModesFromACP(this.defaultModes, transformed.modes, this.configOptions); + this.availableModes = modeInfo.modes; + this.availableModels = this.deriveAvailableModels(transformed.models); this.currentMode = modeInfo.currentModeId ?? this.currentMode; - this.currentModel = response.models?.currentModelId ?? deriveCurrentConfigValue(this.configOptions, "model"); + this.currentModel = + transformed.models?.currentModelId ?? deriveCurrentConfigValue(this.configOptions, "model"); this.thinkingOptionId = deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId; } @@ -1231,13 +1344,19 @@ export class ACPAgentSession implements AgentSession, ACPClient { return item ? [this.wrapTimeline(item)] : []; } case "tool_call": { - const snapshot = mergeToolSnapshot(update.toolCallId, update); + let snapshot = mergeToolSnapshot(update.toolCallId, update); + if (this.toolSnapshotTransformer) { + snapshot = this.toolSnapshotTransformer(snapshot); + } this.toolCalls.set(update.toolCallId, snapshot); return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))]; } case "tool_call_update": { const previous = this.toolCalls.get(update.toolCallId); - const snapshot = mergeToolSnapshot(update.toolCallId, update, previous); + let snapshot = mergeToolSnapshot(update.toolCallId, update, previous); + if (this.toolSnapshotTransformer) { + snapshot = this.toolSnapshotTransformer(snapshot); + } this.toolCalls.set(update.toolCallId, snapshot); return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))]; } @@ -1256,6 +1375,11 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.handleUsageUpdate(update); return []; case "available_commands_update": + this.cachedCommands = update.availableCommands.map((command) => ({ + name: command.name, + description: command.description, + argumentHint: "", + })); return []; default: return []; @@ -1298,15 +1422,21 @@ export class ACPAgentSession implements AgentSession, ACPClient { private handleConfigOptionUpdate(update: ConfigOptionUpdate): void { this.configOptions = update.configOptions; const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions); - if (modeInfo.modes.length > 0) { - this.availableModes = modeInfo.modes; - } + this.availableModes = modeInfo.modes; + this.availableModels = this.deriveAvailableModels(null); this.currentMode = modeInfo.currentModeId ?? this.currentMode; this.currentModel = deriveCurrentConfigValue(this.configOptions, "model") ?? this.currentModel; this.thinkingOptionId = deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId; } + private deriveAvailableModels( + models: SessionModelState | null | undefined, + ): AgentModelDefinition[] { + const availableModels = deriveModelDefinitionsFromACP(this.provider, models, this.configOptions); + return this.modelTransformer ? this.modelTransformer(availableModels) : availableModels; + } + private handleSessionInfoUpdate(update: SessionInfoUpdate): void { if ("title" in update) { this.currentTitle = update.title ?? null; diff --git a/packages/server/src/server/agent/providers/claude-acp-agent.ts b/packages/server/src/server/agent/providers/claude-acp-agent.ts deleted file mode 100644 index 944f06fac..000000000 --- a/packages/server/src/server/agent/providers/claude-acp-agent.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Logger } from "pino"; - -import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js"; -import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; -import { ACPAgentClient } from "./acp-agent.js"; - -const CLAUDE_ACP_CAPABILITIES: AgentCapabilityFlags = { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, -}; - -const CLAUDE_ACP_MODES: AgentMode[] = [ - { - id: "default", - label: "Always Ask", - description: "Prompts for permission the first time a tool is used", - }, - { - id: "acceptEdits", - label: "Accept File Edits", - description: "Automatically approves edit-focused tools without prompting", - }, - { - id: "plan", - label: "Plan Mode", - description: "Analyze the codebase without executing tools or edits", - }, - { - id: "bypassPermissions", - label: "Bypass", - description: "Skip all permission prompts (use with caution)", - }, -]; - -type ClaudeACPAgentClientOptions = { - logger: Logger; - runtimeSettings?: ProviderRuntimeSettings; -}; - -export class ClaudeACPAgentClient extends ACPAgentClient { - constructor(options: ClaudeACPAgentClientOptions) { - super({ - provider: "claude-acp", - logger: options.logger, - runtimeSettings: options.runtimeSettings, - defaultCommand: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"], - defaultModes: CLAUDE_ACP_MODES, - capabilities: CLAUDE_ACP_CAPABILITIES, - }); - } - - override async isAvailable(): Promise { - if (!(await super.isAvailable())) { - return false; - } - return Boolean(process.env["CLAUDE_CODE_OAUTH_TOKEN"] || process.env["ANTHROPIC_API_KEY"]); - } -} diff --git a/packages/server/src/server/agent/providers/pi-acp-agent.ts b/packages/server/src/server/agent/providers/pi-acp-agent.ts new file mode 100644 index 000000000..dc4637330 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi-acp-agent.ts @@ -0,0 +1,147 @@ +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 { AgentCapabilityFlags, AgentModelDefinition } from "../agent-sdk-types.js"; +import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; +import { isCommandAvailable } from "../provider-launch-config.js"; +import { + ACPAgentClient, + type ACPToolSnapshot, + type SessionStateResponse, +} from "./acp-agent.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 = { + 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 ?? []), + ], + }; +} + +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, + }); + } + + override async isAvailable(): Promise { + if (!existsSync(resolvedPiAcpPath)) { + return false; + } + if (!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")) + ); + } +} diff --git a/packages/server/src/server/daemon-e2e/agent-configs.ts b/packages/server/src/server/daemon-e2e/agent-configs.ts index 82bfd805a..4e7265b4f 100644 --- a/packages/server/src/server/daemon-e2e/agent-configs.ts +++ b/packages/server/src/server/daemon-e2e/agent-configs.ts @@ -15,9 +15,9 @@ dotenv.config({ path: resolve(serverRoot, ".env.test"), override: true }); export interface AgentTestConfig { provider: string; - model: string; + model?: string; thinkingOptionId?: string; - modes: { + modes?: { full: string; // No permissions required ask: string; // Requires permission approval }; @@ -32,14 +32,6 @@ export const agentConfigs = { ask: "default", }, }, - "claude-acp": { - provider: "claude-acp", - model: "haiku", - modes: { - full: "bypassPermissions", - ask: "default", - }, - }, codex: { provider: "codex", model: "gpt-5.1-codex-mini", @@ -65,6 +57,10 @@ export const agentConfigs = { ask: "default", }, }, + pi: { + provider: "pi", + thinkingOptionId: "medium", + }, } as const satisfies Record; export type AgentProvider = keyof typeof agentConfigs; @@ -77,9 +73,9 @@ export function getFullAccessConfig(provider: AgentProvider) { const thinkingOptionId = "thinkingOptionId" in config ? config.thinkingOptionId : undefined; return { provider: config.provider, - model: config.model, + ...(config.model ? { model: config.model } : {}), ...(thinkingOptionId ? { thinkingOptionId } : {}), - modeId: config.modes.full, + ...(config.modes?.full ? { modeId: config.modes.full } : {}), }; } @@ -91,9 +87,9 @@ export function getAskModeConfig(provider: AgentProvider) { const thinkingOptionId = "thinkingOptionId" in config ? config.thinkingOptionId : undefined; return { provider: config.provider, - model: config.model, + ...(config.model ? { model: config.model } : {}), ...(thinkingOptionId ? { thinkingOptionId } : {}), - modeId: config.modes.ask, + ...(config.modes?.ask ? { modeId: config.modes.ask } : {}), }; } @@ -112,8 +108,6 @@ export function isProviderAvailable(provider: AgentProvider): boolean { isCommandAvailable("claude") && (Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY)) ); - case "claude-acp": - return Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY); case "codex": return ( isCommandAvailable("codex") && @@ -123,6 +117,14 @@ export function isProviderAvailable(provider: AgentProvider): boolean { return isCommandAvailable("copilot"); case "opencode": return isCommandAvailable("opencode"); + case "pi": + return ( + isCommandAvailable(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) || + existsSync(join(homedir(), ".pi", "agent", "auth.json"))) + ); } } @@ -131,8 +133,8 @@ export function isProviderAvailable(provider: AgentProvider): boolean { */ export const allProviders: AgentProvider[] = [ "claude", - "claude-acp", "codex", "copilot", "opencode", + "pi", ]; diff --git a/packages/server/src/server/daemon-e2e/claude-acp.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-acp.real.e2e.test.ts deleted file mode 100644 index 49f158613..000000000 --- a/packages/server/src/server/daemon-e2e/claude-acp.real.e2e.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import pino from "pino"; - -import { ClaudeACPAgentClient } from "../agent/providers/claude-acp-agent.js"; -import type { SessionOutboundMessage } from "../messages.js"; -import { DaemonClient } from "../test-utils/daemon-client.js"; -import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; -import { getAskModeConfig, getFullAccessConfig, isProviderAvailable } from "./agent-configs.js"; - -function tmpCwd(): string { - return mkdtempSync(path.join(tmpdir(), "daemon-real-claude-acp-")); -} - -describe("daemon E2E (real claude-acp)", () => { - test.runIf(isProviderAvailable("claude-acp"))( - "smoke test in full-access mode", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { "claude-acp": new ClaudeACPAgentClient({ logger }) }, - logger, - }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - - try { - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-acp-real-smoke" }, - }); - - const agent = await client.createAgent({ - cwd, - title: "claude-acp-real-smoke", - ...getFullAccessConfig("claude-acp"), - }); - - await client.sendMessage( - agent.id, - "Reply with exactly: PINEAPPLE", - ); - - 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("claude-acp"); - expect(finish.final?.persistence?.sessionId).toBeTruthy(); - - 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"); - - expect(assistantText).toContain("PINEAPPLE"); - } finally { - await client.close().catch(() => undefined); - await daemon.close().catch(() => undefined); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 420_000, - ); - - test.runIf(isProviderAvailable("claude-acp"))( - "permission flow in ask mode", - async () => { - const logger = pino({ level: "silent" }); - const cwd = tmpCwd(); - const daemon = await createTestPaseoDaemon({ - agentClients: { "claude-acp": new ClaudeACPAgentClient({ logger }) }, - logger, - }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - const messages: SessionOutboundMessage[] = []; - const targetFile = path.join(cwd, "permission-target.txt"); - - try { - writeFileSync(targetFile, "ACP_PERMISSION_CONTENT\n", "utf8"); - - await client.connect(); - await client.fetchAgents({ - subscribe: { subscriptionId: "claude-acp-real-permission" }, - }); - - const unsubscribe = client.subscribeRawMessages((message) => { - messages.push(message); - }); - - try { - const agent = await client.createAgent({ - cwd, - title: "claude-acp-real-permission", - ...getAskModeConfig("claude-acp"), - }); - - await client.sendMessage( - agent.id, - [ - `Use the Bash tool to run exactly: cat ${JSON.stringify(targetFile)}.`, - "If approval is required, wait for approval.", - "After the command succeeds, reply with exactly: ACP_PERMISSION_DONE", - ].join(" "), - ); - - const permissionState = await client.waitForFinish(agent.id, 30_000); - expect(permissionState.status).toBe("permission"); - expect(permissionState.final?.pendingPermissions?.length).toBeGreaterThan(0); - - const permission = permissionState.final!.pendingPermissions[0]!; - await client.respondToPermission(agent.id, permission.id, { - behavior: "allow", - }); - - const finalState = await client.waitForFinish(agent.id, 60_000); - expect(finalState.status).toBe("idle"); - - const hasPermissionResolved = messages.some((message) => { - if (message.type !== "agent_stream") { - return false; - } - if (message.payload.agentId !== agent.id) { - return false; - } - return ( - message.payload.event.type === "permission_resolved" && - message.payload.event.requestId === permission.id && - message.payload.event.resolution.behavior === "allow" - ); - }); - expect(hasPermissionResolved).toBe(true); - } finally { - unsubscribe(); - } - } finally { - await client.close().catch(() => undefined); - await daemon.close().catch(() => undefined); - rmSync(cwd, { recursive: true, force: true }); - } - }, - 420_000, - ); -}); diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts new file mode 100644 index 000000000..a0f5ac8b1 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; + +import { PiACPAgentClient } from "../agent/providers/pi-acp-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-")); +} + +describe("daemon E2E (real pi)", () => { + test.runIf(isProviderAvailable("pi"))( + "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, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ + subscribe: { subscriptionId: "pi-real-smoke" }, + }); + + const agent = await client.createAgent({ + cwd, + title: "pi-real-smoke", + provider: "pi", + thinkingOptionId: "medium", + }); + + await client.sendMessage(agent.id, "Reply with exactly: PINEAPPLE"); + + 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(); + + 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"); + + 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, + ); +});