Official
acp
ACP agents from the ACP registry (Gemini, Cursor, Droid, Kilo, pi, ...), Oh My Pi, and your own entries (custom.json in the plugin's data folder). Agents are discovered at runtime.
The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/acp@0.2.0
Permissions in 0.2.0
Take care. This plugin asks for permissions that can do anything your account can. The app asks you to hold Enable for two seconds or to type the plugin's name before it turns on.
Files
registry.test.ts18.3 KB
// Ported from the Rust plugin's registry.rs tests, plus the cache that
// replaced the data folder.
import * as z from "zod";
import * as Effect from "effect/Effect";
import * as ManagedRuntime from "effect/ManagedRuntime";
import { liveLayer } from "convergence/effect";
import type { Api } from "convergence";
import { afterEach } from "node:test";
import { test } from "node:test";
import assert from "node:assert/strict";
import { fakeApi as sdkFakeApi, memoryStorage } from "../sdk/testing.ts";
import {
MAX_CACHE_AGE,
REGISTRY_URL,
adoptLegacy,
agentIdOf,
assemble,
binaryName,
detectPlatform,
fetchIcons,
isSvg,
loadCustom,
loadRegistry,
markOf,
packageBinary,
parseCustom,
parseRegistry,
platformFromUname,
resolveLaunch,
} from "./registry.ts";
function fakeApi({
fs,
...options
}: Omit<NonNullable<Parameters<typeof sdkFakeApi>[0]>, "fs"> & {
fs?: {
read?: (path: string) => Promise<string>;
write?: (path: string, text: string | Uint8Array) => Promise<unknown>;
};
} = {}) {
const api = sdkFakeApi(options);
if (fs) Object.assign(api.fs, fs);
return api;
}
const runtimes: ManagedRuntime.ManagedRuntime<import("convergence/effect").PluginServices, never>[] = [];
afterEach(async () => {
for (const runtime of runtimes.splice(0)) await runtime.dispose();
});
function run<A, E>(
api: Api,
effect: Effect.Effect<A, E, import("convergence/effect").PluginServices | import("effect/Scope").Scope>,
) {
const runtime: ManagedRuntime.ManagedRuntime<import("convergence/effect").PluginServices, never> =
ManagedRuntime.make(
liveLayer(
api,
(effect) => runtime.runPromise(effect),
() => {},
),
);
runtimes.push(runtime);
return runtime.runPromise(Effect.scoped(effect));
}
const PLATFORM = "darwin-aarch64";
/// `which` over a set of installed programs.
const installed =
(...programs: string[]) =>
(program: string) =>
Effect.succeed(programs.includes(program) ? `/usr/local/bin/${program}` : null);
// The registry publishes a mark per agent as a URL; the UI draws inline
// SVG, so the plugin serves what it downloaded. An agent the registry does
// not list brings its own.
test("a registry mark comes from the cache and a known agent brings its own", () => {
const registry = parseRegistry({
agents: [
{ id: "cursor", name: "Cursor", icon: "https://example.invalid/cursor.svg", distribution: {} },
{ id: "nova", name: "Nova", distribution: {} },
],
});
const entries = assemble(registry, []);
const by = (id: string) => {
const found = entries.find((entry) => entry.id === id);
assert.ok(found);
return found;
};
const svg = '<svg><path fill="currentColor"/></svg>';
assert.equal(markOf(by("cursor"), { cursor: svg }), svg);
// One still downloading, and one with no mark published, show none.
assert.equal(markOf(by("cursor"), {}), null);
assert.equal(markOf(by("nova"), { nova: svg }), null);
// An error page in the cache is not a drawing.
assert.equal(markOf(by("cursor"), { cursor: "<html>404</html>" }), null);
// Oh My Pi is not in the registry, so the plugin carries its mark.
const mark = markOf(by("omp"), {});
assert.ok(mark);
assert.match(mark, /<svg/);
});
test("the native agents are left out and the rest sorted by name", () => {
const registry = parseRegistry({
agents: [
{ id: "opencode", name: "OpenCode" },
{ id: "claude-acp", name: "Claude" },
{ id: "codex-acp", name: "Codex" },
{ id: "zed", name: "zeta" },
{ id: "gemini", name: "Gemini CLI" },
{ name: "no id" },
],
});
assert.deepEqual(
assemble(registry, []).map((entry) => entry.id),
["gemini", "omp", "zed"],
);
assert.equal(agentIdOf("gemini"), "acp:gemini");
});
test("binary names drop directories and extensions", () => {
assert.equal(binaryName("./dist-package/cursor-agent"), "cursor-agent");
assert.equal(binaryName("./bin\\devin.exe"), "devin");
assert.equal(binaryName("./goose"), "goose");
});
test("package names drop scopes and versions", () => {
assert.equal(packageBinary("@google/gemini-cli@0.59.0"), "gemini-cli");
assert.equal(packageBinary("droid@0.218.1"), "droid");
assert.equal(packageBinary("@github/copilot"), "copilot");
});
test("the platform key follows uname", () => {
assert.equal(platformFromUname("Darwin arm64\n"), "darwin-aarch64");
assert.equal(platformFromUname("Darwin x86_64"), "darwin-x86_64");
assert.equal(platformFromUname("Linux aarch64"), "linux-aarch64");
assert.equal(platformFromUname("Linux x86_64"), "linux-x86_64");
assert.equal(platformFromUname("MINGW64_NT-10.0 x86_64"), "windows-x86_64");
});
test("the platform comes from uname, and Windows is what has none", async () => {
const unix = fakeApi({
onSpawn: (program) => (program === "uname" ? (_message, _peer) => {} : new Error("no such program")),
});
const detecting = run(unix, detectPlatform());
await new Promise((resolve) => setTimeout(resolve, 10));
unix.peers[0].stdout.push("Linux aarch64\n");
unix.peers[0].exit(0);
assert.equal(await detecting, "linux-aarch64");
const windows = fakeApi({
onSpawn: () => new Error("uname is not on the PATH"),
env: { PROCESSOR_ARCHITECTURE: "ARM64" },
});
assert.equal(await run(windows, detectPlatform()), "windows-aarch64");
});
test("a missing binary reports how to install it", async () => {
const entry = assemble(
parseRegistry({
agents: [
{
id: "thing",
name: "Thing",
distribution: {
binary: {
[PLATFORM]: {
archive: "https://example.invalid/thing.tar.gz",
cmd: "./definitely-not-installed-xyz",
args: ["acp"],
},
},
},
},
],
}),
).find((entry) => entry.id === "thing");
assert.ok(entry);
await assert.rejects(Effect.runPromise(resolveLaunch(entry, { which: installed(), platform: PLATFORM })), (error) => {
assert.ok(error instanceof Error);
assert.match(error.message, /definitely-not-installed-xyz/);
assert.match(error.message, /example\.invalid/);
return true;
});
// A registry agent with no build for this machine says so.
assert.ok(entry);
const other: import("./registry.ts").Entry = {
...entry,
source: { kind: "registry", distribution: { binary: {}, npx: null, uvx: null } },
};
await assert.rejects(
Effect.runPromise(resolveLaunch(other, { which: installed(), platform: PLATFORM })),
/publishes no build for darwin-aarch64/,
);
});
test("an installed binary resolves with its registry args and env", async () => {
const [entry] = assemble(
parseRegistry({
agents: [
{
id: "shell",
name: "Shell",
distribution: { binary: { [PLATFORM]: { archive: "", cmd: "./sh", args: ["acp"], env: { A: "1" } } } },
},
],
}),
).filter((entry) => entry.id === "shell");
assert.deepEqual(await Effect.runPromise(resolveLaunch(entry, { which: installed("sh"), platform: PLATFORM })), {
program: "/usr/local/bin/sh",
args: ["acp"],
env: { A: "1" },
});
});
test("a package agent prefers its own CLI, then the package runner", async () => {
const [entry] = assemble(
parseRegistry({
agents: [
{
id: "factory-droid",
name: "Factory Droid",
distribution: {
npx: {
package: "droid@0.228.0",
args: ["exec", "--output-format", "acp-daemon"],
env: { DROID_DISABLE_AUTO_UPDATE: "true" },
},
},
},
],
}),
).filter((entry) => entry.id === "factory-droid");
assert.deepEqual(
await Effect.runPromise(resolveLaunch(entry, { which: installed("droid", "npx"), platform: PLATFORM })),
{
program: "/usr/local/bin/droid",
args: ["exec", "--output-format", "acp-daemon"],
env: { DROID_DISABLE_AUTO_UPDATE: "true" },
},
);
const viaNpx = await Effect.runPromise(resolveLaunch(entry, { which: installed("npx"), platform: PLATFORM }));
assert.deepEqual(viaNpx.args, ["-y", "droid@0.228.0", "exec", "--output-format", "acp-daemon"]);
assert.equal(viaNpx.program, "/usr/local/bin/npx");
await assert.rejects(
Effect.runPromise(resolveLaunch(entry, { which: installed(), platform: PLATFORM })),
/install `droid`, or install npx/,
);
// uvx runs the package without `-y`.
const uv: import("./registry.ts").Entry = {
...entry,
source: {
kind: "registry",
distribution: { binary: {}, npx: null, uvx: { package: "fast-agent-acp==0.10.1", args: ["-x"], env: {} } },
},
};
assert.deepEqual((await Effect.runPromise(resolveLaunch(uv, { which: installed("uvx"), platform: PLATFORM }))).args, [
"fast-agent-acp==0.10.1",
"-x",
]);
});
test("custom entries resolve their command", async () => {
const [mine] = assemble(
{ agents: [] },
parseCustom([{ id: "mine", name: "Mine", command: "sh", args: ["-c"] }]),
).filter((entry) => entry.id === "mine");
assert.equal(agentIdOf(mine.id), "acp:mine");
assert.deepEqual(
(await Effect.runPromise(resolveLaunch(mine, { which: installed("sh"), platform: PLATFORM }))).args,
["-c"],
);
await assert.rejects(
Effect.runPromise(resolveLaunch(mine, { which: installed(), platform: PLATFORM })),
/from your custom ACP agents/,
);
});
// Oh My Pi speaks ACP (`omp acp`) but is not in the registry, so the plugin
// lists it itself; the user's own entry still wins.
test("known agents are served and a custom entry replaces them", async () => {
const omp = assemble({ agents: [] }, []).find((entry) => entry.id === "omp");
assert.ok(omp);
assert.equal(omp.source.kind, "custom");
if (omp.source.kind !== "custom") assert.fail("known agent source");
assert.equal(agentIdOf(omp.id), "acp:omp");
assert.equal(omp.name, "Oh My Pi");
assert.equal(omp.source.command, "omp");
assert.deepEqual(omp.source.args, ["acp"]);
await assert.rejects(
Effect.runPromise(resolveLaunch(omp, { which: installed(), platform: PLATFORM })),
/brew install omp/,
"a missing binary says how to get it",
);
const custom = parseCustom([{ id: "omp", name: "My omp", command: "/opt/omp/bin/omp", args: ["acp", "--verbose"] }]);
const entries = assemble({ agents: [] }, custom).filter((entry) => entry.id === "omp");
assert.equal(entries.length, 1, "one entry per id");
assert.equal(entries[0].name, "My omp");
});
test("custom entries are checked", () => {
const parsed = parseCustom([
{
id: "ok",
command: "agent",
env: { A: "1", B: 2 },
directories: ["/x", 3],
mcpServers: [{ name: "m", command: "m" }, "bad"],
},
{ id: "", command: "x" },
{ id: "no-command" },
"junk",
]);
assert.deepEqual(parsed, [
{
id: "ok",
name: null,
command: "agent",
args: [],
env: { A: "1" },
directories: ["/x"],
mcpServers: [{ name: "m", command: "m" }],
},
]);
assert.deepEqual(parseCustom({ not: "a list" }), []);
assert.deepEqual(parseCustom(null), []);
});
test("custom agents come from custom.json in the data folder, and storage overrides it", async () => {
const files: Record<string, string> = {
"/data/acp/custom.json": JSON.stringify([
{ id: "mine", name: "Mine", command: "my-agent", args: ["acp"] },
{ id: "omp", command: "/opt/omp", args: ["acp"] },
]),
};
const fs = {
read: async (path: string) => (path in files ? files[path] : Promise.reject(new Error(`${path}: not found`))),
};
const api = fakeApi({
fs,
onHost: memoryStorage({ custom: [{ id: "mine", name: "Mine (settings)", command: "other" }] }),
});
api.paths = { data: "/data/acp/" };
const custom = await run(api, loadCustom());
assert.deepEqual(
custom.map((entry) => [entry.id, entry.name, entry.command]),
[
["omp", null, "/opt/omp"],
["mine", "Mine (settings)", "other"],
],
);
// A file entry replaces the known agent of the same id.
const overridden = assemble({ agents: [] }, custom).find((entry) => entry.id === "omp");
assert.ok(overridden && overridden.source.kind === "custom");
assert.equal(overridden.source.command, "/opt/omp");
// No file, and a file that is not JSON: nothing from it.
const none = fakeApi({ onHost: memoryStorage() });
none.paths = { data: "/data/acp" };
assert.deepEqual(await run(none, loadCustom()), []);
const broken = fakeApi({ fs: { read: async () => "{ not json" }, onHost: memoryStorage() });
broken.paths = { data: "/data/acp" };
assert.deepEqual(await run(broken, loadCustom()), []);
});
test("the Rust plugin's custom agents become custom.json once", async () => {
const files: Record<string, string> = { "/data/acp/legacy/custom.json": '[{ "id": "old", "command": "old-agent" }]' };
const fs = {
read: async (path: string) => (path in files ? files[path] : Promise.reject(new Error(`${path}: not found`))),
write: async (path: string, text: string | Uint8Array) => {
files[path] = String(text);
},
};
const storage = memoryStorage();
const api = fakeApi({ fs, onHost: storage });
api.paths = { data: "/data/acp" };
assert.equal(await run(api, adoptLegacy()), true);
assert.equal(files["/data/acp/custom.json"], files["/data/acp/legacy/custom.json"]);
assert.deepEqual(
(await run(api, loadCustom())).map((entry) => entry.id),
["old"],
);
// Done once: an entry the user deletes afterwards does not come back.
files["/data/acp/custom.json"] = "[]";
assert.equal(await run(api, adoptLegacy()), false);
assert.equal(files["/data/acp/custom.json"], "[]");
// A custom.json the user already wrote is kept.
const kept: Record<string, string> = {
"/data/acp/legacy/custom.json": '[{ "id": "old", "command": "x" }]',
"/data/acp/custom.json": "[]",
};
const other = fakeApi({
fs: {
read: async (path: string) => (path in kept ? kept[path] : Promise.reject(new Error("missing"))),
write: async () => assert.fail("no write"),
},
onHost: memoryStorage(),
});
other.paths = { data: "/data/acp" };
assert.equal(await run(other, adoptLegacy()), false);
// A refused read leaves the mark unset, so a later start tries again.
const refusedApi = fakeApi({ onHost: memoryStorage() });
refusedApi.paths = { data: "/data/acp" };
assert.equal(await run(refusedApi, adoptLegacy()), false);
assert.equal(
refusedApi.hostCalls.some((call) => call.method === "host/storage.set"),
false,
);
});
test("a registry is read leniently", () => {
assert.equal(parseRegistry("not json"), null);
assert.equal(parseRegistry({ version: "1" }), null);
const registry = parseRegistry(
JSON.stringify({
agents: [
{ id: "a", name: "A", distribution: { binary: { [PLATFORM]: { cmd: "./a", args: null } }, npx: { nope: 1 } } },
],
}),
);
assert.ok(registry);
assert.deepEqual(registry.agents[0].distribution, {
binary: { [PLATFORM]: { archive: "", cmd: "./a", args: [], env: {} } },
npx: null,
uvx: null,
});
});
test("a fresh cache is used as it is, a stale one is used and refreshed, none waits for the network", async () => {
const cached = { agents: [{ id: "gemini", name: "Gemini" }] };
const now = 1_000_000_000;
const fresh = fakeApi({ onHost: memoryStorage({ registry: { fetchedAt: now - 1000, registry: cached } }) });
assert.deepEqual(await run(fresh, loadRegistry(now)), { registry: parseRegistry(cached), stale: false });
assert.equal(fresh.fetches.length, 0);
const old = fakeApi({
onHost: memoryStorage({ registry: { fetchedAt: now - MAX_CACHE_AGE - 1, registry: cached } }),
});
assert.equal((await run(old, loadRegistry(now))).stale, true);
assert.equal(old.fetches.length, 0, "a stale cache is used at once");
const storage = memoryStorage();
const first = fakeApi({
onHost: storage,
onFetch: (url) => (url === REGISTRY_URL ? { body: { agents: [{ id: "kilo", name: "Kilo" }] } } : { status: 404 }),
});
const loaded = await run(first, loadRegistry(now));
assert.equal(loaded.registry.agents[0].id, "kilo");
assert.equal(z.object({ fetchedAt: z.number() }).parse(storage.values.get("registry")).fetchedAt, now);
const offline = fakeApi({ onHost: memoryStorage(), onFetch: () => new Error("offline") });
assert.deepEqual(await run(offline, loadRegistry(now)), { registry: { agents: [] }, stale: false });
});
test("marks are fetched once, together, and only drawings are kept", async () => {
const storage = memoryStorage();
const svg = '<svg viewBox="0 0 1 1"/>';
const api = fakeApi({
onHost: storage,
onFetch: (url) =>
url.endsWith("a.svg") ? { body: svg } : url.endsWith("b.svg") ? { body: "<html>nope</html>" } : new Error("down"),
});
const entries = [
{ id: "a", iconUrl: "https://cdn.agentclientprotocol.com/a.svg" },
{ id: "b", iconUrl: "https://cdn.agentclientprotocol.com/b.svg" },
{ id: "c", iconUrl: "https://cdn.agentclientprotocol.com/c.svg" },
{ id: "d", iconUrl: null },
];
const icons = {};
await run(api, fetchIcons(entries, icons));
assert.deepEqual(icons, { a: svg });
assert.deepEqual(storage.values.get("icons"), { a: svg });
assert.equal(api.fetches.length, 3);
// Only the missing ones are asked again; `force` reads them all.
await run(api, fetchIcons(entries, icons));
assert.equal(api.fetches.length, 5);
await run(api, fetchIcons(entries, icons, { force: true }));
assert.equal(api.fetches.length, 8);
assert.ok(isSvg(svg) && !isSvg("x".repeat(10)));
});
test("a slow mark does not hold the agents past the bound", async () => {
const api = fakeApi({ onHost: memoryStorage(), onFetch: () => new Promise(() => {}) });
const started = Date.now();
await run(api, fetchIcons([{ id: "a", iconUrl: "https://cdn.agentclientprotocol.com/a.svg" }], {}, { timeout: 50 }));
assert.ok(Date.now() - started < 1000);
});
test("a mark that arrives after the startup bound still fills the live cache", async () => {
let finish: (value: unknown) => void = () => {};
const api = fakeApi({
onHost: memoryStorage(),
onFetch: () =>
new Promise((resolve) => {
finish = resolve;
}),
});
const icons: import("./registry.ts").Icons = {};
await run(
api,
Effect.gen(function* () {
yield* fetchIcons([{ id: "late", iconUrl: "https://cdn.agentclientprotocol.com/late.svg" }], icons, {
timeout: 10,
});
assert.deepEqual(icons, {});
finish({ body: "<svg/>" });
yield* Effect.sleep(20);
assert.deepEqual(icons, { late: "<svg/>" });
}),
);
});Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 94.9 KB | 6 permissions | Listed |
No comments yet.