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, + ); +});