feat(acp): scaffold fusion-plugin-acp-runtime (U1)
New runtime plugin registering runtimeId 'acp', mirroring the fusion-plugin-droid-runtime shape. Adds @agentclientprotocol/sdk@0.24.0 and an SDK smoke-import test that gates on the load-bearing exports (ClientSideConnection, ndJsonStream, PROTOCOL_VERSION=1) so a breaking SDK change surfaces at U1. Runtime adapter is a contract-conforming skeleton (incl. describeModel); session driving lands in U2/U3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
13
plugins/fusion-plugin-acp-runtime/manifest.json
Normal file
13
plugins/fusion-plugin-acp-runtime/manifest.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "fusion-plugin-acp-runtime",
|
||||
"name": "ACP Runtime Plugin",
|
||||
"version": "0.1.0",
|
||||
"description": "Drives any external Agent Client Protocol (ACP) agent for Fusion",
|
||||
"author": "Fusion Team",
|
||||
"runtime": {
|
||||
"runtimeId": "acp",
|
||||
"name": "ACP Runtime",
|
||||
"description": "Drives any external ACP-compatible agent over JSON-RPC/stdio",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
}
|
||||
41
plugins/fusion-plugin-acp-runtime/package.json
Normal file
41
plugins/fusion-plugin-acp-runtime/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@fusion-plugin-examples/acp-runtime",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "ACP (Agent Client Protocol) runtime plugin for Fusion — drives any ACP-compatible agent over JSON-RPC/stdio",
|
||||
"keywords": [
|
||||
"fusion-plugin",
|
||||
"acp",
|
||||
"agent-client-protocol",
|
||||
"runtime"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./probe": {
|
||||
"types": "./src/probe.ts",
|
||||
"import": "./src/probe.ts"
|
||||
}
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --silent=passed-only --reporter=dot",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.24.0",
|
||||
"@fusion/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-ai": "*",
|
||||
"@earendil-works/pi-coding-agent": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.2",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import plugin, { AcpRuntimeAdapter, acpRuntimeFactory, acpRuntimeMetadata, resolveCliSettings } from "../index.js";
|
||||
import { ACP_NOT_IMPLEMENTED } from "../runtime-adapter.js";
|
||||
import type { AgentRuntime } from "../types.js";
|
||||
|
||||
describe("fusion-plugin-acp-runtime", () => {
|
||||
it("declares the acp runtime in its manifest", () => {
|
||||
expect(plugin.manifest.id).toBe("fusion-plugin-acp-runtime");
|
||||
expect(plugin.manifest.runtime?.runtimeId).toBe("acp");
|
||||
expect(acpRuntimeMetadata.runtimeId).toBe("acp");
|
||||
});
|
||||
|
||||
it("factory returns an AgentRuntime conforming object", async () => {
|
||||
const runtime = (await acpRuntimeFactory({ settings: {} } as never)) as AgentRuntime;
|
||||
expect(runtime).toBeTruthy();
|
||||
expect(runtime.id).toBe("acp");
|
||||
expect(typeof runtime.name).toBe("string");
|
||||
expect(typeof runtime.createSession).toBe("function");
|
||||
expect(typeof runtime.promptWithFallback).toBe("function");
|
||||
// describeModel is required by the contract — the adapter must implement it.
|
||||
expect(typeof runtime.describeModel).toBe("function");
|
||||
});
|
||||
|
||||
it("describeModel returns the session's model description", () => {
|
||||
const runtime = new AcpRuntimeAdapter({ acpModel: "gemini-2.0" });
|
||||
const desc = runtime.describeModel({ lastModelDescription: "acp/gemini-2.0" } as never);
|
||||
expect(desc).toBe("acp/gemini-2.0");
|
||||
});
|
||||
|
||||
it("session-driving stubs reject with the not-implemented marker (until U2/U3)", async () => {
|
||||
const runtime = new AcpRuntimeAdapter({});
|
||||
await expect(
|
||||
runtime.createSession({ cwd: "/tmp", systemPrompt: "" } as never),
|
||||
).rejects.toThrow(ACP_NOT_IMPLEMENTED);
|
||||
await expect(runtime.promptWithFallback({} as never, "hi")).rejects.toThrow(ACP_NOT_IMPLEMENTED);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCliSettings", () => {
|
||||
it("returns conservative defaults for undefined settings", () => {
|
||||
const s = resolveCliSettings(undefined);
|
||||
expect(s.binaryPath).toBe("acp-agent");
|
||||
expect(s.args).toEqual([]);
|
||||
// fs capabilities are opt-in (KTD6) — default OFF.
|
||||
expect(s.fsRead).toBe(false);
|
||||
expect(s.fsWrite).toBe(false);
|
||||
// env allow-list empty by default (KTD6b) — no inherited process.env.
|
||||
expect(s.envAllowList).toEqual([]);
|
||||
});
|
||||
|
||||
it("honors explicit binary, args, and capability toggles", () => {
|
||||
const s = resolveCliSettings({
|
||||
acpBinaryPath: "gemini",
|
||||
acpArgs: ["--acp"],
|
||||
acpModel: "gemini-2.0",
|
||||
acpFsRead: true,
|
||||
acpEnvAllowList: ["HOME", "PATH"],
|
||||
});
|
||||
expect(s.binaryPath).toBe("gemini");
|
||||
expect(s.args).toEqual(["--acp"]);
|
||||
expect(s.model).toBe("gemini-2.0");
|
||||
expect(s.fsRead).toBe(true);
|
||||
expect(s.fsWrite).toBe(false);
|
||||
expect(s.envAllowList).toEqual(["HOME", "PATH"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import * as acp from "@agentclientprotocol/sdk";
|
||||
|
||||
// U1 gating verification (KTD2): the integration is built on a day-old SDK.
|
||||
// These assertions fail the build if a load-bearing export is missing or
|
||||
// reshaped, surfacing a breaking change at U1 rather than deep in U2.
|
||||
describe("@agentclientprotocol/sdk export surface", () => {
|
||||
it("exposes ClientSideConnection as a constructable", () => {
|
||||
expect(typeof acp.ClientSideConnection).toBe("function");
|
||||
});
|
||||
|
||||
it("exposes ndJsonStream as a function", () => {
|
||||
expect(typeof acp.ndJsonStream).toBe("function");
|
||||
});
|
||||
|
||||
it("exposes PROTOCOL_VERSION as the integer 1", () => {
|
||||
expect(typeof acp.PROTOCOL_VERSION).toBe("number");
|
||||
expect(acp.PROTOCOL_VERSION).toBe(1);
|
||||
});
|
||||
|
||||
it("exposes the client/agent method maps used for routing", () => {
|
||||
expect(acp.CLIENT_METHODS).toBeDefined();
|
||||
expect(acp.AGENT_METHODS).toBeDefined();
|
||||
});
|
||||
});
|
||||
50
plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts
Normal file
50
plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Resolves the ACP agent launch configuration from plugin settings.
|
||||
//
|
||||
// Unlike the Claude/Droid CLIs (one fixed binary per plugin), ACP is a protocol:
|
||||
// the user points this runtime at *any* ACP-compatible agent binary plus the
|
||||
// flag that puts it in ACP mode (e.g. `gemini --acp`). Settings therefore carry
|
||||
// an arbitrary binary + args, plus the conservative-by-default fs capability
|
||||
// toggles (KTD6: writes default OFF) and an env allow-list (KTD6b).
|
||||
|
||||
export interface AcpCliSettings {
|
||||
/** Agent binary to spawn (e.g. "gemini", "npx", an absolute path). */
|
||||
binaryPath: string;
|
||||
/** Arguments that launch the agent in ACP/stdio mode (e.g. ["--acp"]). */
|
||||
args: string[];
|
||||
/** Optional model identifier reported via describeModel. */
|
||||
model?: string;
|
||||
/** Advertise `fs/read_text_file` capability. Default: false (opt-in). */
|
||||
fsRead: boolean;
|
||||
/** Advertise `fs/write_text_file` capability. Default: false (opt-in, KTD6). */
|
||||
fsWrite: boolean;
|
||||
/**
|
||||
* Environment variables to forward to the agent subprocess (KTD6b allow-list).
|
||||
* The agent is untrusted; inherited `process.env` is NOT forwarded. Empty by
|
||||
* default — callers opt specific vars in by name.
|
||||
*/
|
||||
envAllowList: string[];
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const out = value.filter((v): v is string => typeof v === "string");
|
||||
return out.length === value.length ? out : undefined;
|
||||
}
|
||||
|
||||
function asBool(value: unknown): boolean {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
export function resolveCliSettings(settings?: Record<string, unknown>): AcpCliSettings {
|
||||
const binaryPath = asTrimmedString(settings?.acpBinaryPath) ?? "acp-agent";
|
||||
const args = asStringArray(settings?.acpArgs) ?? [];
|
||||
const model = asTrimmedString(settings?.acpModel);
|
||||
const fsRead = asBool(settings?.acpFsRead);
|
||||
const fsWrite = asBool(settings?.acpFsWrite);
|
||||
const envAllowList = asStringArray(settings?.acpEnvAllowList) ?? [];
|
||||
return { binaryPath, args, model, fsRead, fsWrite, envAllowList };
|
||||
}
|
||||
46
plugins/fusion-plugin-acp-runtime/src/index.ts
Normal file
46
plugins/fusion-plugin-acp-runtime/src/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { definePlugin } from "@fusion/plugin-sdk";
|
||||
import type { FusionPlugin, PluginRuntimeFactory, PluginRuntimeManifestMetadata } from "@fusion/plugin-sdk";
|
||||
import { resolveCliSettings } from "./cli-spawn.js";
|
||||
import { AcpRuntimeAdapter } from "./runtime-adapter.js";
|
||||
|
||||
export const ACP_RUNTIME_ID = "acp";
|
||||
const ACP_RUNTIME_VERSION = "0.1.0";
|
||||
|
||||
export const acpRuntimeMetadata: PluginRuntimeManifestMetadata = {
|
||||
runtimeId: ACP_RUNTIME_ID,
|
||||
name: "ACP Runtime",
|
||||
description: "Drives any external ACP-compatible agent over JSON-RPC/stdio",
|
||||
version: ACP_RUNTIME_VERSION,
|
||||
};
|
||||
|
||||
export const acpRuntimeFactory: PluginRuntimeFactory = async (ctx) =>
|
||||
new AcpRuntimeAdapter(ctx.settings as Record<string, unknown> | undefined);
|
||||
|
||||
const plugin: FusionPlugin = definePlugin({
|
||||
manifest: {
|
||||
id: "fusion-plugin-acp-runtime",
|
||||
name: "ACP Runtime Plugin",
|
||||
version: ACP_RUNTIME_VERSION,
|
||||
description: "Drives any external ACP-compatible agent over JSON-RPC/stdio",
|
||||
runtime: acpRuntimeMetadata,
|
||||
},
|
||||
state: "installed",
|
||||
hooks: {
|
||||
onLoad: (ctx) => {
|
||||
const settings = resolveCliSettings(ctx.settings as Record<string, unknown>);
|
||||
ctx.logger.info(
|
||||
`ACP Runtime Plugin loaded — binary=${settings.binaryPath} args=[${settings.args.join(" ")}] ` +
|
||||
`fsRead=${settings.fsRead} fsWrite=${settings.fsWrite}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
metadata: acpRuntimeMetadata,
|
||||
factory: acpRuntimeFactory,
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
export { AcpRuntimeAdapter };
|
||||
export { resolveCliSettings } from "./cli-spawn.js";
|
||||
export type { AcpCliSettings } from "./cli-spawn.js";
|
||||
63
plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts
Normal file
63
plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// AgentRuntime adapter for the ACP runtime.
|
||||
//
|
||||
// U1 scaffold: implements the full `AgentRuntime` contract shape (including the
|
||||
// required `describeModel`) with stubs that throw `not_implemented` until the
|
||||
// session driver lands in U2/U3. The skeleton exists so the plugin loads,
|
||||
// registers as `runtimeId: "acp"`, and conforms to the interface the engine
|
||||
// resolves via `getRuntimeById`.
|
||||
|
||||
import { resolveCliSettings, type AcpCliSettings } from "./cli-spawn.js";
|
||||
import type {
|
||||
AgentRuntime,
|
||||
AgentRuntimeOptions,
|
||||
AgentSession,
|
||||
AgentSessionResult,
|
||||
AcpSession,
|
||||
} from "./types.js";
|
||||
|
||||
export const ACP_NOT_IMPLEMENTED = "acp_not_implemented";
|
||||
|
||||
export class AcpRuntimeAdapter implements AgentRuntime {
|
||||
readonly id = "acp";
|
||||
readonly name = "ACP Runtime";
|
||||
private readonly settings: AcpCliSettings;
|
||||
|
||||
constructor(settings?: Record<string, unknown>) {
|
||||
this.settings = resolveCliSettings(settings);
|
||||
}
|
||||
|
||||
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
|
||||
// Session establishment (spawn + initialize + session/new) lands in U2/U3.
|
||||
// The skeleton constructs the session shell so the contract is observable.
|
||||
const model = this.settings.model ?? options.defaultModelId ?? "acp";
|
||||
const session: AcpSession = {
|
||||
model,
|
||||
systemPrompt: options.systemPrompt,
|
||||
sessionId: "",
|
||||
cwd: options.cwd,
|
||||
lastModelDescription: `acp/${model}`,
|
||||
callbacks: {
|
||||
onText: options.onText,
|
||||
onThinking: options.onThinking,
|
||||
onToolStart: options.onToolStart,
|
||||
onToolEnd: options.onToolEnd,
|
||||
},
|
||||
gate: options.actionGateContext,
|
||||
dispose: () => undefined,
|
||||
};
|
||||
throw new Error(`${ACP_NOT_IMPLEMENTED}: createSession lands in U2/U3 (session=${session.lastModelDescription})`);
|
||||
}
|
||||
|
||||
async promptWithFallback(_session: AgentSession, _prompt: string, _options?: unknown): Promise<void> {
|
||||
throw new Error(`${ACP_NOT_IMPLEMENTED}: promptWithFallback lands in U3`);
|
||||
}
|
||||
|
||||
describeModel(session: AgentSession): string {
|
||||
return session.lastModelDescription || "acp";
|
||||
}
|
||||
|
||||
async dispose(session: AgentSession): Promise<void> {
|
||||
// Best-effort teardown; the authoritative kill is the process registry (KTD4a).
|
||||
session.dispose();
|
||||
}
|
||||
}
|
||||
86
plugins/fusion-plugin-acp-runtime/src/types.ts
Normal file
86
plugins/fusion-plugin-acp-runtime/src/types.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// Local types for the ACP (Agent Client Protocol) runtime plugin.
|
||||
//
|
||||
// The wire protocol types come from `@agentclientprotocol/sdk` (the `schema`
|
||||
// namespace). These local types describe (a) the Fusion `AgentRuntime` contract
|
||||
// this plugin implements and (b) the ACP session state this plugin tracks.
|
||||
//
|
||||
// The `AgentRuntimeOptions` here is a plugin-local structural copy of the engine
|
||||
// contract (`packages/engine/src/agent-runtime.ts`). It deliberately includes
|
||||
// only the fields this runtime reads. `actionGateContext` is the engine-populated
|
||||
// per-run permission gate — see `PermissionGate` below, the narrow structural
|
||||
// view this plugin couples to instead of importing `@fusion/engine` internals.
|
||||
|
||||
/** Callbacks the engine wires to surface streamed agent output into Fusion's UI/logs. */
|
||||
export interface AcpCallbacks {
|
||||
onText?: (text: string) => void;
|
||||
onThinking?: (text: string) => void;
|
||||
onToolStart?: (toolName: string, args?: unknown) => void;
|
||||
onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow structural view of the engine's `AgentActionGateContext`
|
||||
* (`packages/engine/src/agent-action-gate.ts`). The plugin reads only these
|
||||
* members; typing them locally avoids a hard dependency on `@fusion/engine`.
|
||||
*
|
||||
* All HITL closures are optional: when absent, the permission floor (U5)
|
||||
* default-denies `require-approval` categories rather than throwing.
|
||||
*/
|
||||
export interface PermissionGate {
|
||||
permissionPolicy?: unknown;
|
||||
evaluate?: (toolName: string, args: unknown) => unknown;
|
||||
resolveGateOutcome?: (evaluation: unknown) => unknown;
|
||||
createApprovalRequest?: (...args: unknown[]) => Promise<unknown> | unknown;
|
||||
findApprovalByDedupeKey?: (...args: unknown[]) => Promise<unknown> | unknown;
|
||||
pauseForApproval?: (...args: unknown[]) => Promise<unknown> | unknown;
|
||||
markApprovalCompleted?: (...args: unknown[]) => Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
/** Plugin-local copy of the engine's AgentRuntimeOptions (subset this runtime reads). */
|
||||
export interface AgentRuntimeOptions {
|
||||
cwd: string;
|
||||
systemPrompt: string;
|
||||
tools?: "coding" | "readonly";
|
||||
onText?: (text: string) => void;
|
||||
onThinking?: (text: string) => void;
|
||||
onToolStart?: (toolName: string, args?: unknown) => void;
|
||||
onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
|
||||
defaultProvider?: string;
|
||||
defaultModelId?: string;
|
||||
defaultThinkingLevel?: string;
|
||||
/** Per-run permission gate, populated by the engine. See PermissionGate. */
|
||||
actionGateContext?: PermissionGate;
|
||||
}
|
||||
|
||||
/** Live ACP session state tracked by the runtime adapter. */
|
||||
export interface AcpSession {
|
||||
/** Model/agent identifier resolved for this session. */
|
||||
model: string;
|
||||
systemPrompt: string;
|
||||
/** ACP session id returned by `session/new` (empty until established). */
|
||||
sessionId: string;
|
||||
/** Working directory the agent operates over (the task worktree). */
|
||||
cwd: string;
|
||||
lastModelDescription: string;
|
||||
callbacks: AcpCallbacks;
|
||||
/** Per-run permission gate captured at createSession (U5/U7 read this). */
|
||||
gate?: PermissionGate;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export type AgentSession = AcpSession;
|
||||
|
||||
export interface AgentSessionResult {
|
||||
session: AgentSession;
|
||||
sessionFile?: string;
|
||||
}
|
||||
|
||||
/** The Fusion runtime contract this plugin implements (mirrors the engine interface). */
|
||||
export interface AgentRuntime {
|
||||
id: string;
|
||||
name: string;
|
||||
createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult>;
|
||||
promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void>;
|
||||
describeModel(session: AgentSession): string;
|
||||
dispose?(session: AgentSession): Promise<void>;
|
||||
}
|
||||
9
plugins/fusion-plugin-acp-runtime/tsconfig.json
Normal file
9
plugins/fusion-plugin-acp-runtime/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
22
plugins/fusion-plugin-acp-runtime/vitest.config.ts
Normal file
22
plugins/fusion-plugin-acp-runtime/vitest.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { computeMaxWorkers } from "../../packages/core/src/__test-utils__/vitest-workers";
|
||||
|
||||
const maxWorkers = computeMaxWorkers();
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
|
||||
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))],
|
||||
globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))],
|
||||
pool: "threads",
|
||||
maxWorkers,
|
||||
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user