feat: ACP base provider, Copilot integration, eliminate hardcoded provider unions (#170)

* feat(server): add ACP base provider with Claude ACP integration

Implement a generic Agent Client Protocol (ACP) base provider that any
ACP-compatible agent can extend with minimal code. Includes a concrete
Claude ACP implementation via @agentclientprotocol/claude-agent-acp
with full parity to the existing Claude Code provider.

The base handles subprocess lifecycle, streaming translation, permission
bridging, terminal/fs callbacks, listModels, loadSession/resume, and
mode/model management. The concrete class only specifies the command,
modes, and availability check.

* fix: update lockfile signatures and Nix hash

* feat: add Copilot ACP provider, eliminate hardcoded provider unions, fix ACP streaming bugs

Add Copilot as an ACP provider (copilot --acp), with real modes and models
discovered from the ACP server. Fix two ACP base bugs: duplicate assistant
text (emit deltas not cumulative) and idle→running→stuck (fire-and-return
startTurn). Replace all hardcoded provider string unions with string/manifest-
derived values so adding a provider only requires: impl class, manifest entry,
registry factory, icon, and E2E config. Add provider docs and Copilot icon.

* fix: update lockfile signatures and Nix hash

* feat(app): add OpenCode provider icon

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Mohamed Boudra
2026-04-02 22:20:01 +07:00
committed by GitHub
parent 5d89f9444a
commit a854096c35
30 changed files with 3097 additions and 93 deletions

359
docs/PROVIDERS.md Normal file
View File

@@ -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<boolean> {
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<boolean> {
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<string, ProviderClientFactory> = {
// ... 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 (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="..." />
</Svg>
);
}
```
Then register it in `packages/app/src/components/provider-icons.ts`:
```ts
import { MyProviderIcon } from "@/components/icons/my-provider-icon";
const PROVIDER_ICONS: Record<string, typeof Bot> = {
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<string, AgentTestConfig>;
```
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<AgentSession>;
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>, launchContext?: AgentLaunchContext): Promise<AgentSession>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
isAvailable(): Promise<boolean>;
// Optional:
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
}
```
**`AgentSession`** -- a running agent conversation:
```ts
interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
streamHistory(): AsyncGenerator<AgentStreamEvent>;
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
getAvailableModes(): Promise<AgentMode[]>;
getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>;
getPendingPermissions(): AgentPermissionRequest[];
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
describePersistence(): AgentPersistenceHandle | null;
interrupt(): Promise<void>;
close(): Promise<void>;
// Optional:
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
}
```
### 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.

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-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).

10
package-lock.json generated
View File

@@ -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",

View File

@@ -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 (
<Svg width={size} height={size} viewBox="0 0 512 416" fill={color}>
<Path
d="M181.33 266.143c0-11.497 9.32-20.818 20.818-20.818 11.498 0 20.819 9.321 20.819 20.818v38.373c0 11.497-9.321 20.818-20.819 20.818-11.497 0-20.818-9.32-20.818-20.818v-38.373zM308.807 245.325c-11.477 0-20.798 9.321-20.798 20.818v38.373c0 11.497 9.32 20.818 20.798 20.818 11.497 0 20.818-9.32 20.818-20.818v-38.373c0-11.497-9.32-20.818-20.818-20.818z"
fillRule="nonzero"
/>
<Path d="M512.002 246.393v57.384c-.02 7.411-3.696 14.638-9.67 19.011C431.767 374.444 344.695 416 256 416c-98.138 0-196.379-56.542-246.33-93.21-5.975-4.374-9.65-11.6-9.671-19.012v-57.384a35.347 35.347 0 016.857-20.922l15.583-21.085c8.336-11.312 20.757-14.31 33.98-14.31 4.988-56.953 16.794-97.604 45.024-127.354C155.194 5.77 226.56 0 256 0c29.441 0 100.807 5.77 154.557 62.722 28.19 29.75 40.036 70.401 45.025 127.354 13.263 0 25.602 2.936 33.958 14.31l15.583 21.127c4.476 6.077 6.878 13.345 6.878 20.88zm-97.666-26.075c-.677-13.058-11.292-18.19-22.338-21.824-11.64 7.309-25.848 10.183-39.46 10.183-14.454 0-41.432-3.47-63.872-25.869-5.667-5.625-9.527-14.454-12.155-24.247a212.902 212.902 0 00-20.469-1.088c-6.098 0-13.099.349-20.551 1.088-2.628 9.793-6.509 18.622-12.155 24.247-22.4 22.4-49.418 25.87-63.872 25.87-13.612 0-27.86-2.855-39.501-10.184-11.005 3.613-21.558 8.828-22.277 21.824-1.17 24.555-1.272 49.11-1.375 73.645-.041 12.318-.082 24.658-.288 36.976.062 7.166 4.374 13.818 10.882 16.774 52.97 24.124 103.045 36.278 149.137 36.278 46.01 0 96.085-12.154 149.014-36.278 6.508-2.956 10.84-9.608 10.881-16.774.637-36.832.124-73.809-1.642-110.62h.041zM107.521 168.97c8.643 8.623 24.966 14.392 42.56 14.392 13.448 0 39.03-2.874 60.156-24.329 9.28-8.951 15.05-31.35 14.413-54.079-.657-18.231-5.769-33.28-13.448-39.665-8.315-7.371-27.203-10.574-48.33-8.644-22.399 2.238-41.267 9.588-50.875 19.833-20.798 22.728-16.323 80.317-4.476 92.492zm130.556-56.008c.637 3.51.965 7.35 1.273 11.517 0 2.875 0 5.77-.308 8.952 6.406-.636 11.847-.636 16.959-.636s10.553 0 16.959.636c-.329-3.182-.329-6.077-.329-8.952.329-4.167.657-8.007 1.294-11.517-6.735-.637-12.812-.965-17.924-.965s-11.21.328-17.924.965zm49.275-8.008c-.637 22.728 5.133 45.128 14.413 54.08 21.105 21.454 46.708 24.328 60.155 24.328 17.596 0 33.918-5.769 42.561-14.392 11.847-12.175 16.322-69.764-4.476-92.492-9.608-10.245-28.476-17.595-50.875-19.833-21.127-1.93-40.015 1.273-48.33 8.644-7.679 6.385-12.791 21.434-13.448 39.665z" />
</Svg>
);
}

View File

@@ -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 (
<Svg width={size} height={size} viewBox="96 64 288 384" fill={color}>
<Path d="M320 224V352H192V224H320Z" opacity={0.4} />
<Path
fillRule="evenodd"
clipRule="evenodd"
d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z"
/>
</Svg>
);
}

View File

@@ -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<string, typeof Bot> = {
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 {

View File

@@ -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 ?? "",

View File

@@ -412,7 +412,7 @@ export async function runRunCommand(
const callStructuredTurn = async (structuredPrompt: string): Promise<string> => {
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,

View File

@@ -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[];

View File

@@ -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[];

View File

@@ -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<ProviderListItem> = {

View File

@@ -22,7 +22,7 @@ export type ScheduleTarget =
| {
type: "new-agent";
config: {
provider: "claude" | "codex" | "opencode";
provider: string;
cwd: string;
modeId?: string;
model?: string;

View File

@@ -54,7 +54,7 @@ let claudeModelsFromJson: ProviderModel[] = [];
const ctx = await createE2ETestContext({ timeout: 120000 });
async function runProviderModelsJson(
provider: "claude" | "codex" | "opencode",
provider: string,
): Promise<ProviderModel[]> {
const transientNeedles = ["transport closed", "timed out", "timeout", "socket", "econn"];

View File

@@ -50,7 +50,7 @@ async function cleanup(): Promise<void> {
}
async function runProviderCase(input: {
provider: "claude" | "codex" | "opencode";
provider: string;
mode: string;
model: string;
}): Promise<void> {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string, ProviderClientFactory> = {
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<AgentProvider, ProviderDefinition> {
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<AgentProvider, ProviderDefinition>;
}
// Deprecated: Use buildProviderRegistry instead
@@ -71,11 +95,12 @@ export function createAllClients(
options?: BuildProviderRegistryOptions,
): Record<AgentProvider, AgentClient> {
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<AgentProvider, AgentClient>;
}
export async function shutdownProviders(

View File

@@ -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();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -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<boolean> {
if (!(await super.isAvailable())) {
return false;
}
return Boolean(process.env["CLAUDE_CODE_OAUTH_TOKEN"] || process.env["ANTHROPIC_API_KEY"]);
}
}

View File

@@ -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<boolean> {
return super.isAvailable();
}
}

View File

@@ -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",
];

View File

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

View File

@@ -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,

View File

@@ -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()),

View File

@@ -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<string, unknown>): LoggerLike;
@@ -14,10 +15,6 @@ function getLogger(logger: LoggerLike): LoggerLike {
type AgentStoragePersistence = Pick<AgentStorage, "applySnapshot" | "list">;
type AgentManagerStateSource = Pick<AgentManager, "subscribe">;
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<AgentSe
}
export function buildSessionConfig(record: StoredAgentRecord): AgentSessionConfig {
if (!isKnownProvider(record.provider)) {
if (!isValidAgentProvider(record.provider)) {
throw new Error(`Unknown provider '${record.provider}'`);
}
const overrides = buildConfigOverrides(record);

View File

@@ -14,7 +14,7 @@ const openaiApiKey = process.env.OPENAI_API_KEY ?? null;
const shouldRun = process.env.PASEO_VOICE_ROUNDTRIP_E2E === "1" && Boolean(openaiApiKey);
const speechTest = shouldRun ? test : test.skip;
type VoiceRoundtripProvider = "claude" | "codex" | "opencode";
type VoiceRoundtripProvider = string;
function getVoiceRoundtripConfig(provider: VoiceRoundtripProvider): {
provider: VoiceRoundtripProvider;

View File

@@ -609,7 +609,7 @@ program
// Agent runner
interface AgentConfig {
cli: "claude" | "codex";
cli: string;
model?: string;
effort?: string;
}

View File

@@ -1,6 +1,6 @@
export type TaskStatus = "draft" | "open" | "in_progress" | "done" | "failed";
export type AgentType = "claude" | "codex";
export type AgentType = string;
export type ModelName = "haiku" | "sonnet" | "opus" | `gpt-${string}`;

View File

@@ -171,7 +171,7 @@
"additionalProperties": false
},
"propertyNames": {
"enum": ["claude", "codex", "opencode"]
"type": "string"
}
}
},
@@ -223,8 +223,7 @@
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": ["claude", "codex", "opencode"]
"type": "string"
},
"model": {
"type": "string",