diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md new file mode 100644 index 000000000..6de30f82b --- /dev/null +++ b/docs/PROVIDERS.md @@ -0,0 +1,359 @@ +# Adding a New Provider to Paseo + +This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both. + +## Two Integration Patterns + +### ACP (Agent Client Protocol) -- recommended + +Extend `ACPAgentClient`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks. + +Existing ACP providers: `claude-acp`, `copilot`. + +### Direct + +Implement the `AgentClient` and `AgentSession` interfaces yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch. + +Existing direct providers: `claude`, `codex`, `opencode`. + +--- + +## ACP Provider Checklist + +### 1. Create the provider class + +Create `packages/server/src/server/agent/providers/{name}-agent.ts`. + +Define capabilities, modes, and a thin subclass of `ACPAgentClient`: + +```ts +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 MY_PROVIDER_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, +}; + +const MY_PROVIDER_MODES: AgentMode[] = [ + { + id: "default", + label: "Default", + description: "Standard agent mode", + }, + // Add more modes as needed +]; + +type MyProviderClientOptions = { + logger: Logger; + runtimeSettings?: ProviderRuntimeSettings; +}; + +export class MyProviderACPAgentClient extends ACPAgentClient { + constructor(options: MyProviderClientOptions) { + super({ + provider: "my-provider", // Must match the ID used everywhere else + logger: options.logger, + runtimeSettings: options.runtimeSettings, + defaultCommand: ["my-agent-binary", "--acp"], // CLI command to spawn + defaultModes: MY_PROVIDER_MODES, + capabilities: MY_PROVIDER_CAPABILITIES, + }); + } + + // Override isAvailable() if the provider needs specific auth/env vars + override async isAvailable(): Promise { + if (!(await super.isAvailable())) { + return false; // Binary not found + } + return Boolean(process.env["MY_PROVIDER_API_KEY"]); + } +} +``` + +The `super.isAvailable()` call checks that the binary from `defaultCommand` is on `$PATH`. Override only to add credential checks on top. + +For reference, here is how Copilot does it -- no auth override needed because the CLI handles auth itself: + +```ts +export class CopilotACPAgentClient extends ACPAgentClient { + constructor(options: CopilotACPAgentClientOptions) { + super({ + provider: "copilot", + logger: options.logger, + runtimeSettings: options.runtimeSettings, + defaultCommand: ["copilot", "--acp"], + defaultModes: COPILOT_MODES, + capabilities: COPILOT_CAPABILITIES, + }); + } + + override async isAvailable(): Promise { + return super.isAvailable(); + } +} +``` + +### 2. Add to the provider manifest + +In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry. + +First, define the modes with visual metadata: + +```ts +const MY_PROVIDER_MODES: AgentProviderModeDefinition[] = [ + { + id: "default", + label: "Default", + description: "Standard agent mode", + icon: "ShieldCheck", + colorTier: "safe", + }, + { + id: "autonomous", + label: "Autonomous", + description: "Runs without prompting", + icon: "ShieldOff", + colorTier: "dangerous", + }, +]; +``` + +Available `colorTier` values: `"safe"`, `"moderate"`, `"dangerous"`, `"planning"`. +Available `icon` values: `"ShieldCheck"`, `"ShieldAlert"`, `"ShieldOff"`. + +Then add to the `AGENT_PROVIDER_DEFINITIONS` array: + +```ts +export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ + // ... existing providers ... + { + id: "my-provider", + label: "My Provider", + description: "Short description of the provider", + defaultModeId: "default", + modes: MY_PROVIDER_MODES, + // Optional: enable voice + voice: { + enabled: true, + defaultModeId: "default", + defaultModel: "some-model", + }, + }, +]; +``` + +### 3. Add the factory to the provider registry + +In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry: + +```ts +import { MyProviderACPAgentClient } from "./providers/my-provider-agent.js"; + +const PROVIDER_CLIENT_FACTORIES: Record = { + // ... existing factories ... + "my-provider": (logger, runtimeSettings) => + new MyProviderACPAgentClient({ + logger, + runtimeSettings: runtimeSettings?.["my-provider"], + }), +}; +``` + +### 4. Add a provider icon (app) + +Create `packages/app/src/components/icons/my-provider-icon.tsx` following the pattern from existing icons (e.g., `claude-icon.tsx`): + +```tsx +import Svg, { Path } from "react-native-svg"; + +interface MyProviderIconProps { + size?: number; + color?: string; +} + +export function MyProviderIcon({ size = 16, color = "currentColor" }: MyProviderIconProps) { + return ( + + + + ); +} +``` + +Then register it in `packages/app/src/components/provider-icons.ts`: + +```ts +import { MyProviderIcon } from "@/components/icons/my-provider-icon"; + +const PROVIDER_ICONS: Record = { + claude: ClaudeIcon as unknown as typeof Bot, + codex: CodexIcon as unknown as typeof Bot, + "my-provider": MyProviderIcon as unknown as typeof Bot, +}; +``` + +If no icon is registered, the app falls back to a generic `Bot` icon from lucide. + +### 5. Add E2E test config + +In `packages/server/src/server/daemon-e2e/agent-configs.ts`, add your provider: + +```ts +export const agentConfigs = { + // ... existing configs ... + "my-provider": { + provider: "my-provider", + model: "default-model-id", + modes: { + full: "autonomous", // Mode with no permission prompts + ask: "default", // Mode that requires permission approval + }, + }, +} as const satisfies Record; +``` + +Add an availability check in `isProviderAvailable()`: + +```ts +case "my-provider": + return ( + isCommandAvailable("my-agent-binary") && + Boolean(process.env.MY_PROVIDER_API_KEY) + ); +``` + +Add to the `allProviders` array: + +```ts +export const allProviders: AgentProvider[] = [ + "claude", + "claude-acp", + "codex", + "copilot", + "opencode", + "my-provider", +]; +``` + +### 6. Run typecheck + +```bash +npm run typecheck +``` + +This is required after every change per project rules. + +--- + +## Direct Provider Checklist + +If your agent does not speak ACP, implement the interfaces from `agent-sdk-types.ts` directly. + +### Interfaces to implement + +**`AgentClient`** -- factory for sessions and model listing: + +```ts +interface AgentClient { + readonly provider: AgentProvider; + readonly capabilities: AgentCapabilityFlags; + createSession(config: AgentSessionConfig, launchContext?: AgentLaunchContext): Promise; + resumeSession(handle: AgentPersistenceHandle, overrides?: Partial, launchContext?: AgentLaunchContext): Promise; + listModels(options?: ListModelsOptions): Promise; + isAvailable(): Promise; + // Optional: + listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise; +} +``` + +**`AgentSession`** -- a running agent conversation: + +```ts +interface AgentSession { + readonly provider: AgentProvider; + readonly id: string | null; + readonly capabilities: AgentCapabilityFlags; + run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise; + startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>; + subscribe(callback: (event: AgentStreamEvent) => void): () => void; + streamHistory(): AsyncGenerator; + getRuntimeInfo(): Promise; + getAvailableModes(): Promise; + getCurrentMode(): Promise; + setMode(modeId: string): Promise; + getPendingPermissions(): AgentPermissionRequest[]; + respondToPermission(requestId: string, response: AgentPermissionResponse): Promise; + describePersistence(): AgentPersistenceHandle | null; + interrupt(): Promise; + close(): Promise; + // Optional: + listCommands?(): Promise; + setModel?(modelId: string | null): Promise; + setThinkingOption?(thinkingOptionId: string | null): Promise; +} +``` + +### Steps + +1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces +2. Add to the provider manifest (same as ACP step 2 above) +3. Add factory to the registry (same as ACP step 3 above) +4. Add icon (same as ACP step 4 above) +5. Add E2E config (same as ACP step 5 above) +6. Run typecheck + +--- + +## Testing + +### Manual testing with the CLI + +Start the daemon if not already running, then: + +```bash +# Launch an agent with your provider +paseo run --provider my-provider + +# Launch with a specific model and mode +paseo run --provider my-provider --model some-model --mode default + +# List running agents +paseo ls -a -g + +# Check if the provider reports models +paseo models --provider my-provider +``` + +### E2E test patterns + +The E2E configs in `agent-configs.ts` expose two helpers: + +- `getFullAccessConfig(provider)` -- returns config for a session with no permission prompts +- `getAskModeConfig(provider)` -- returns config for a session that triggers permission requests + +Tests use `isProviderAvailable(provider)` to skip when the binary or credentials are missing, so CI will not fail for providers that are not installed. + +--- + +## Gotchas + +**Mode IDs can be URIs.** ACP providers like Copilot use full URIs as mode IDs (e.g., `"https://agentclientprotocol.com/protocol/session-modes#agent"`). Never assume mode IDs are simple strings. The manifest `defaultModeId` must match exactly. + +**Models and modes are discovered dynamically.** ACP providers report available models and modes at runtime via the protocol. The static definitions in `provider-manifest.ts` are used for UI scaffolding (icons, color tiers) but the runtime values from the agent process are the source of truth. + +**`AgentProvider` is always `string`.** The type alias is `type AgentProvider = string`. Provider IDs are validated against the manifest at runtime, not at the type level. + +**Auth patterns vary.** Some providers need API keys in env vars (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`), some use OAuth tokens (`CLAUDE_CODE_OAUTH_TOKEN`), some use auth files (`~/.codex/auth.json`), and some handle auth entirely in their CLI binary (Copilot). Your `isAvailable()` method should check whatever is needed. + +**The manifest mode list and the agent class mode list are separate.** The manifest in `provider-manifest.ts` includes UI metadata (`icon`, `colorTier`). The agent class defines modes without UI metadata (just `id`, `label`, `description`). Keep them in sync. + +**`defaultCommand` is a tuple.** The first element is the binary name, the rest are default arguments. The base class uses this to find the executable and spawn the process. + +**Runtime settings can override the command.** Users can configure custom binary paths or environment variables per provider via `ProviderRuntimeSettings`. Your factory in the registry should pass `runtimeSettings?.["your-provider"]` through to the constructor. diff --git a/nix/package.nix b/nix/package.nix index 695c396b8..1e60f29d7 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-UtE4rf5CbPCtp86swvZ0IbU+DhGxZlEfDubAiWz2RKE="; + npmDepsHash = "sha256-0fzdnz2LQ0IRk2wbe0/wORylp7mgU0gl2fAs8my4Eok="; # 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 405ef4ffe..f0835ede9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,15 @@ } } }, + "node_modules/@agentclientprotocol/sdk": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.17.1.tgz", + "integrity": "sha512-yjyIn8POL18IOXioLySYiL0G44kZ/IZctAls7vS3AC3X+qLhFXbWmzABSZehwRnWFShMXT+ODa/HJG1+mGXZ1A==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/@ai-sdk/gateway": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.1.tgz", @@ -35416,6 +35425,7 @@ "name": "@getpaseo/server", "version": "0.1.43-rc.1", "dependencies": { + "@agentclientprotocol/sdk": "^0.17.1", "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", diff --git a/packages/app/src/components/icons/copilot-icon.tsx b/packages/app/src/components/icons/copilot-icon.tsx new file mode 100644 index 000000000..a9075a2a5 --- /dev/null +++ b/packages/app/src/components/icons/copilot-icon.tsx @@ -0,0 +1,18 @@ +import Svg, { Path } from "react-native-svg"; + +interface CopilotIconProps { + size?: number; + color?: string; +} + +export function CopilotIcon({ size = 16, color = "currentColor" }: CopilotIconProps) { + return ( + + + + + ); +} diff --git a/packages/app/src/components/icons/opencode-icon.tsx b/packages/app/src/components/icons/opencode-icon.tsx new file mode 100644 index 000000000..5f45f72ef --- /dev/null +++ b/packages/app/src/components/icons/opencode-icon.tsx @@ -0,0 +1,19 @@ +import Svg, { Path } from "react-native-svg"; + +interface OpenCodeIconProps { + size?: number; + color?: string; +} + +export function OpenCodeIcon({ size = 16, color = "currentColor" }: OpenCodeIconProps) { + return ( + + + + + ); +} diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index 0089dac56..a8bd2ec04 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -1,10 +1,15 @@ import { Bot } from "lucide-react-native"; 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"; 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, }; export function getProviderIcon(provider: string): typeof Bot { diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 880c8a4b9..96910ffa7 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -11,9 +11,8 @@ import { AgentInputArea } from "@/components/agent-input-area"; import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; import { FileDropZone } from "@/components/file-drop-zone"; import type { ImageAttachment } from "@/components/message-input"; +import { getProviderIcon } from "@/components/provider-icons"; import { ToastViewport, useToastHost } from "@/components/toast-host"; -import { ClaudeIcon } from "@/components/icons/claude-icon"; -import { CodexIcon } from "@/components/icons/codex-icon"; import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; import { useAgentInitialization } from "@/hooks/use-agent-initialization"; import { @@ -51,16 +50,14 @@ import { } from "@/screens/agent/agent-ready-screen-bottom-anchor"; function formatProviderLabel(provider: Agent["provider"]): string { - if (provider === "claude") { - return "Claude"; - } - if (provider === "codex") { - return "Codex"; - } if (!provider) { return "Agent"; } - return provider.charAt(0).toUpperCase() + provider.slice(1); + return provider + .split(/[-_\s]+/) + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); } function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string | null { @@ -96,7 +93,7 @@ function useAgentPanelDescriptor( ); const provider = descriptorState.provider; const label = resolveWorkspaceAgentTabLabel(descriptorState.title); - const icon = provider === "claude" ? ClaudeIcon : provider === "codex" ? CodexIcon : Bot; + const icon = getProviderIcon(provider) ?? Bot; return { label: label ?? "", diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index 040aae4e8..c4817076b 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -412,7 +412,7 @@ export async function runRunCommand( const callStructuredTurn = async (structuredPrompt: string): Promise => { if (!structuredAgent) { structuredAgent = await client.createAgent({ - provider: resolvedProviderModel.provider as "claude" | "codex" | "opencode", + provider: resolvedProviderModel.provider, cwd, title: options.name, modeId: options.mode, @@ -511,7 +511,7 @@ export async function runRunCommand( // Create the agent const agent = await client.createAgent({ - provider: resolvedProviderModel.provider as "claude" | "codex" | "opencode", + provider: resolvedProviderModel.provider, cwd, title: options.name, modeId: options.mode, diff --git a/packages/cli/src/commands/loop/run.ts b/packages/cli/src/commands/loop/run.ts index 4fa6a14d9..ab693d6bc 100644 --- a/packages/cli/src/commands/loop/run.ts +++ b/packages/cli/src/commands/loop/run.ts @@ -18,9 +18,9 @@ export interface LoopRunRow { } export interface LoopRunOptions extends CommandOptions { - provider?: "claude" | "codex" | "opencode"; + provider?: string; model?: string; - verifyProvider?: "claude" | "codex" | "opencode"; + verifyProvider?: string; verifyModel?: string; verify?: string; verifyCheck?: string[]; diff --git a/packages/cli/src/commands/loop/types.ts b/packages/cli/src/commands/loop/types.ts index c951b3e95..72ca2a67a 100644 --- a/packages/cli/src/commands/loop/types.ts +++ b/packages/cli/src/commands/loop/types.ts @@ -45,11 +45,11 @@ export interface LoopRecord { name: string | null; prompt: string; cwd: string; - provider: "claude" | "codex" | "opencode"; + provider: string; model: string | null; - workerProvider: "claude" | "codex" | "opencode" | null; + workerProvider: string | null; workerModel: string | null; - verifierProvider: "claude" | "codex" | "opencode" | null; + verifierProvider: string | null; verifierModel: string | null; verifyPrompt: string | null; verifyChecks: string[]; @@ -122,11 +122,11 @@ export interface LoopStopPayload { export interface LoopRunInput { prompt: string; cwd: string; - provider?: "claude" | "codex" | "opencode"; + provider?: string; model?: string; - workerProvider?: "claude" | "codex" | "opencode"; + workerProvider?: string; workerModel?: string; - verifierProvider?: "claude" | "codex" | "opencode"; + verifierProvider?: string; verifierModel?: string; verifyPrompt?: string; verifyChecks?: string[]; diff --git a/packages/cli/src/commands/provider/ls.ts b/packages/cli/src/commands/provider/ls.ts index 233020718..7e8bcfbb5 100644 --- a/packages/cli/src/commands/provider/ls.ts +++ b/packages/cli/src/commands/provider/ls.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; +import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/server"; /** Provider list item for display */ export interface ProviderListItem { @@ -9,27 +10,13 @@ export interface ProviderListItem { modes: string; } -/** Static provider data - providers are built-in and don't require daemon */ -const PROVIDERS: ProviderListItem[] = [ - { - provider: "claude", - status: "available", - defaultMode: "default", - modes: "plan, default, bypass", - }, - { - provider: "codex", - status: "available", - defaultMode: "auto", - modes: "read-only, auto, full-access", - }, - { - provider: "opencode", - status: "available", - defaultMode: "default", - modes: "plan, default, bypass", - }, -]; +/** Derive provider list from the manifest — single source of truth */ +const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({ + provider: def.id, + status: "available", + defaultMode: def.defaultModeId ?? "default", + modes: def.modes.map((m) => m.label).join(", "), +})); /** Schema for provider ls output */ export const providerLsSchema: OutputSchema = { diff --git a/packages/cli/src/commands/schedule/types.ts b/packages/cli/src/commands/schedule/types.ts index 0e3e5b11a..6f9a27b74 100644 --- a/packages/cli/src/commands/schedule/types.ts +++ b/packages/cli/src/commands/schedule/types.ts @@ -22,7 +22,7 @@ export type ScheduleTarget = | { type: "new-agent"; config: { - provider: "claude" | "codex" | "opencode"; + provider: string; cwd: string; modeId?: string; model?: string; diff --git a/packages/cli/tests/15-provider.test.ts b/packages/cli/tests/15-provider.test.ts index 89e301c38..dda23cc66 100644 --- a/packages/cli/tests/15-provider.test.ts +++ b/packages/cli/tests/15-provider.test.ts @@ -54,7 +54,7 @@ let claudeModelsFromJson: ProviderModel[] = []; const ctx = await createE2ETestContext({ timeout: 120000 }); async function runProviderModelsJson( - provider: "claude" | "codex" | "opencode", + provider: string, ): Promise { const transientNeedles = ["transport closed", "timed out", "timeout", "socket", "econn"]; diff --git a/packages/cli/tests/e2e/run-output-schema.test.ts b/packages/cli/tests/e2e/run-output-schema.test.ts index 89b4afb25..670871850 100644 --- a/packages/cli/tests/e2e/run-output-schema.test.ts +++ b/packages/cli/tests/e2e/run-output-schema.test.ts @@ -50,7 +50,7 @@ async function cleanup(): Promise { } async function runProviderCase(input: { - provider: "claude" | "codex" | "opencode"; + provider: string; mode: string; model: string; }): Promise { diff --git a/packages/server/package.json b/packages/server/package.json index c934152e6..a5fd68ea8 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -60,6 +60,7 @@ "test:e2e:ui": "vitest --ui e2e.test.ts" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.17.1", "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index bc0b8d89c..fa318a593 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -11,6 +11,9 @@ export interface AgentModeVisuals { export interface AgentProviderModeDefinition extends AgentMode, AgentModeVisuals {} +// TODO: `modes` should not be static. Providers (especially ACP) report their +// own modes at runtime via session/new. We should fetch modes from the provider +// as source of truth and enrich with UI metadata (icons, colorTier) on top. export interface AgentProviderDefinition { id: string; label: string; @@ -80,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", @@ -110,6 +137,18 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ defaultModel: "haiku", }, }, + { + id: "claude-acp", + label: "Claude ACP", + description: "Claude Code via Agent Client Protocol with streaming, permissions, and session resume", + defaultModeId: "default", + modes: CLAUDE_MODES, + voice: { + enabled: true, + defaultModeId: "default", + defaultModel: "haiku", + }, + }, { id: "codex", label: "Codex", @@ -122,6 +161,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", diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index d01e0c16e..9929a1b42 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -8,7 +8,9 @@ import type { AgentProviderRuntimeSettingsMap } from "./provider-launch-config.j import type { Logger } from "pino"; import { ClaudeAgentClient } from "./providers/claude-agent.js"; +import { ClaudeACPAgentClient } from "./providers/claude-acp-agent.js"; import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js"; +import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js"; import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js"; import { @@ -30,37 +32,59 @@ type BuildProviderRegistryOptions = { runtimeSettings?: AgentProviderRuntimeSettingsMap; }; +type ProviderClientFactory = ( + logger: Logger, + runtimeSettings?: AgentProviderRuntimeSettingsMap, +) => AgentClient; + +const PROVIDER_CLIENT_FACTORIES: Record = { + claude: (logger, runtimeSettings) => + new ClaudeAgentClient({ + logger, + runtimeSettings: runtimeSettings?.claude, + }), + "claude-acp": (logger, runtimeSettings) => + new ClaudeACPAgentClient({ + logger, + runtimeSettings: runtimeSettings?.["claude-acp"], + }), + 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), +}; + +function getProviderClientFactory(provider: string): ProviderClientFactory { + const factory = PROVIDER_CLIENT_FACTORIES[provider]; + if (!factory) { + throw new Error(`No provider client factory registered for '${provider}'`); + } + return factory; +} + export function buildProviderRegistry( logger: Logger, options?: BuildProviderRegistryOptions, ): Record { const runtimeSettings = options?.runtimeSettings; - const claudeClient = new ClaudeAgentClient({ - logger, - runtimeSettings: runtimeSettings?.claude, - }); - const codexClient = new CodexAppServerAgentClient(logger, runtimeSettings?.codex); - const opencodeClient = new OpenCodeAgentClient(logger, runtimeSettings?.opencode); - - return { - claude: { - ...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "claude")!, - createClient: (logger: Logger) => - new ClaudeAgentClient({ logger, runtimeSettings: runtimeSettings?.claude }), - fetchModels: (options) => claudeClient.listModels(options), - }, - codex: { - ...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "codex")!, - createClient: (logger: Logger) => - new CodexAppServerAgentClient(logger, runtimeSettings?.codex), - fetchModels: (options) => codexClient.listModels(options), - }, - opencode: { - ...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "opencode")!, - createClient: (logger: Logger) => new OpenCodeAgentClient(logger, runtimeSettings?.opencode), - fetchModels: (options) => opencodeClient.listModels(options), - }, - }; + return Object.fromEntries( + AGENT_PROVIDER_DEFINITIONS.map((definition) => { + const createClient = getProviderClientFactory(definition.id); + const modelClient = createClient(logger, runtimeSettings); + return [ + definition.id, + { + ...definition, + createClient: (providerLogger: Logger) => createClient(providerLogger, runtimeSettings), + fetchModels: (listOptions?: ListModelsOptions) => modelClient.listModels(listOptions), + } satisfies ProviderDefinition, + ]; + }), + ) as Record; } // Deprecated: Use buildProviderRegistry instead @@ -71,11 +95,12 @@ export function createAllClients( options?: BuildProviderRegistryOptions, ): Record { const registry = buildProviderRegistry(logger, options); - return { - claude: registry.claude.createClient(logger), - codex: registry.codex.createClient(logger), - opencode: registry.opencode.createClient(logger), - }; + return Object.fromEntries( + Object.entries(registry).map(([provider, definition]) => [ + provider, + definition.createClient(logger), + ]), + ) as Record; } export async function shutdownProviders( diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts new file mode 100644 index 000000000..55fbac92b --- /dev/null +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, test, vi } from "vitest"; + +import { + ACPAgentSession, + deriveModelDefinitionsFromACP, + deriveModesFromACP, + mapACPUsage, +} from "./acp-agent.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; + +function createSession(): ACPAgentSession { + return new ACPAgentSession( + { + provider: "claude-acp", + cwd: "/tmp/paseo-acp-test", + }, + { + provider: "claude-acp", + logger: createTestLogger(), + defaultCommand: ["claude", "--acp"], + defaultModes: [], + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + }, + ); +} + +describe("mapACPUsage", () => { + test("maps ACP usage fields into Paseo usage", () => { + expect( + mapACPUsage({ + inputTokens: 11, + outputTokens: 7, + totalTokens: 18, + cachedReadTokens: 5, + }), + ).toEqual({ + inputTokens: 11, + outputTokens: 7, + cachedInputTokens: 5, + }); + }); +}); + +describe("deriveModesFromACP", () => { + test("prefers explicit ACP mode state", () => { + const result = deriveModesFromACP( + [{ id: "fallback", label: "Fallback" }], + { + availableModes: [ + { id: "default", name: "Always Ask", description: "Prompt before tools" }, + { id: "plan", name: "Plan", description: "Read only" }, + ], + currentModeId: "plan", + }, + [], + ); + + expect(result).toEqual({ + modes: [ + { id: "default", label: "Always Ask", description: "Prompt before tools" }, + { id: "plan", label: "Plan", description: "Read only" }, + ], + currentModeId: "plan", + }); + }); + + test("falls back to config options when explicit mode state is absent", () => { + const result = deriveModesFromACP( + [{ id: "fallback", label: "Fallback" }], + null, + [ + { + id: "mode", + name: "Mode", + category: "mode", + type: "select", + currentValue: "acceptEdits", + options: [ + { value: "default", name: "Always Ask" }, + { value: "acceptEdits", name: "Accept File Edits" }, + ], + }, + ], + ); + + expect(result).toEqual({ + modes: [ + { id: "default", label: "Always Ask", description: undefined }, + { id: "acceptEdits", label: "Accept File Edits", description: undefined }, + ], + currentModeId: "acceptEdits", + }); + }); +}); + +describe("deriveModelDefinitionsFromACP", () => { + test("attaches shared thinking options to ACP model state", () => { + const result = deriveModelDefinitionsFromACP("claude-acp", { + availableModels: [ + { modelId: "haiku", name: "Haiku", description: "Fast" }, + { modelId: "sonnet", name: "Sonnet", description: "Balanced" }, + ], + currentModelId: "haiku", + }, [ + { + id: "reasoning", + name: "Reasoning", + category: "thought_level", + type: "select", + currentValue: "medium", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + ], + }, + ]); + + expect(result).toEqual([ + { + provider: "claude-acp", + id: "haiku", + label: "Haiku", + description: "Fast", + isDefault: true, + thinkingOptions: [ + { id: "low", label: "Low", description: undefined, isDefault: false, metadata: undefined }, + { id: "medium", label: "Medium", description: undefined, isDefault: true, metadata: undefined }, + { id: "high", label: "High", description: undefined, isDefault: false, metadata: undefined }, + ], + defaultThinkingOptionId: "medium", + }, + { + provider: "claude-acp", + id: "sonnet", + label: "Sonnet", + description: "Balanced", + isDefault: false, + thinkingOptions: [ + { id: "low", label: "Low", description: undefined, isDefault: false, metadata: undefined }, + { id: "medium", label: "Medium", description: undefined, isDefault: true, metadata: undefined }, + { id: "high", label: "High", description: undefined, isDefault: false, metadata: undefined }, + ], + defaultThinkingOptionId: "medium", + }, + ]); + }); +}); + +describe("ACPAgentSession", () => { + 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 } }> = []; + (session as any).sessionId = "session-1"; + + session.subscribe((event) => { + events.push(event as { type: string; item?: { type: string; text?: string } }); + }); + + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + messageId: "assistant-1", + content: { type: "text", text: "Hey!" }, + } as any, + }); + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + messageId: "assistant-1", + content: { type: "text", text: " How are you?" }, + } as any, + }); + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_thought_chunk", + messageId: "thought-1", + content: { type: "text", text: "Thinking" }, + } as any, + }); + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_thought_chunk", + messageId: "thought-1", + content: { type: "text", text: " more" }, + } as any, + }); + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "user_message_chunk", + messageId: "user-1", + content: { type: "text", text: "hel" }, + } as any, + }); + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "user_message_chunk", + messageId: "user-1", + content: { type: "text", text: "lo" }, + } as any, + }); + + const timeline = events + .filter((event) => event.type === "timeline") + .map((event) => event.item) + .filter(Boolean); + + expect(timeline).toEqual([ + { type: "assistant_message", text: "Hey!" }, + { type: "assistant_message", text: " How are you?" }, + { type: "reasoning", text: "Thinking" }, + { type: "reasoning", text: " more" }, + { type: "user_message", text: "hel", messageId: "user-1" }, + { type: "user_message", text: "hello", messageId: "user-1" }, + ]); + }); + + test("startTurn returns before the ACP prompt settles and completes later via subscribers", async () => { + const session = createSession(); + const events: Array<{ type: string; turnId?: string }> = []; + let resolvePrompt!: (value: any) => void; + const prompt = vi.fn( + () => + new Promise((resolve) => { + resolvePrompt = resolve; + }), + ); + + (session as any).sessionId = "session-1"; + (session as any).connection = { prompt }; + + session.subscribe((event) => { + events.push(event as { type: string; turnId?: string }); + }); + + const { turnId } = await session.startTurn("hello"); + + expect(prompt).toHaveBeenCalledOnce(); + expect(events.find((event) => event.type === "turn_started")).toMatchObject({ + type: "turn_started", + turnId, + }); + expect((session as any).activeForegroundTurnId).toBe(turnId); + + resolvePrompt({ stopReason: "end_turn", usage: { outputTokens: 3 } }); + await Promise.resolve(); + await Promise.resolve(); + + expect(events.find((event) => event.type === "turn_completed")).toMatchObject({ + type: "turn_completed", + turnId, + }); + expect((session as any).activeForegroundTurnId).toBeNull(); + }); + + test("startTurn converts background prompt rejections into turn_failed events", async () => { + const session = createSession(); + const events: Array<{ type: string; turnId?: string; error?: string }> = []; + let rejectPrompt!: (error: Error) => void; + const prompt = vi.fn( + () => + new Promise((_, reject) => { + rejectPrompt = reject; + }), + ); + + (session as any).sessionId = "session-1"; + (session as any).connection = { prompt }; + + session.subscribe((event) => { + events.push(event as { type: string; turnId?: string; error?: string }); + }); + + const { turnId } = await session.startTurn("hello"); + + rejectPrompt(new Error("prompt failed")); + await Promise.resolve(); + await Promise.resolve(); + + const turnFailedEvent = events.find((event) => event.type === "turn_failed"); + expect(turnFailedEvent).toMatchObject({ + type: "turn_failed", + turnId, + error: "prompt failed", + }); + expect((session as any).activeForegroundTurnId).toBeNull(); + }); +}); diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts new file mode 100644 index 000000000..fe40c125d --- /dev/null +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -0,0 +1,1936 @@ +import { + spawn, + type ChildProcess, + type ChildProcessWithoutNullStreams, +} from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { Readable, Writable } from "node:stream"; +import { + ClientSideConnection, + PROTOCOL_VERSION, + ndJsonStream, + type AgentCapabilities as ACPAgentCapabilities, + type Client as ACPClient, + type ClientCapabilities as ACPClientCapabilities, + type ConfigOptionUpdate, + type ContentBlock, + type CreateTerminalRequest, + type CurrentModeUpdate, + type EnvVariable, + type InitializeResponse, + type KillTerminalRequest, + type ListSessionsResponse, + type LoadSessionResponse, + type McpServer, + type NewSessionResponse, + type PermissionOption, + type Plan, + type PromptResponse, + type ReadTextFileRequest, + type RequestPermissionRequest, + type RequestPermissionResponse, + type ResumeSessionResponse, + type SessionConfigOption, + type SessionInfoUpdate, + type SessionMode, + type SessionModelState, + type SessionNotification, + type SessionUpdate, + type TerminalOutputRequest, + type TerminalOutputResponse, + type ToolCall, + type ToolCallContent, + type ToolCallLocation, + type ToolCallStatus, + type ToolCallUpdate, + type ToolKind, + type Usage, + type UsageUpdate, + type WaitForTerminalExitRequest, + type WriteTextFileRequest, +} from "@agentclientprotocol/sdk"; +import type { Logger } from "pino"; + +import type { + AgentCapabilityFlags, + AgentClient, + AgentLaunchContext, + AgentMetadata, + AgentMode, + AgentModelDefinition, + AgentPermissionRequest, + AgentPermissionRequestKind, + AgentPermissionResponse, + AgentPersistenceHandle, + AgentPromptContentBlock, + AgentPromptInput, + AgentRunOptions, + AgentRunResult, + AgentRuntimeInfo, + AgentSession, + AgentSessionConfig, + AgentStreamEvent, + AgentTimelineItem, + AgentUsage, + ListModelsOptions, + ListPersistedAgentsOptions, + McpServerConfig, + PersistedAgentDescriptor, + ToolCallDetail, + ToolCallTimelineItem, +} from "../agent-sdk-types.js"; +import { + applyProviderEnv, + findExecutable, + resolveProviderCommandPrefix, + type ProviderRuntimeSettings, +} from "../provider-launch-config.js"; + +const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, +}; + +const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = { + fs: { + readTextFile: true, + writeTextFile: true, + }, + terminal: true, +}; + +type ACPAgentClientOptions = { + provider: string; + logger: Logger; + runtimeSettings?: ProviderRuntimeSettings; + defaultCommand: [string, ...string[]]; + defaultModes?: AgentMode[]; + capabilities?: AgentCapabilityFlags; +}; + +type ACPAgentSessionOptions = { + provider: string; + logger: Logger; + runtimeSettings?: ProviderRuntimeSettings; + defaultCommand: [string, ...string[]]; + defaultModes: AgentMode[]; + capabilities: AgentCapabilityFlags; + handle?: AgentPersistenceHandle; + launchEnv?: Record; +}; + +type SpawnedACPProcess = { + child: ChildProcessWithoutNullStreams; + connection: ClientSideConnection; + initialize: InitializeResponse; +}; + +type ACPToolSnapshot = { + toolCallId: string; + title: string; + kind?: ToolKind | null; + status?: ToolCallStatus | null; + content?: ToolCallContent[] | null; + locations?: ToolCallLocation[] | null; + rawInput?: unknown; + rawOutput?: unknown; +}; + +type PendingPermission = { + request: AgentPermissionRequest; + options: PermissionOption[]; + resolve: (response: RequestPermissionResponse) => void; + reject: (error: Error) => void; + turnId: string | null; +}; + +type MessageAssemblyState = { + text: string; +}; + +type SessionStateResponse = NewSessionResponse | LoadSessionResponse | ResumeSessionResponse; + +type TerminalExit = { + exitCode?: number | null; + signal?: string | null; +}; + +type TerminalEntry = { + id: string; + child: ChildProcess; + output: string; + truncated: boolean; + outputByteLimit: number | null; + exit: TerminalExit | null; + waitForExit: Promise; + resolveExit: (exit: TerminalExit) => void; + rejectExit: (error: Error) => void; +}; + +type ConfigOptionSelector = { + id: string; + label: string; + description?: string; + isDefault?: boolean; + metadata?: AgentMetadata; +}; + +export function mapACPUsage(usage: Usage | null | undefined): AgentUsage | undefined { + if (!usage) { + return undefined; + } + + return { + inputTokens: usage.inputTokens ?? undefined, + outputTokens: usage.outputTokens ?? undefined, + cachedInputTokens: usage.cachedReadTokens ?? undefined, + }; +} + +export function deriveModesFromACP( + fallbackModes: AgentMode[], + modeState?: { availableModes?: SessionMode[] | null; currentModeId?: string | null } | null, + configOptions?: SessionConfigOption[] | null, +): { modes: AgentMode[]; currentModeId: string | null } { + if (modeState?.availableModes?.length) { + return { + modes: modeState.availableModes.map((mode) => ({ + id: mode.id, + label: mode.name, + description: mode.description ?? undefined, + })), + currentModeId: modeState.currentModeId ?? null, + }; + } + + const modeOption = configOptions?.find( + (option) => option.type === "select" && option.category === "mode", + ); + if (modeOption?.type === "select") { + const flatOptions = flattenSelectOptions(modeOption.options); + return { + modes: flatOptions.map((option) => ({ + id: option.value, + label: option.name, + description: option.description ?? undefined, + })), + currentModeId: modeOption.currentValue, + }; + } + + return { + modes: fallbackModes, + currentModeId: null, + }; +} + +export function deriveModelDefinitionsFromACP( + provider: string, + models: SessionModelState | null | undefined, + configOptions?: SessionConfigOption[] | null, +): AgentModelDefinition[] { + const thinkingOptions = deriveSelectorOptions(configOptions, "thought_level"); + const defaultThinkingOptionId = thinkingOptions.find((option) => option.isDefault)?.id ?? null; + + if (models?.availableModels?.length) { + return models.availableModels.map((model) => ({ + provider, + id: model.modelId, + label: model.name, + description: model.description ?? undefined, + isDefault: model.modelId === models.currentModelId, + thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined, + defaultThinkingOptionId: defaultThinkingOptionId ?? undefined, + })); + } + + const modelOptions = deriveSelectorOptions(configOptions, "model"); + return modelOptions.map((option) => ({ + provider, + id: option.id, + label: option.label, + description: option.description, + isDefault: option.isDefault, + thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined, + defaultThinkingOptionId: defaultThinkingOptionId ?? undefined, + metadata: option.metadata, + })); +} + +export class ACPAgentClient implements AgentClient { + readonly provider: string; + readonly capabilities: AgentCapabilityFlags; + + protected readonly logger: Logger; + protected readonly runtimeSettings?: ProviderRuntimeSettings; + protected readonly defaultCommand: [string, ...string[]]; + protected readonly defaultModes: AgentMode[]; + + constructor(options: ACPAgentClientOptions) { + this.provider = options.provider; + this.capabilities = options.capabilities ?? DEFAULT_ACP_CAPABILITIES; + this.logger = options.logger.child({ module: "agent", provider: options.provider }); + this.runtimeSettings = options.runtimeSettings; + this.defaultCommand = options.defaultCommand; + this.defaultModes = options.defaultModes ?? []; + } + + async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): Promise { + this.assertProvider(config); + const session = new ACPAgentSession( + { ...config, provider: this.provider }, + { + provider: this.provider, + logger: this.logger, + runtimeSettings: this.runtimeSettings, + defaultCommand: this.defaultCommand, + defaultModes: this.defaultModes, + capabilities: this.capabilities, + launchEnv: launchContext?.env, + }, + ); + await session.initializeNewSession(); + return session; + } + + async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + launchContext?: AgentLaunchContext, + ): Promise { + if (handle.provider !== this.provider) { + throw new Error(`Cannot resume ${handle.provider} handle with ${this.provider} provider`); + } + + const storedConfig = coerceSessionConfigMetadata(handle.metadata); + const cwd = overrides?.cwd ?? storedConfig.cwd; + if (!cwd) { + throw new Error(`${this.provider} resume requires the original working directory`); + } + + const mergedConfig: AgentSessionConfig = { + ...storedConfig, + ...overrides, + provider: this.provider, + cwd, + }; + const session = new ACPAgentSession(mergedConfig, { + provider: this.provider, + logger: this.logger, + runtimeSettings: this.runtimeSettings, + defaultCommand: this.defaultCommand, + defaultModes: this.defaultModes, + capabilities: this.capabilities, + handle, + launchEnv: launchContext?.env, + }); + await session.initializeResumedSession(); + return session; + } + + async listModels(options?: ListModelsOptions): Promise { + const cwd = options?.cwd ?? process.cwd(); + const probe = await this.spawnProcess(undefined); + try { + const response = await probe.connection.newSession({ + cwd, + mcpServers: [], + }); + return deriveModelDefinitionsFromACP(this.provider, response.models, response.configOptions); + } finally { + await this.closeProbe(probe); + } + } + + async listPersistedAgents( + options?: ListPersistedAgentsOptions, + ): Promise { + const probe = await this.spawnProcess(undefined); + try { + if (!probe.initialize.agentCapabilities?.sessionCapabilities?.list) { + return []; + } + + const sessions: PersistedAgentDescriptor[] = []; + let cursor: string | null | undefined; + do { + const page: ListSessionsResponse = await probe.connection.listSessions({ + ...(cursor ? { cursor } : {}), + }); + for (const session of page.sessions) { + sessions.push({ + provider: this.provider, + sessionId: session.sessionId, + cwd: session.cwd, + title: session.title ?? null, + lastActivityAt: session.updatedAt ? new Date(session.updatedAt) : new Date(0), + persistence: { + provider: this.provider, + sessionId: session.sessionId, + nativeHandle: session.sessionId, + metadata: { + provider: this.provider, + cwd: session.cwd, + title: session.title ?? null, + }, + }, + timeline: [], + }); + } + cursor = page.nextCursor ?? null; + } while (cursor && (!options?.limit || sessions.length < options.limit)); + + return typeof options?.limit === "number" ? sessions.slice(0, options.limit) : sessions; + } finally { + await this.closeProbe(probe); + } + } + + async isAvailable(): Promise { + try { + this.resolveLaunchCommand(); + return true; + } catch { + return false; + } + } + + protected async spawnProcess( + launchEnv?: Record, + ): Promise { + const { command, args } = this.resolveLaunchCommand(); + const child = spawn(command, args, { + cwd: process.cwd(), + env: { + ...applyProviderEnv(process.env as Record, this.runtimeSettings), + ...(launchEnv ?? {}), + }, + shell: process.platform === "win32", + stdio: ["pipe", "pipe", "pipe"], + }); + + const stderrChunks: string[] = []; + child.stderr.on("data", (chunk: Buffer | string) => { + stderrChunks.push(chunk.toString()); + }); + + const spawnErrorPromise = new Promise((_, reject) => { + child.once("error", (error) => { + const stderr = stderrChunks.join("").trim(); + reject(new Error(stderr ? `${String(error)}\n${stderr}` : String(error))); + }); + }); + + if (!child.stdin || !child.stdout) { + throw new Error(`${this.provider} ACP process did not expose stdio pipes`); + } + + const stream = ndJsonStream( + Writable.toWeb(child.stdin) as WritableStream, + Readable.toWeb(child.stdout) as ReadableStream, + ); + const connection = new ClientSideConnection(() => this.buildProbeClient(), stream); + const initialize = (await Promise.race([ + connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: ACP_CLIENT_CAPABILITIES, + clientInfo: { name: "Paseo", version: "dev" }, + }), + spawnErrorPromise, + ])) as InitializeResponse; + + return { child, connection, initialize }; + } + + protected buildProbeClient(): ACPClient { + return { + async requestPermission(): Promise { + return { outcome: { outcome: "cancelled" } }; + }, + async sessionUpdate(): Promise {}, + async readTextFile(params: ReadTextFileRequest) { + const content = await fs.readFile(params.path, "utf8"); + return { content }; + }, + async writeTextFile(params: WriteTextFileRequest) { + await fs.mkdir(path.dirname(params.path), { recursive: true }); + await fs.writeFile(params.path, params.content, "utf8"); + return {}; + }, + async createTerminal() { + throw new Error("ACP model probe does not support terminal execution"); + }, + }; + } + + protected async closeProbe(probe: SpawnedACPProcess): Promise { + try { + if (probe.initialize.agentCapabilities?.sessionCapabilities?.close) { + // No active session to close here; ignore capability. + } + } finally { + probe.child.kill("SIGTERM"); + await waitForChildExit(probe.child, 2_000); + } + } + + protected resolveLaunchCommand(): { command: string; args: string[] } { + const prefix = resolveProviderCommandPrefix(this.runtimeSettings?.command, () => { + const resolved = findExecutable(this.defaultCommand[0]); + if (!resolved) { + throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`); + } + return resolved; + }); + return { + command: prefix.command, + args: [...prefix.args, ...this.defaultCommand.slice(1)], + }; + } + + private assertProvider(config: AgentSessionConfig): void { + if (config.provider !== this.provider) { + throw new Error(`Expected ${this.provider} config, received ${config.provider}`); + } + } +} + +export class ACPAgentSession implements AgentSession, ACPClient { + readonly provider: string; + readonly capabilities: AgentCapabilityFlags; + + private readonly logger: Logger; + private readonly runtimeSettings?: ProviderRuntimeSettings; + private readonly defaultCommand: [string, ...string[]]; + private readonly defaultModes: AgentMode[]; + private readonly launchEnv?: Record; + private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); + private readonly pendingPermissions = new Map(); + private readonly messageAssemblies = new Map(); + private readonly toolCalls = new Map(); + private readonly terminalEntries = new Map(); + private readonly persistedHistory: AgentTimelineItem[] = []; + private readonly initialHandle?: AgentPersistenceHandle; + + private readonly config: AgentSessionConfig; + private child: ChildProcessWithoutNullStreams | null = null; + private connection: ClientSideConnection | null = null; + private agentCapabilities: ACPAgentCapabilities | null = null; + private sessionId: string | null = null; + private currentMode: string | null = null; + private availableModes: AgentMode[]; + private currentModel: string | null = null; + private thinkingOptionId: string | null = null; + private currentTitle: string | null = null; + private lastActivityAt: string | null = null; + private configOptions: SessionConfigOption[] = []; + private currentTurnUsage: AgentUsage | undefined; + private activeForegroundTurnId: string | null = null; + private closed = false; + private historyPending = false; + private replayingHistory = false; + private suppressUserEchoMessageId: string | null = null; + private suppressUserEchoText: string | null = null; + private bootstrapThreadEventPending = false; + + constructor(config: AgentSessionConfig, options: ACPAgentSessionOptions) { + this.provider = options.provider; + this.capabilities = options.capabilities; + this.logger = options.logger.child({ module: "agent", provider: options.provider }); + this.runtimeSettings = options.runtimeSettings; + this.defaultCommand = options.defaultCommand; + this.defaultModes = options.defaultModes; + this.availableModes = options.defaultModes; + this.launchEnv = options.launchEnv; + this.initialHandle = options.handle; + this.config = { ...config, provider: options.provider }; + this.currentMode = config.modeId ?? null; + this.currentModel = config.model ?? null; + this.thinkingOptionId = config.thinkingOptionId ?? null; + this.currentTitle = config.title ?? null; + } + + get id(): string | null { + return this.sessionId; + } + + async initializeNewSession(): Promise { + const spawned = await this.spawnProcess(); + this.child = spawned.child; + this.connection = spawned.connection; + this.agentCapabilities = spawned.initialize.agentCapabilities ?? null; + + const response = await this.connection.newSession({ + cwd: this.config.cwd, + mcpServers: normalizeMcpServers(this.config.mcpServers), + }); + this.sessionId = response.sessionId; + this.bootstrapThreadEventPending = true; + this.applySessionState(response); + await this.applyConfiguredOverrides(); + } + + async initializeResumedSession(): Promise { + const handle = this.initialHandle; + if (!handle) { + throw new Error("Resume requested without persistence handle"); + } + + const spawned = await this.spawnProcess(); + this.child = spawned.child; + this.connection = spawned.connection; + this.agentCapabilities = spawned.initialize.agentCapabilities ?? null; + this.sessionId = handle.sessionId; + this.bootstrapThreadEventPending = true; + + const sessionCapabilities = this.agentCapabilities?.sessionCapabilities; + if (this.agentCapabilities?.loadSession) { + this.replayingHistory = true; + const response = await this.connection.loadSession({ + sessionId: handle.sessionId, + cwd: this.config.cwd, + mcpServers: normalizeMcpServers(this.config.mcpServers), + }); + this.replayingHistory = false; + this.historyPending = this.persistedHistory.length > 0; + this.applySessionState(response); + } else if (sessionCapabilities?.resume) { + const response = await this.connection.unstable_resumeSession({ + sessionId: handle.sessionId, + cwd: this.config.cwd, + mcpServers: normalizeMcpServers(this.config.mcpServers), + }); + this.applySessionState(response); + } else { + throw new Error(`${this.provider} does not support ACP session resume`); + } + + await this.applyConfiguredOverrides(); + } + + async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { + const timeline: AgentTimelineItem[] = []; + let finalText = ""; + let usage: AgentUsage | undefined; + let turnId: string | null = null; + let settled = false; + let resolveCompletion!: () => void; + let rejectCompletion!: (error: Error) => void; + const buffered: AgentStreamEvent[] = []; + + const completion = new Promise((resolve, reject) => { + resolveCompletion = resolve; + rejectCompletion = reject; + }); + + const processEvent = (event: AgentStreamEvent) => { + if (settled) { + return; + } + if (turnId && "turnId" in event && event.turnId && event.turnId !== turnId) { + return; + } + if (event.type === "timeline") { + timeline.push(event.item); + if (event.item.type === "assistant_message") { + finalText = event.item.text.startsWith(finalText) + ? event.item.text + : `${finalText}${event.item.text}`; + } + return; + } + if (event.type === "turn_completed") { + usage = event.usage; + settled = true; + resolveCompletion(); + return; + } + if (event.type === "turn_failed") { + settled = true; + rejectCompletion(new Error(event.error)); + return; + } + if (event.type === "turn_canceled") { + settled = true; + resolveCompletion(); + } + }; + + const unsubscribe = this.subscribe((event) => { + if (!turnId) { + buffered.push(event); + return; + } + processEvent(event); + }); + + try { + const started = await this.startTurn(prompt, options); + turnId = started.turnId; + for (const event of buffered) { + processEvent(event); + } + if (!settled) { + await completion; + } + } finally { + unsubscribe(); + } + + if (!this.sessionId) { + throw new Error("ACP session did not expose a session id"); + } + + return { + sessionId: this.sessionId, + finalText, + usage, + timeline, + }; + } + + async startTurn(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<{ turnId: string }> { + if (this.closed) { + throw new Error(`${this.provider} session is closed`); + } + if (!this.connection || !this.sessionId) { + throw new Error(`${this.provider} session is not initialized`); + } + if (this.activeForegroundTurnId) { + throw new Error("A foreground turn is already active"); + } + + const turnId = randomUUID(); + const messageId = randomUUID(); + this.activeForegroundTurnId = turnId; + this.suppressUserEchoMessageId = messageId; + this.suppressUserEchoText = extractPromptText(prompt); + this.emitBootstrapThreadEvent(); + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + + void this.connection + .prompt({ + sessionId: this.sessionId, + messageId, + prompt: toACPContentBlocks(prompt), + }) + .then((response) => { + this.handlePromptResponse(response, turnId); + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + this.finishTurn({ + type: "turn_failed", + provider: this.provider, + error: message, + diagnostic: this.collectDiagnostic(message), + turnId, + }); + }); + + return { turnId }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.subscribers.add(callback); + if (this.sessionId) { + callback({ + type: "thread_started", + provider: this.provider, + sessionId: this.sessionId, + }); + } + return () => { + this.subscribers.delete(callback); + }; + } + + async *streamHistory(): AsyncGenerator { + if (!this.historyPending || this.persistedHistory.length === 0) { + return; + } + const history = [...this.persistedHistory]; + this.persistedHistory.length = 0; + this.historyPending = false; + for (const item of history) { + yield { type: "timeline", provider: this.provider, item }; + } + } + + async getRuntimeInfo(): Promise { + return { + provider: this.provider, + sessionId: this.sessionId, + model: this.currentModel, + thinkingOptionId: this.thinkingOptionId, + modeId: this.currentMode, + extra: { + title: this.currentTitle, + updatedAt: this.lastActivityAt, + }, + }; + } + + async getAvailableModes(): Promise { + return [...this.availableModes]; + } + + async getCurrentMode(): Promise { + return this.currentMode; + } + + async setMode(modeId: string): Promise { + if (!this.connection || !this.sessionId) { + throw new Error("ACP session not initialized"); + } + + const modeExists = this.availableModes.some((mode) => mode.id === modeId); + if (!modeExists && this.availableModes.length > 0) { + throw new Error(`Unknown ${this.provider} mode '${modeId}'`); + } + + if (this.availableModes.length > 0) { + await this.connection.setSessionMode({ sessionId: this.sessionId, modeId }); + this.currentMode = modeId; + return; + } + + const modeOption = this.getSelectConfigOption("mode"); + if (!modeOption) { + throw new Error(`${this.provider} does not expose ACP mode switching`); + } + await this.connection.setSessionConfigOption({ + sessionId: this.sessionId, + configId: modeOption.id, + value: modeId, + }); + this.currentMode = modeId; + } + + async setModel(modelId: string | null): Promise { + if (!this.connection || !this.sessionId) { + throw new Error("ACP session not initialized"); + } + if (!modelId) { + this.currentModel = null; + return; + } + + if (this.agentCapabilities?.sessionCapabilities && "unstable_setSessionModel" in this.connection) { + try { + await this.connection.unstable_setSessionModel({ + sessionId: this.sessionId, + modelId, + }); + this.currentModel = modelId; + return; + } catch { + // Fall through to config option path. + } + } + + const modelOption = this.getSelectConfigOption("model"); + if (!modelOption) { + throw new Error(`${this.provider} does not expose ACP model selection`); + } + await this.connection.setSessionConfigOption({ + sessionId: this.sessionId, + configId: modelOption.id, + value: modelId, + }); + this.currentModel = modelId; + } + + async setThinkingOption(thinkingOptionId: string | null): Promise { + if (!this.connection || !this.sessionId) { + throw new Error("ACP session not initialized"); + } + if (!thinkingOptionId) { + this.thinkingOptionId = null; + return; + } + + const option = this.getSelectConfigOption("thought_level"); + if (!option) { + throw new Error(`${this.provider} does not expose ACP thought-level selection`); + } + await this.connection.setSessionConfigOption({ + sessionId: this.sessionId, + configId: option.id, + value: thinkingOptionId, + }); + this.thinkingOptionId = thinkingOptionId; + } + + getPendingPermissions(): AgentPermissionRequest[] { + return Array.from(this.pendingPermissions.values(), (entry) => entry.request); + } + + async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise { + const pending = this.pendingPermissions.get(requestId); + if (!pending) { + throw new Error(`No pending permission request with id '${requestId}'`); + } + + this.pendingPermissions.delete(requestId); + const selectedOption = selectPermissionOption(pending.options, response); + pending.resolve( + selectedOption + ? { + outcome: { + outcome: "selected", + optionId: selectedOption.optionId, + }, + } + : { outcome: { outcome: "cancelled" } }, + ); + + this.pushEvent({ + type: "permission_resolved", + provider: this.provider, + requestId, + resolution: response, + turnId: pending.turnId ?? undefined, + }); + + if (response.behavior === "deny" && response.interrupt && this.connection && this.sessionId) { + await this.connection.cancel({ sessionId: this.sessionId }); + } + } + + describePersistence(): AgentPersistenceHandle | null { + if (!this.sessionId) { + return null; + } + return { + provider: this.provider, + sessionId: this.sessionId, + nativeHandle: this.sessionId, + metadata: { + ...this.config, + title: this.currentTitle, + }, + }; + } + + async interrupt(): Promise { + if (!this.connection || !this.sessionId) { + return; + } + + for (const pending of this.pendingPermissions.values()) { + pending.resolve({ outcome: { outcome: "cancelled" } }); + } + this.pendingPermissions.clear(); + + if (this.activeForegroundTurnId) { + await this.connection.cancel({ sessionId: this.sessionId }); + } + } + + async close(): Promise { + if (this.closed) { + return; + } + this.closed = true; + + for (const pending of this.pendingPermissions.values()) { + pending.resolve({ outcome: { outcome: "cancelled" } }); + } + this.pendingPermissions.clear(); + + if (this.connection && this.sessionId) { + try { + if (this.activeForegroundTurnId) { + await this.connection.cancel({ sessionId: this.sessionId }); + } + } catch {} + + try { + if (this.agentCapabilities?.sessionCapabilities?.close) { + await this.connection.unstable_closeSession({ sessionId: this.sessionId }); + } + } catch (error) { + this.logger.debug({ err: error }, "ACP closeSession failed during shutdown"); + } + } + + for (const terminal of this.terminalEntries.values()) { + terminal.child.kill("SIGTERM"); + } + this.terminalEntries.clear(); + + if (this.child) { + this.child.kill("SIGTERM"); + await waitForChildExit(this.child, 2_000); + } + + this.subscribers.clear(); + this.connection = null; + this.child = null; + this.activeForegroundTurnId = null; + } + + async requestPermission( + params: RequestPermissionRequest, + ): Promise { + const requestId = randomUUID(); + const request = mapPermissionRequest( + this.provider, + requestId, + params, + this.toolCalls.get(params.toolCall.toolCallId) ?? mergeToolSnapshot(params.toolCall.toolCallId, params.toolCall), + ); + + const promise = new Promise((resolve, reject) => { + this.pendingPermissions.set(requestId, { + request, + options: params.options, + resolve, + reject, + turnId: this.activeForegroundTurnId, + }); + }); + + this.pushEvent({ + type: "permission_requested", + provider: this.provider, + request, + turnId: this.activeForegroundTurnId ?? undefined, + }); + return promise; + } + + async sessionUpdate(params: SessionNotification): Promise { + if (params.sessionId !== this.sessionId) { + return; + } + + const events = this.translateSessionUpdate(params.update); + if (this.replayingHistory) { + for (const event of events) { + if (event.type === "timeline") { + this.persistedHistory.push(event.item); + } + } + return; + } + + for (const event of events) { + this.pushEvent(event); + } + } + + async readTextFile(params: ReadTextFileRequest): Promise<{ content: string }> { + const raw = await fs.readFile(params.path, "utf8"); + if (!params.line && !params.limit) { + return { content: raw }; + } + const lines = raw.split(/\r?\n/); + const start = Math.max((params.line ?? 1) - 1, 0); + const end = params.limit ? start + params.limit : undefined; + return { content: lines.slice(start, end).join("\n") }; + } + + async writeTextFile(params: WriteTextFileRequest): Promise> { + await fs.mkdir(path.dirname(params.path), { recursive: true }); + await fs.writeFile(params.path, params.content, "utf8"); + return {}; + } + + async createTerminal(params: CreateTerminalRequest): Promise<{ terminalId: string }> { + const terminalId = randomUUID(); + const env = Object.fromEntries((params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value])); + const child = spawn(params.command, params.args ?? [], { + cwd: params.cwd ?? this.config.cwd, + env: { + ...applyProviderEnv(process.env as Record, this.runtimeSettings), + ...env, + }, + shell: process.platform === "win32", + stdio: ["ignore", "pipe", "pipe"], + }); + + let resolveExit!: (exit: TerminalExit) => void; + let rejectExit!: (error: Error) => void; + const waitForExit = new Promise((resolve, reject) => { + resolveExit = resolve; + rejectExit = reject; + }); + + const entry: TerminalEntry = { + id: terminalId, + child, + output: "", + truncated: false, + outputByteLimit: params.outputByteLimit ?? null, + exit: null, + waitForExit, + resolveExit, + rejectExit, + }; + + child.stdout.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString())); + child.stderr.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString())); + child.once("error", (error) => rejectExit(error instanceof Error ? error : new Error(String(error)))); + child.once("exit", (code, signal) => { + const exit = { exitCode: code, signal }; + entry.exit = exit; + resolveExit(exit); + }); + + this.terminalEntries.set(terminalId, entry); + return { terminalId }; + } + + async terminalOutput(params: TerminalOutputRequest): Promise { + const entry = this.getTerminalEntry(params.terminalId); + return { + output: entry.output, + truncated: entry.truncated, + exitStatus: entry.exit ?? undefined, + }; + } + + async waitForTerminalExit(params: WaitForTerminalExitRequest): Promise { + const entry = this.getTerminalEntry(params.terminalId); + return entry.waitForExit; + } + + async releaseTerminal(params: { sessionId: string; terminalId: string }): Promise { + const entry = this.getTerminalEntry(params.terminalId); + if (!entry.exit) { + entry.child.kill("SIGTERM"); + } + this.terminalEntries.delete(params.terminalId); + } + + async killTerminal(params: KillTerminalRequest): Promise> { + const entry = this.getTerminalEntry(params.terminalId); + if (!entry.exit) { + entry.child.kill("SIGTERM"); + } + return {}; + } + + private async spawnProcess(): Promise { + const prefix = resolveProviderCommandPrefix(this.runtimeSettings?.command, () => { + const resolved = findExecutable(this.defaultCommand[0]); + if (!resolved) { + throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`); + } + return resolved; + }); + + const command = prefix.command; + const args = [...prefix.args, ...this.defaultCommand.slice(1)]; + const child = spawn(command, args, { + cwd: this.config.cwd, + env: { + ...applyProviderEnv(process.env as Record, this.runtimeSettings), + ...(this.launchEnv ?? {}), + }, + shell: process.platform === "win32", + stdio: ["pipe", "pipe", "pipe"], + }); + + const stderrChunks: string[] = []; + child.stderr.on("data", (chunk: Buffer | string) => { + stderrChunks.push(chunk.toString()); + }); + child.once("exit", (code, signal) => { + if (this.closed) { + return; + } + if (this.activeForegroundTurnId) { + this.synthesizeCanceledToolCalls(); + this.finishTurn({ + type: "turn_failed", + provider: this.provider, + error: `ACP agent exited unexpectedly (${code ?? "null"}${signal ? `, ${signal}` : ""})`, + diagnostic: stderrChunks.join("").trim() || undefined, + turnId: this.activeForegroundTurnId, + }); + } + }); + + if (!child.stdin || !child.stdout) { + throw new Error(`${this.provider} ACP process did not expose stdio pipes`); + } + + const stream = ndJsonStream( + Writable.toWeb(child.stdin) as WritableStream, + Readable.toWeb(child.stdout) as ReadableStream, + ); + const connection = new ClientSideConnection(() => this, stream); + const initialize = await connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: ACP_CLIENT_CAPABILITIES, + clientInfo: { name: "Paseo", version: "dev" }, + }); + + return { child, connection, initialize }; + } + + private applySessionState(response: SessionStateResponse): void { + this.configOptions = response.configOptions ?? []; + + const modeInfo = deriveModesFromACP(this.defaultModes, response.modes, this.configOptions); + this.availableModes = modeInfo.modes.length > 0 ? modeInfo.modes : this.defaultModes; + this.currentMode = modeInfo.currentModeId ?? this.currentMode; + + this.currentModel = response.models?.currentModelId ?? deriveCurrentConfigValue(this.configOptions, "model"); + this.thinkingOptionId = + deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId; + } + + private async applyConfiguredOverrides(): Promise { + if (this.config.modeId && this.config.modeId !== this.currentMode) { + await this.setMode(this.config.modeId); + } + if (this.config.model && this.config.model !== this.currentModel) { + await this.setModel(this.config.model); + } + if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) { + await this.setThinkingOption(this.config.thinkingOptionId); + } + } + + private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] { + switch (update.sessionUpdate) { + case "user_message_chunk": { + const item = this.createMessageTimelineItem("user_message", update); + if (!item) { + return []; + } + const shouldSuppress = + this.suppressUserEchoMessageId && + update.messageId === this.suppressUserEchoMessageId && + this.suppressUserEchoText && + item.text === this.suppressUserEchoText; + if (shouldSuppress) { + return []; + } + return [this.wrapTimeline(item)]; + } + case "agent_message_chunk": { + const item = this.createMessageTimelineItem("assistant_message", update); + return item ? [this.wrapTimeline(item)] : []; + } + case "agent_thought_chunk": { + const item = this.createMessageTimelineItem("reasoning", update); + return item ? [this.wrapTimeline(item)] : []; + } + case "tool_call": { + const snapshot = mergeToolSnapshot(update.toolCallId, update); + 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); + this.toolCalls.set(update.toolCallId, snapshot); + return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))]; + } + case "plan": + return [this.wrapTimeline(mapPlanToTimeline(update))]; + case "current_mode_update": + this.handleCurrentModeUpdate(update); + return []; + case "config_option_update": + this.handleConfigOptionUpdate(update); + return []; + case "session_info_update": + this.handleSessionInfoUpdate(update); + return []; + case "usage_update": + this.handleUsageUpdate(update); + return []; + case "available_commands_update": + return []; + default: + return []; + } + } + + private createMessageTimelineItem( + type: "user_message" | "assistant_message" | "reasoning", + update: Extract< + SessionUpdate, + { sessionUpdate: "user_message_chunk" | "agent_message_chunk" | "agent_thought_chunk" } + >, + ): + | { type: "user_message"; text: string; messageId?: string } + | { type: "assistant_message"; text: string } + | { type: "reasoning"; text: string } + | null { + const chunkText = contentBlockToText(update.content); + if (!chunkText) { + return null; + } + const key = `${type}:${update.messageId ?? "default"}`; + const state = this.messageAssemblies.get(key) ?? { text: "" }; + state.text += chunkText; + this.messageAssemblies.set(key, state); + + if (type === "user_message") { + return { type: "user_message", text: state.text, messageId: update.messageId ?? undefined }; + } + if (type === "assistant_message") { + return { type: "assistant_message", text: chunkText }; + } + return { type: "reasoning", text: chunkText }; + } + + private handleCurrentModeUpdate(update: CurrentModeUpdate): void { + this.currentMode = update.currentModeId; + } + + 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.currentMode = modeInfo.currentModeId ?? this.currentMode; + this.currentModel = deriveCurrentConfigValue(this.configOptions, "model") ?? this.currentModel; + this.thinkingOptionId = + deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId; + } + + private handleSessionInfoUpdate(update: SessionInfoUpdate): void { + if ("title" in update) { + this.currentTitle = update.title ?? null; + } + if ("updatedAt" in update) { + this.lastActivityAt = update.updatedAt ?? null; + } + } + + private handleUsageUpdate(update: UsageUpdate): void { + void update; + } + + private handlePromptResponse(response: PromptResponse, turnId: string): void { + this.currentTurnUsage = mapACPUsage(response.usage) ?? this.currentTurnUsage; + + switch (response.stopReason) { + case "cancelled": + this.synthesizeCanceledToolCalls(); + this.finishTurn({ + type: "turn_canceled", + provider: this.provider, + reason: "Interrupted", + turnId, + }); + break; + case "end_turn": + case "max_tokens": + case "max_turn_requests": + case "refusal": + default: + this.finishTurn({ + type: "turn_completed", + provider: this.provider, + usage: this.currentTurnUsage, + turnId, + }); + break; + } + } + + private wrapTimeline(item: AgentTimelineItem): AgentStreamEvent { + return { + type: "timeline", + provider: this.provider, + item, + turnId: this.activeForegroundTurnId ?? undefined, + }; + } + + private pushEvent(event: AgentStreamEvent): void { + for (const subscriber of this.subscribers) { + subscriber(event); + } + } + + private finishTurn(event: Extract): void { + this.activeForegroundTurnId = null; + this.suppressUserEchoMessageId = null; + this.suppressUserEchoText = null; + this.pushEvent(event); + } + + private emitBootstrapThreadEvent(): void { + if (!this.bootstrapThreadEventPending || !this.sessionId) { + return; + } + this.bootstrapThreadEventPending = false; + this.pushEvent({ + type: "thread_started", + provider: this.provider, + sessionId: this.sessionId, + }); + } + + private synthesizeCanceledToolCalls(): void { + for (const snapshot of this.toolCalls.values()) { + const mapped = mapToolSnapshotToTimeline(snapshot, this.terminalEntries); + if (mapped.status === "running") { + this.pushEvent( + this.wrapTimeline({ + ...mapped, + status: "canceled", + error: null, + }), + ); + } + } + } + + private collectDiagnostic(message: string): string | undefined { + const parts: string[] = [message]; + if (this.child?.exitCode != null) { + parts.push(`exitCode=${this.child.exitCode}`); + } + if (this.child?.signalCode) { + parts.push(`signal=${this.child.signalCode}`); + } + return parts.length > 0 ? parts.join(" | ") : undefined; + } + + private getSelectConfigOption(category: string): Extract | null { + const option = this.configOptions.find( + (entry): entry is Extract => + entry.type === "select" && entry.category === category, + ); + return option ?? null; + } + + private getTerminalEntry(terminalId: string): TerminalEntry { + const entry = this.terminalEntries.get(terminalId); + if (!entry) { + throw new Error(`Unknown terminal '${terminalId}'`); + } + return entry; + } +} + +function flattenSelectOptions( + options: Extract["options"], +): Array<{ value: string; name: string; description?: string | null; group?: string }> { + const flattened: Array<{ value: string; name: string; description?: string | null; group?: string }> = []; + for (const option of options) { + if ("value" in option) { + flattened.push(option); + continue; + } + for (const groupOption of option.options) { + flattened.push({ ...groupOption, group: option.group }); + } + } + return flattened; +} + +function deriveSelectorOptions( + configOptions: SessionConfigOption[] | null | undefined, + category: string, +): ConfigOptionSelector[] { + const option = configOptions?.find( + (entry): entry is Extract => + entry.type === "select" && entry.category === category, + ); + if (!option) { + return []; + } + + return flattenSelectOptions(option.options).map((value) => ({ + id: value.value, + label: value.name, + description: value.description ?? undefined, + isDefault: value.value === option.currentValue, + metadata: value.group ? { group: value.group } : undefined, + })); +} + +function deriveCurrentConfigValue( + configOptions: SessionConfigOption[] | null | undefined, + category: string, +): string | null { + const option = configOptions?.find( + (entry): entry is Extract => + entry.type === "select" && entry.category === category, + ); + return option?.currentValue ?? null; +} + +function normalizeMcpServers(servers?: Record): McpServer[] { + if (!servers) { + return []; + } + + return Object.entries(servers).map(([name, config]) => { + if (config.type === "stdio") { + return { + name, + command: config.command, + args: config.args ?? [], + env: Object.entries(config.env ?? {}).map(([envName, value]) => ({ + name: envName, + value, + })), + } satisfies McpServer; + } + + if (config.type === "http") { + return { + type: "http", + name, + url: config.url, + headers: Object.entries(config.headers ?? {}).map(([headerName, value]) => ({ + name: headerName, + value, + })), + } satisfies McpServer; + } + + return { + type: "sse", + name, + url: config.url, + headers: Object.entries(config.headers ?? {}).map(([headerName, value]) => ({ + name: headerName, + value, + })), + } satisfies McpServer; + }); +} + +function toACPContentBlocks(prompt: AgentPromptInput): ContentBlock[] { + if (typeof prompt === "string") { + return [{ type: "text", text: prompt }]; + } + + return prompt.map((block: AgentPromptContentBlock) => { + if (block.type === "text") { + return { type: "text", text: block.text }; + } + return { + type: "image", + data: block.data, + mimeType: block.mimeType, + }; + }); +} + +function extractPromptText(prompt: AgentPromptInput): string { + if (typeof prompt === "string") { + return prompt; + } + return prompt + .filter((block): block is Extract => block.type === "text") + .map((block) => block.text) + .join(""); +} + +function contentBlockToText(content: ContentBlock): string { + switch (content.type) { + case "text": + return content.text; + case "resource_link": + return content.title ?? content.uri; + case "resource": + return "text" in content.resource ? content.resource.text : `[resource:${content.resource.mimeType ?? "binary"}]`; + case "image": + return "[image]"; + case "audio": + return "[audio]"; + default: + return ""; + } +} + +function mergeToolSnapshot( + toolCallId: string, + update: ToolCall | ToolCallUpdate, + previous?: ACPToolSnapshot, +): ACPToolSnapshot { + const isFull = "title" in update && typeof update.title === "string"; + return { + toolCallId, + title: (update.title ?? previous?.title ?? toolCallId) as string, + kind: update.kind ?? previous?.kind ?? null, + status: update.status ?? previous?.status ?? null, + content: update.content !== undefined ? update.content : previous?.content ?? null, + locations: update.locations !== undefined ? update.locations : previous?.locations ?? null, + rawInput: update.rawInput !== undefined ? update.rawInput : previous?.rawInput, + rawOutput: update.rawOutput !== undefined ? update.rawOutput : previous?.rawOutput, + ...(isFull ? {} : {}), + }; +} + +function mapPlanToTimeline(plan: Plan): AgentTimelineItem { + return { + type: "todo", + items: plan.entries.map((entry) => ({ + text: entry.content, + completed: entry.status === "completed", + })), + }; +} + +function mapToolSnapshotToTimeline( + snapshot: ACPToolSnapshot, + terminals: Map, +): ToolCallTimelineItem { + const status = mapToolStatus(snapshot.status); + const detail = mapToolDetail(snapshot, terminals); + const base = { + type: "tool_call" as const, + callId: snapshot.toolCallId, + name: snapshot.kind ?? snapshot.title, + detail, + metadata: { + kind: snapshot.kind ?? undefined, + title: snapshot.title, + }, + }; + if (status === "failed") { + return { + ...base, + status: "failed", + error: { message: readErrorMessage(snapshot.rawOutput) }, + }; + } + if (status === "completed") { + return { + ...base, + status: "completed", + error: null, + }; + } + return { + ...base, + status: "running", + error: null, + }; +} + +function mapToolStatus(status: ToolCallStatus | null | undefined): ToolCallTimelineItem["status"] { + switch (status) { + case "completed": + return "completed"; + case "failed": + return "failed"; + case "pending": + case "in_progress": + default: + return "running"; + } +} + +function mapToolDetail(snapshot: ACPToolSnapshot, terminals: Map): ToolCallDetail { + const firstLocation = snapshot.locations?.[0]?.path; + const textContent = extractToolText(snapshot.content); + const diffContent = extractDiffContent(snapshot.content); + const terminalContent = extractTerminalContent(snapshot.content, terminals); + const rawInput = readRecord(snapshot.rawInput); + const rawOutput = readRecord(snapshot.rawOutput); + + switch (snapshot.kind) { + case "read": + return { + type: "read", + filePath: + firstLocation ?? readString(rawInput, ["path", "filePath", "file"]) ?? snapshot.title, + content: textContent ?? readString(rawOutput, ["content", "text"]), + offset: readNumber(rawInput, ["offset", "line"]), + limit: readNumber(rawInput, ["limit"]), + }; + case "edit": + case "delete": + return { + type: "edit", + filePath: + firstLocation ?? readString(rawInput, ["path", "filePath", "file"]) ?? snapshot.title, + oldString: diffContent?.oldText ?? readString(rawInput, ["oldText", "oldString"]), + newString: + snapshot.kind === "delete" + ? "" + : diffContent?.newText ?? readString(rawInput, ["newText", "newString"]), + unifiedDiff: textContent ?? undefined, + }; + case "search": + return { + type: "search", + query: readString(rawInput, ["query", "pattern"]) ?? snapshot.title, + toolName: "search", + content: textContent ?? readString(rawOutput, ["content", "text"]), + filePaths: snapshot.locations?.map((location) => location.path), + }; + case "execute": + return { + type: "shell", + command: + terminalContent?.command ?? + buildShellCommand(rawInput) ?? + readString(rawInput, ["command"]) ?? + snapshot.title, + cwd: terminalContent?.cwd ?? readString(rawInput, ["cwd"]), + output: terminalContent?.output ?? textContent ?? readString(rawOutput, ["output", "text"]), + exitCode: terminalContent?.exitCode ?? readNumber(rawOutput, ["exitCode"]), + }; + case "fetch": + return { + type: "fetch", + url: readString(rawInput, ["url"]) ?? snapshot.title, + prompt: readString(rawInput, ["prompt"]), + result: textContent ?? readString(rawOutput, ["result", "text", "content"]), + code: readNumber(rawOutput, ["status", "code"]), + }; + case "think": + return { + type: "plain_text", + label: snapshot.title, + icon: "brain", + text: textContent ?? stringifyUnknown(snapshot.rawOutput), + }; + case "switch_mode": + return { + type: "plain_text", + label: snapshot.title, + icon: "sparkles", + text: textContent ?? stringifyUnknown(snapshot.rawInput), + }; + default: + if (terminalContent) { + return { + type: "shell", + command: terminalContent.command ?? snapshot.title, + cwd: terminalContent.cwd, + output: terminalContent.output, + exitCode: terminalContent.exitCode, + }; + } + if (textContent) { + return { + type: "plain_text", + label: snapshot.title, + text: textContent, + icon: "wrench", + }; + } + return { + type: "unknown", + input: snapshot.rawInput ?? null, + output: snapshot.rawOutput ?? null, + }; + } +} + +function extractToolText(content: ToolCallContent[] | null | undefined): string | undefined { + if (!content) { + return undefined; + } + const parts: string[] = []; + for (const item of content) { + if (item.type === "content") { + const text = contentBlockToText(item.content); + if (text) { + parts.push(text); + } + } + } + return parts.length > 0 ? parts.join("\n") : undefined; +} + +function extractDiffContent( + content: ToolCallContent[] | null | undefined, +): { oldText?: string | null; newText: string } | null { + const diff = content?.find((item): item is Extract => item.type === "diff"); + return diff ? { oldText: diff.oldText ?? undefined, newText: diff.newText } : null; +} + +function extractTerminalContent( + content: ToolCallContent[] | null | undefined, + terminals: Map, +): + | { + command?: string; + cwd?: string; + output?: string; + exitCode?: number | null; + } + | undefined { + const terminal = content?.find( + (item): item is Extract => item.type === "terminal", + ); + if (!terminal) { + return undefined; + } + const entry = terminals.get(terminal.terminalId); + if (!entry) { + return undefined; + } + return { + output: entry.output, + exitCode: entry.exit?.exitCode ?? null, + }; +} + +function mapPermissionRequest( + provider: string, + requestId: string, + params: RequestPermissionRequest, + snapshot: ACPToolSnapshot, +): AgentPermissionRequest { + const kind: AgentPermissionRequestKind = snapshot.kind === "switch_mode" ? "mode" : "tool"; + return { + id: requestId, + provider, + name: snapshot.kind ?? snapshot.title, + kind, + title: params.toolCall.title ?? snapshot.title, + detail: mapToolDetail(snapshot, new Map()), + metadata: { + toolCallId: params.toolCall.toolCallId, + rawRequest: params, + options: params.options, + }, + }; +} + +function selectPermissionOption( + options: PermissionOption[], + response: AgentPermissionResponse, +): PermissionOption | null { + const order = + response.behavior === "allow" + ? ["allow_once", "allow_always"] + : ["reject_once", "reject_always"]; + for (const kind of order) { + const match = options.find((option) => option.kind === kind); + if (match) { + return match; + } + } + return null; +} + +function appendTerminalOutput(entry: TerminalEntry, chunk: string): void { + entry.output += chunk; + const limit = entry.outputByteLimit; + if (!limit) { + return; + } + while (Buffer.byteLength(entry.output, "utf8") > limit && entry.output.length > 0) { + entry.output = entry.output.slice(1); + entry.truncated = true; + } +} + +function readRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function readString( + record: Record | null, + keys: string[], +): string | undefined { + if (!record) { + return undefined; + } + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value; + } + } + return undefined; +} + +function readNumber( + record: Record | null, + keys: string[], +): number | undefined { + if (!record) { + return undefined; + } + for (const key of keys) { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + } + return undefined; +} + +function buildShellCommand(record: Record | null): string | undefined { + if (!record) { + return undefined; + } + const command = readString(record, ["command"]); + const args = Array.isArray(record["args"]) + ? record["args"].filter((value): value is string => typeof value === "string") + : []; + if (!command) { + return undefined; + } + return args.length > 0 ? `${command} ${args.join(" ")}` : command; +} + +function readErrorMessage(value: unknown): string { + if (typeof value === "string") { + return value; + } + const record = readRecord(value); + return readString(record, ["message", "error"]) ?? "Tool call failed"; +} + +function stringifyUnknown(value: unknown): string | undefined { + if (value == null) { + return undefined; + } + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function coerceSessionConfigMetadata(metadata: AgentMetadata | undefined): Partial { + if (!metadata || typeof metadata !== "object") { + return {}; + } + return metadata as Partial; +} + +async function waitForChildExit( + child: ChildProcessWithoutNullStreams, + timeoutMs: number, +): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + await Promise.race([ + new Promise((resolve) => child.once("exit", () => resolve())), + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } +} diff --git a/packages/server/src/server/agent/providers/claude-acp-agent.ts b/packages/server/src/server/agent/providers/claude-acp-agent.ts new file mode 100644 index 000000000..944f06fac --- /dev/null +++ b/packages/server/src/server/agent/providers/claude-acp-agent.ts @@ -0,0 +1,62 @@ +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/copilot-acp-agent.ts b/packages/server/src/server/agent/providers/copilot-acp-agent.ts new file mode 100644 index 000000000..e282c51cf --- /dev/null +++ b/packages/server/src/server/agent/providers/copilot-acp-agent.ts @@ -0,0 +1,54 @@ +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 COPILOT_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, +}; + +const COPILOT_MODES: AgentMode[] = [ + { + id: "https://agentclientprotocol.com/protocol/session-modes#agent", + label: "Agent", + description: "Default agent mode for conversational interactions", + }, + { + id: "https://agentclientprotocol.com/protocol/session-modes#plan", + label: "Plan", + description: "Plan mode for creating and executing multi-step plans", + }, + { + id: "https://agentclientprotocol.com/protocol/session-modes#autopilot", + label: "Autopilot", + description: "Autonomous mode that runs until task completion without user interaction", + }, +]; + +type CopilotACPAgentClientOptions = { + logger: Logger; + runtimeSettings?: ProviderRuntimeSettings; +}; + +export class CopilotACPAgentClient extends ACPAgentClient { + constructor(options: CopilotACPAgentClientOptions) { + super({ + provider: "copilot", + logger: options.logger, + runtimeSettings: options.runtimeSettings, + defaultCommand: ["copilot", "--acp"], + defaultModes: COPILOT_MODES, + capabilities: COPILOT_CAPABILITIES, + }); + } + + override async isAvailable(): Promise { + return super.isAvailable(); + } +} diff --git a/packages/server/src/server/daemon-e2e/agent-configs.ts b/packages/server/src/server/daemon-e2e/agent-configs.ts index d4de333be..803450f26 100644 --- a/packages/server/src/server/daemon-e2e/agent-configs.ts +++ b/packages/server/src/server/daemon-e2e/agent-configs.ts @@ -14,7 +14,7 @@ const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); dotenv.config({ path: resolve(serverRoot, ".env.test"), override: true }); export interface AgentTestConfig { - provider: "claude" | "codex" | "opencode"; + provider: string; model: string; thinkingOptionId?: string; modes: { @@ -32,6 +32,14 @@ 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", @@ -41,6 +49,14 @@ export const agentConfigs = { ask: "auto", }, }, + copilot: { + provider: "copilot", + model: "claude-haiku-4.5", + modes: { + full: "https://agentclientprotocol.com/protocol/session-modes#autopilot", + ask: "https://agentclientprotocol.com/protocol/session-modes#agent", + }, + }, opencode: { provider: "opencode", model: "opencode/glm-5-free", @@ -96,11 +112,15 @@ 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") && (existsSync(join(homedir(), ".codex", "auth.json")) || Boolean(process.env.OPENAI_API_KEY)) ); + case "copilot": + return isCommandAvailable("copilot"); case "opencode": return isCommandAvailable("opencode"); } @@ -109,4 +129,10 @@ export function isProviderAvailable(provider: AgentProvider): boolean { /** * Helper to run a test for each provider. */ -export const allProviders: AgentProvider[] = ["claude", "codex", "opencode"]; +export const allProviders: AgentProvider[] = [ + "claude", + "claude-acp", + "codex", + "copilot", + "opencode", +]; 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 new file mode 100644 index 000000000..49f158613 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/claude-acp.real.e2e.test.ts @@ -0,0 +1,156 @@ +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/exports.ts b/packages/server/src/server/exports.ts index ae059eabd..cc07163ba 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -37,6 +37,12 @@ export { quoteWindowsCommand, } from "./agent/provider-launch-config.js"; +// Provider manifest (source of truth for provider definitions) +export { + AGENT_PROVIDER_DEFINITIONS, + type AgentProviderDefinition, +} from "./agent/provider-manifest.js"; + // Agent SDK types for CLI commands export type { AgentMode, diff --git a/packages/server/src/server/loop-service.ts b/packages/server/src/server/loop-service.ts index 81d48b351..1d3eccf3d 100644 --- a/packages/server/src/server/loop-service.ts +++ b/packages/server/src/server/loop-service.ts @@ -71,11 +71,11 @@ const LoopRecordSchema = z.object({ name: z.string().nullable(), prompt: z.string(), cwd: z.string(), - provider: z.enum(["claude", "codex", "opencode"]), + provider: z.string(), model: z.string().nullable(), - workerProvider: z.enum(["claude", "codex", "opencode"]).nullable(), + workerProvider: z.string().nullable(), workerModel: z.string().nullable(), - verifierProvider: z.enum(["claude", "codex", "opencode"]).nullable(), + verifierProvider: z.string().nullable(), verifierModel: z.string().nullable(), verifyPrompt: z.string().nullable(), verifyChecks: z.array(z.string()), diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index 9a60af11c..387786a8f 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -1,6 +1,7 @@ import type { AgentManager } from "./agent/agent-manager.js"; -import type { AgentProvider, AgentSessionConfig } from "./agent/agent-sdk-types.js"; +import type { AgentSessionConfig } from "./agent/agent-sdk-types.js"; import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; +import { isValidAgentProvider } from "./agent/provider-manifest.js"; type LoggerLike = { child(bindings: Record): LoggerLike; @@ -14,10 +15,6 @@ function getLogger(logger: LoggerLike): LoggerLike { type AgentStoragePersistence = Pick; type AgentManagerStateSource = Pick; -function isKnownProvider(provider: string): provider is AgentProvider { - return provider === "claude" || provider === "codex" || provider === "opencode"; -} - /** * Attach AgentStorage persistence to an AgentManager instance so every * agent_state snapshot is flushed to disk. @@ -54,7 +51,7 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial