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:
co-authored by
github-actions[bot] <github-actions[bot]@users.noreply.github.com>
parent
5d89f9444a
commit
a854096c35
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -609,7 +609,7 @@ program
|
||||
// Agent runner
|
||||
|
||||
interface AgentConfig {
|
||||
cli: "claude" | "codex";
|
||||
cli: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user