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:
gsxdsm
2026-06-03 08:35:51 -07:00
parent 8e3f4df2b4
commit a3292fb545
12 changed files with 456 additions and 0 deletions

View 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"
}
}

View 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"
}
}

View File

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

View File

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

View 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 };
}

View 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";

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

View 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>;
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*"]
}

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

34
pnpm-lock.yaml generated
View File

@@ -627,6 +627,31 @@ importers:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.7.0)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.9.0)
plugins/fusion-plugin-acp-runtime:
dependencies:
'@agentclientprotocol/sdk':
specifier: 0.24.0
version: 0.24.0(zod@4.3.6)
'@earendil-works/pi-ai':
specifier: '*'
version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
'@earendil-works/pi-coding-agent':
specifier: '*'
version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
'@fusion/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
devDependencies:
'@types/node':
specifier: ^25.5.2
version: 25.5.2
typescript:
specifier: ^5.7.0
version: 5.9.3
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.7.0)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.9.0)
plugins/fusion-plugin-agent-browser:
dependencies:
'@fusion/plugin-sdk':
@@ -971,6 +996,11 @@ packages:
'@adobe/css-tools@4.4.4':
resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
'@agentclientprotocol/sdk@0.24.0':
resolution: {integrity: sha512-vvu9appvGvfYstBj19C6NCepV6SvUhY5VRv60KUZ4XzhTah/olOYul5Zo4C+x2enyshMSvgB2mm/OEmrsHaSmA==}
peerDependencies:
zod: ^3.25.0 || ^4.0.0
'@alcalzone/ansi-tokenize@0.2.5':
resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==}
engines: {node: '>=18'}
@@ -6439,6 +6469,10 @@ snapshots:
'@adobe/css-tools@4.4.4': {}
'@agentclientprotocol/sdk@0.24.0(zod@4.3.6)':
dependencies:
zod: 4.3.6
'@alcalzone/ansi-tokenize@0.2.5':
dependencies:
ansi-styles: 6.2.3

View File

@@ -7,6 +7,7 @@ packages:
- "plugins/fusion-plugin-openclaw-runtime"
- "plugins/fusion-plugin-hermes-runtime"
- "plugins/fusion-plugin-droid-runtime"
- "plugins/fusion-plugin-acp-runtime"
- "plugins/fusion-plugin-cursor-runtime"
- "plugins/fusion-plugin-agent-browser"
- "plugins/fusion-plugin-whatsapp-chat"