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 23:20:01 +08:00
committed by GitHub
co-authored by github-actions[bot] <github-actions[bot]@users.noreply.github.com>
parent 5d89f9444a
commit a854096c35
30 changed files with 3097 additions and 93 deletions
@@ -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",
@@ -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(
@@ -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
@@ -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"]);
}
}
@@ -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();
}
}
@@ -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",
];
@@ -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,
);
});
+6
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,
+3 -3
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()),
@@ -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);
@@ -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;
+1 -1
View File
@@ -609,7 +609,7 @@ program
// Agent runner
interface AgentConfig {
cli: "claude" | "codex";
cli: string;
model?: string;
effort?: string;
}
+1 -1
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}`;