Enforce shared, client, and server plugin SDK boundaries (#4347)

* fix(plugins): keep React out of the server plugin host

* fix(plugins): align SDK guidance and tests with runtime boundaries

* fix(plugin): loosen the react peer to ~19.1.0

The plugin SDK pins its react peer to exactly 19.1.0, and that pin is
what a downstream `npm i -g @getpaseo/cli` resolves against. So every
user ends up with react 19.1.0 in their global node_modules, where
corporate dependency scanners flag it: the CVE-2025-55182 advisory range
covers 19.1.0 even though the vulnerability itself lives in
react-server-dom-webpack/-parcel/-turbopack, none of which the CLI
installs. The user is then left hand-patching node_modules or
uninstalling Paseo.

Widening the peer to ~19.1.0 lets a downstream install pick the latest
19.1.x patch (19.1.9 today) while this repo stays on 19.1.0, which the
root override still pins.

Keeping the repo itself on 19.1.0 is deliberate. react-native 0.81.5
embeds a 19.1.0 Paper renderer that hard-throws "Incompatible React
versions" on any exact mismatch, and it is reachable from Fabric builds
too: RendererImplementation.js requires the Paper shim unconditionally
in findNodeHandle, unstable_batchedUpdates, sendAccessibilityEvent and
three other entry points, and react-native-gesture-handler re-exports
findNodeHandle. Bumping the app's react is therefore not safe until
react-native moves.

The published CLI tree contains no react-native, so downstream installs
are not subject to that constraint.

Supersedes the earlier 19.1.5/19.1.9 version-bump approach on this
branch, which would have broken the mobile app.

* Enforce example import ownership including client types

* Update Nix dependency hash for plugin peer metadata

* Group plugin APIs under client and server entry trees

* Resolve plugin type dependencies through declarations

* Validate declarations for implicit plugin type imports

* Preserve optional imports and validate declaration references

* Refresh Nix hash for plugin declaration resolver

* Canonicalize plugin paths consistently on Windows

* Normalize plugin ownership paths across resolver boundaries

---------

Co-authored-by: liujin0506 <liujin0506@qq.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Rafał Lindemann
2026-09-07 23:42:28 +02:00
committed by GitHub
co-authored by liujin0506 Mohamed Boudra
parent d779514f10
commit 1f5b6143d6
120 changed files with 1774 additions and 929 deletions
File diff suppressed because it is too large Load Diff
+985
View File
@@ -0,0 +1,985 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { AgentSideConnection, PROTOCOL_VERSION, type Agent } from "@agentclientprotocol/sdk";
import type { ProviderConnection, ProviderEvent, ProviderInput } from "./provider.js";
import { afterEach, describe, expect, it } from "vitest";
import { runAcpProvider, type AcpStreamMessage } from "./acp.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
async function fakeAcp(source: string): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-acp-test-"));
temporaryDirectories.push(directory);
const executable = path.join(directory, "agent.cjs");
await writeFile(executable, source, "utf8");
return executable;
}
async function connect(
executable: string,
capabilities: string[],
transformers?: Parameters<typeof runAcpProvider>[0]["transformers"],
): Promise<{ connection: ProviderConnection; events: ProviderEvent[] }> {
const registration = runAcpProvider({
id: "test-acp",
label: "Test ACP",
command: [process.execPath, executable],
transformers,
});
const connection = await registration.connect({ versions: [1], capabilities });
const events: ProviderEvent[] = [];
connection.onEvent((event) => events.push(event));
return { connection, events };
}
function openInput(
settings: Record<string, string> = {},
): Extract<ProviderInput, { type: "session.open" }> {
return {
type: "session.open",
requestId: "open-1",
sessionId: "session-1",
config: { cwd: "/repo", env: {}, mcpServers: {}, settings, persist: true },
history: "skip",
};
}
async function waitForEvent(
events: ProviderEvent[],
predicate: (event: ProviderEvent) => boolean,
): Promise<ProviderEvent> {
await expect.poll(() => events.find(predicate), { timeout: 3_000 }).toBeTruthy();
return events.find(predicate)!;
}
type AcpRequestMessage = Extract<AcpStreamMessage, { id: string | number | null; method: string }>;
interface ConnectorInstance {
readonly requests: AcpRequestMessage[];
readsCanceled: boolean;
writesClosed: boolean;
closeFromAgent(): void;
request(method: string, params: unknown): Promise<unknown>;
respond(request: AcpRequestMessage, result: unknown): void;
notify(method: string, params: unknown): void;
}
interface ConnectorResponse {
resolve(value: unknown): void;
reject(error: Error): void;
}
interface ConnectorHarnessOptions {
capabilities?: Record<string, unknown>;
configOptions?: unknown[];
handleMessage?(instance: ConnectorInstance, message: AcpStreamMessage): boolean;
handleRequest?(instance: ConnectorInstance, request: AcpRequestMessage): boolean;
}
function countRequests(instance: ConnectorInstance, method: string): number {
return instance.requests.filter((request) => request.method === method).length;
}
function receiveConnectorMessage(
instance: ConnectorInstance,
message: AcpStreamMessage,
options?: ConnectorHarnessOptions,
): void {
if (!("method" in message)) return;
if ("id" in message) instance.requests.push(message as AcpRequestMessage);
if (options?.handleMessage?.(instance, message)) return;
if (!("id" in message)) return;
const request = message as AcpRequestMessage;
if (options?.handleRequest?.(instance, request)) return;
respondToConnectorRequest(instance, request, options);
}
function respondToConnectorRequest(
instance: ConnectorInstance,
request: AcpRequestMessage,
options?: ConnectorHarnessOptions,
): void {
if (request.method === "initialize") {
instance.respond(request, {
protocolVersion: request.params
? (request.params as { protocolVersion: number }).protocolVersion
: PROTOCOL_VERSION,
agentCapabilities: options?.capabilities ?? {},
});
} else if (request.method === "session/new" || request.method === "session/load") {
instance.respond(request, {
sessionId: "connector-session",
modes: null,
configOptions: options?.configOptions ?? [],
});
} else if (request.method === "session/prompt") {
instance.respond(request, { stopReason: "end_turn" });
} else if (request.method === "session/close") {
instance.respond(request, {});
} else if (request.method === "session/set_config_option") {
const params = request.params as { configId: string; value: string | boolean };
instance.respond(request, {
configOptions: (options?.configOptions ?? []).map((option) =>
(option as { id?: string }).id === params.configId
? Object.assign({}, option, { currentValue: params.value })
: option,
),
});
}
}
function connectorHarness(options?: ConnectorHarnessOptions) {
const instances: ConnectorInstance[] = [];
const connector = () => {
let controller!: ReadableStreamDefaultController<AcpStreamMessage>;
let requestSequence = 0;
const responses = new Map<number, ConnectorResponse>();
const instance: ConnectorInstance = {
requests: [],
readsCanceled: false,
writesClosed: false,
closeFromAgent: () => controller.close(),
request: (method, params) => {
const response = new Promise<unknown>((resolve, reject) => {
requestSequence += 1;
responses.set(requestSequence, { resolve, reject });
controller.enqueue({ jsonrpc: "2.0", id: requestSequence, method, params });
});
void response.catch(() => undefined);
return response;
},
respond: (request, result) => controller.enqueue({ jsonrpc: "2.0", id: request.id, result }),
notify: (method, params) => controller.enqueue({ jsonrpc: "2.0", method, params }),
};
instances.push(instance);
const readable = new ReadableStream<AcpStreamMessage>({
start(value) {
controller = value;
},
cancel() {
instance.readsCanceled = true;
},
});
const writable = new WritableStream<AcpStreamMessage>({
write(message) {
if (!("method" in message)) {
if (typeof message.id !== "number") return;
const response = responses.get(message.id);
if (!response) return;
responses.delete(message.id);
if ("error" in message) {
response.reject(
new Error(
message.error.data === undefined
? message.error.message
: `${message.error.message}: ${JSON.stringify(message.error.data)}`,
),
);
} else response.resolve(message.result);
return;
}
receiveConnectorMessage(instance, message, options);
},
close() {
instance.writesClosed = true;
},
abort() {
instance.writesClosed = true;
},
});
return { readable, writable };
};
return { connector, instances };
}
const basicAgent = `const readline = require("node:readline");
const lines = readline.createInterface({ input: process.stdin });
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
lines.on("line", (line) => {
const message = JSON.parse(line);
if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } });
else if (message.method === "session/new") setTimeout(() => send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: [] } }), 150);
else if (message.method === "session/prompt") send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } });
});`;
describe("runAcpProvider", () => {
it("rejects connection cleanly when the ACP executable does not exist", async () => {
const registration = runAcpProvider({
id: "missing-acp",
label: "Missing ACP",
command: [path.join(tmpdir(), `missing-acp-${process.pid}`)],
acpOptions: { startupTimeoutMs: 250 },
});
await expect(
registration.connect({ versions: [1], capabilities: ["prompt.message"] }),
).rejects.toThrow(/ENOENT|spawn/u);
});
it("accepts an official ACP SDK stream connector instead of a command", async () => {
const connector = () => {
const clientToAgent = new TransformStream();
const agentToClient = new TransformStream();
const agent: Agent = {
initialize: async () => ({ protocolVersion: PROTOCOL_VERSION, agentCapabilities: {} }),
newSession: async () => ({ sessionId: "sdk-session", modes: null, configOptions: [] }),
prompt: async () => ({ stopReason: "end_turn" }),
cancel: async () => undefined,
};
const agentConnection = new AgentSideConnection(() => agent, {
writable: agentToClient.writable,
readable: clientToAgent.readable,
});
void agentConnection;
return {
writable: clientToAgent.writable,
readable: agentToClient.readable,
};
};
const registration = runAcpProvider({
id: "sdk-acp",
label: "SDK ACP",
connector,
});
const connection = await registration.connect({
versions: [1],
capabilities: ["prompt.message"],
});
const events: ProviderEvent[] = [];
connection.onEvent((event) => events.push(event));
await connection.send(openInput());
await waitForEvent(events, (event) => event.type === "session.ready");
expect(connection.capabilities).toEqual(["prompt.message"]);
await connection.close();
});
it("maps permission behavior to the first matching ACP option", async () => {
const harness = connectorHarness();
const registration = runAcpProvider({
id: "sdk-acp",
label: "SDK ACP",
connector: harness.connector,
});
const connection = await registration.connect({
versions: [1],
capabilities: ["prompt.message", "permission"],
});
const events: ProviderEvent[] = [];
connection.onEvent((event) => events.push(event));
await connection.send(openInput());
await waitForEvent(events, (event) => event.type === "session.ready");
const allow = harness.instances[1]!.request("session/request_permission", {
sessionId: "connector-session",
toolCall: {
toolCallId: "tool-allow",
title: "Allow tool",
kind: "execute",
status: "pending",
content: [],
rawInput: {},
},
options: [
{ optionId: "allow-always", name: "Always allow", kind: "allow_always" },
{ optionId: "allow-once", name: "Allow once", kind: "allow_once" },
{ optionId: "reject-once", name: "Reject", kind: "reject_once" },
],
});
await waitForEvent(
events,
(event) =>
event.type === "session.permission" && event.request.id === "permission:tool-allow",
);
await connection.send({
type: "session.permission",
sessionId: "session-1",
permissionId: "permission:tool-allow",
response: { behavior: "allow" },
});
await expect(allow).resolves.toEqual({
outcome: { outcome: "selected", optionId: "allow-always" },
});
const deny = harness.instances[1]!.request("session/request_permission", {
sessionId: "connector-session",
toolCall: {
toolCallId: "tool-deny",
title: "Deny tool",
kind: "execute",
status: "pending",
content: [],
rawInput: {},
},
options: [
{ optionId: "allow-once", name: "Allow", kind: "allow_once" },
{ optionId: "reject-always", name: "Always reject", kind: "reject_always" },
{ optionId: "reject-once", name: "Reject once", kind: "reject_once" },
],
});
await waitForEvent(
events,
(event) => event.type === "session.permission" && event.request.id === "permission:tool-deny",
);
await connection.send({
type: "session.permission",
sessionId: "session-1",
permissionId: "permission:tool-deny",
response: { behavior: "deny" },
});
await expect(deny).resolves.toEqual({
outcome: { outcome: "selected", optionId: "reject-always" },
});
await connection.close();
});
it("rejects an explicit ACP permission option with the wrong behavior", async () => {
const harness = connectorHarness();
const registration = runAcpProvider({
id: "sdk-acp",
label: "SDK ACP",
connector: harness.connector,
});
const connection = await registration.connect({
versions: [1],
capabilities: ["prompt.message", "permission"],
});
const events: ProviderEvent[] = [];
connection.onEvent((event) => events.push(event));
await connection.send(openInput());
await waitForEvent(events, (event) => event.type === "session.ready");
const permission = harness.instances[1]!.request("session/request_permission", {
sessionId: "connector-session",
toolCall: {
toolCallId: "tool-explicit",
title: "Explicit tool",
kind: "execute",
status: "pending",
content: [],
rawInput: {},
},
options: [
{ optionId: "allow-once", name: "Allow", kind: "allow_once" },
{ optionId: "reject-once", name: "Reject", kind: "reject_once" },
],
});
await waitForEvent(
events,
(event) =>
event.type === "session.permission" && event.request.id === "permission:tool-explicit",
);
await connection.send({
type: "session.permission",
sessionId: "session-1",
permissionId: "permission:tool-explicit",
response: { behavior: "allow", selectedActionId: "missing" },
});
await expect(
waitForEvent(
events,
(event) =>
event.type === "session.runtime_failed" && event.error.message.includes("'missing'"),
),
).resolves.toBeDefined();
await connection.send({
type: "session.permission",
sessionId: "session-1",
permissionId: "permission:tool-explicit",
response: { behavior: "allow", selectedActionId: "reject-once" },
});
await expect(
waitForEvent(
events,
(event) =>
event.type === "session.runtime_failed" &&
event.error.message.includes("'reject-once'") &&
event.error.message.includes("does not match 'allow' behavior"),
),
).resolves.toBeDefined();
await connection.send({
type: "session.permission",
sessionId: "session-1",
permissionId: "permission:tool-explicit",
response: { behavior: "allow", selectedActionId: "allow-once" },
});
await expect(permission).resolves.toEqual({
outcome: { outcome: "selected", optionId: "allow-once" },
});
await connection.close();
});
it("closes both connector directions for the probe and live session", async () => {
const harness = connectorHarness();
const registration = runAcpProvider({
id: "owned-acp",
label: "Owned ACP",
connector: harness.connector,
});
const connection = await registration.connect({
versions: [1],
capabilities: ["prompt.message"],
});
expect(harness.instances).toHaveLength(1);
expect(harness.instances[0]).toMatchObject({ readsCanceled: true, writesClosed: true });
const events: ProviderEvent[] = [];
connection.onEvent((event) => events.push(event));
await connection.send(openInput());
await waitForEvent(events, (event) => event.type === "session.ready");
await connection.close();
expect(harness.instances).toHaveLength(2);
expect(harness.instances[1]).toMatchObject({ readsCanceled: true, writesClosed: true });
});
it("publishes runtime failure on unexpected connector EOF", async () => {
const harness = connectorHarness();
const registration = runAcpProvider({
id: "eof-acp",
label: "EOF ACP",
connector: harness.connector,
});
const connection = await registration.connect({
versions: [1],
capabilities: ["prompt.message"],
});
const events: ProviderEvent[] = [];
connection.onEvent((event) => events.push(event));
await connection.send(openInput());
await waitForEvent(events, (event) => event.type === "session.ready");
harness.instances[1]!.closeFromAgent();
await expect(
waitForEvent(events, (event) => event.type === "session.runtime_failed"),
).resolves.toMatchObject({
sessionId: "session-1",
error: { message: expect.stringContaining("closed") },
});
await connection.close();
});
it("settles an active auto prompt before admitting its auto replacement", async () => {
const order: string[] = [];
let firstPrompt: AcpRequestMessage | null = null;
let activePrompts = 0;
let maximumActivePrompts = 0;
const harness = connectorHarness({
handleMessage(instance, message) {
if (!("method" in message)) return false;
const request = "id" in message ? (message as AcpRequestMessage) : null;
if (message.method === "session/prompt" && request) {
const promptNumber = firstPrompt ? "second" : "first";
order.push(`prompt:${promptNumber}`);
activePrompts += 1;
maximumActivePrompts = Math.max(maximumActivePrompts, activePrompts);
if (!firstPrompt) {
firstPrompt = request;
} else {
activePrompts -= 1;
instance.respond(request, { stopReason: "end_turn" });
}
return true;
}
if (message.method === "session/cancel") {
order.push("cancel");
activePrompts -= 1;
instance.respond(firstPrompt!, { stopReason: "cancelled" });
return true;
}
return false;
},
});
const registration = runAcpProvider({
id: "overlap-acp",
label: "Overlap ACP",
connector: harness.connector,
});
const connection = await registration.connect({
versions: [1],
capabilities: ["prompt.message"],
});
const events: ProviderEvent[] = [];
connection.onEvent((event) => events.push(event));
await connection.send(openInput());
await waitForEvent(events, (event) => event.type === "session.ready");
await connection.send({
type: "session.prompt",
sessionId: "session-1",
prompt: {
clientMessageId: "first",
delivery: "auto",
input: { type: "message", content: [{ type: "text", text: "first" }] },
},
});
await expect.poll(() => countRequests(harness.instances[1]!, "session/prompt")).toBe(1);
await connection.send({
type: "session.prompt",
sessionId: "session-1",
prompt: {
clientMessageId: "second",
delivery: "auto",
input: { type: "message", content: [{ type: "text", text: "second" }] },
},
});
await waitForEvent(
events,
(event) =>
event.type === "session.turn" &&
event.turnId === "acp:second" &&
event.state === "completed",
);
expect(order).toEqual(["prompt:first", "cancel", "prompt:second"]);
expect(maximumActivePrompts).toBe(1);
const firstTerminal = events.findIndex(
(event) =>
event.type === "session.turn" && event.turnId === "acp:first" && event.state === "canceled",
);
const secondAdmission = events.findIndex(
(event) => event.type === "session.prompt_result" && event.clientMessageId === "second",
);
expect(firstTerminal).toBeGreaterThan(-1);
expect(firstTerminal).toBeLessThan(secondAdmission);
await connection.close();
});
it("serializes configuration mutations", async () => {
let releaseMutation: (() => void) | null = null;
const configOptions = [
{
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: "a",
options: [
{ name: "A", value: "a" },
{ name: "B", value: "b" },
],
},
];
const harness = connectorHarness({
capabilities: { loadSession: true },
configOptions,
handleRequest(instance, request) {
if (request.method !== "session/set_config_option") return false;
const value = (request.params as { value: string }).value;
if (value !== "first") return false;
releaseMutation = () =>
instance.respond(request, {
configOptions: configOptions.map((option) =>
Object.assign({}, option, { currentValue: value }),
),
});
return true;
},
});
const registration = runAcpProvider({
id: "lane-acp",
label: "Lane ACP",
connector: harness.connector,
});
const connection = await registration.connect({
versions: [1],
capabilities: ["prompt.message", "session.configure"],
});
const events: ProviderEvent[] = [];
connection.onEvent((event) => events.push(event));
await connection.send(openInput());
await waitForEvent(events, (event) => event.type === "session.ready");
await connection.send({
type: "session.configure",
requestId: "configure-first",
sessionId: "session-1",
changes: { model: "first" },
});
await connection.send({
type: "session.configure",
requestId: "configure-second",
sessionId: "session-1",
changes: { model: "second" },
});
await expect
.poll(() => countRequests(harness.instances[1]!, "session/set_config_option"))
.toBe(1);
releaseMutation!();
await waitForEvent(
events,
(event) => event.type === "request.completed" && event.requestId === "configure-second",
);
expect(
events
.filter(
(event) => event.type === "request.completed" && event.requestId.startsWith("configure-"),
)
.map((event) => (event as Extract<ProviderEvent, { type: "request.completed" }>).requestId),
).toEqual(["configure-first", "configure-second"]);
await connection.close();
});
it("does not claim exact permission policy, listing, image, or configuration support", async () => {
const executable = await fakeAcp(basicAgent);
const { connection, events } = await connect(executable, [
"prompt.message",
"prompt.image",
"session.configure",
"session.list",
"permission.tool_policy",
]);
expect(connection.capabilities).toEqual(["prompt.message", "session.configure"]);
await connection.send(openInput());
const opened = await waitForEvent(events, (event) => event.type === "session.opened");
expect(opened).toMatchObject({ capabilities: ["prompt.message"] });
await waitForEvent(events, (event) => event.type === "session.ready");
await expect(
connection.send({
type: "session.configure",
requestId: "configure-unsupported",
sessionId: "session-1",
changes: { model: "not-advertised" },
}),
).rejects.toThrow("session.configure");
await connection.close();
});
it("derives optional connection capabilities from ACP initialization", async () => {
const executable = await fakeAcp(`const readline = require("node:readline");
const lines = readline.createInterface({ input: process.stdin });
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
lines.on("line", (line) => {
const message = JSON.parse(line);
if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: {
protocolVersion: message.params.protocolVersion,
agentCapabilities: {
loadSession: true,
promptCapabilities: { image: true },
sessionCapabilities: { list: {} },
},
} });
});`);
const { connection } = await connect(executable, [
"prompt.message",
"prompt.image",
"session.list",
"session.persistence",
]);
expect(connection.capabilities).toEqual([
"prompt.message",
"prompt.image",
"session.list",
"session.persistence",
]);
await connection.close();
});
it("acknowledges admission before session creation completes", async () => {
const executable = await fakeAcp(basicAgent);
const { connection, events } = await connect(executable, ["prompt.message"]);
let ready = false;
const admission = connection.send(openInput()).then(() => {
ready = true;
return undefined;
});
await admission;
expect(ready).toBe(true);
expect(events).toEqual([]);
await waitForEvent(events, (event) => event.type === "session.ready");
await connection.close();
});
it("rejects provider inputs after the ACP connection closes", async () => {
const harness = connectorHarness();
const registration = runAcpProvider({
id: "closed-acp",
label: "Closed ACP",
connector: harness.connector,
});
const connection = await registration.connect({ versions: [1], capabilities: [] });
await connection.close();
await expect(connection.send({ type: "catalog", requestId: "too-late" })).rejects.toThrow(
"ACP provider connection is closed",
);
expect(harness.instances).toHaveLength(1);
});
it("waits for delayed session creation and closes its connector after connection close", async () => {
let delayedNew: AcpRequestMessage | null = null;
const harness = connectorHarness({
handleRequest(_instance, request) {
if (request.method !== "session/new") return false;
delayedNew = request;
return true;
},
});
const registration = runAcpProvider({
id: "delayed-open-acp",
label: "Delayed open ACP",
connector: harness.connector,
});
const connection = await registration.connect({
versions: [1],
capabilities: ["prompt.message"],
});
const events: ProviderEvent[] = [];
connection.onEvent((event) => events.push(event));
await connection.send(openInput());
await expect.poll(() => delayedNew).not.toBeNull();
let closeSettled = false;
const closing = connection.close().then(() => {
closeSettled = true;
return undefined;
});
await Promise.resolve();
expect(closeSettled).toBe(false);
harness.instances[1]!.respond(delayedNew!, {
sessionId: "late-session",
modes: null,
configOptions: [],
});
await closing;
expect(harness.instances[1]).toMatchObject({ readsCanceled: true, writesClosed: true });
expect(events).toEqual([]);
});
it("refuses unsupported steering before sending an ACP prompt", async () => {
const executable = await fakeAcp(basicAgent);
const { connection, events } = await connect(executable, ["prompt.message"]);
await connection.send(openInput());
await waitForEvent(events, (event) => event.type === "session.ready");
await expect(
connection.send({
type: "session.prompt",
sessionId: "session-1",
prompt: {
clientMessageId: "steer-1",
delivery: "steer",
input: { type: "message", content: [{ type: "text", text: "change course" }] },
},
}),
).rejects.toThrow("prompt.steer");
expect(events.some((event) => event.type === "session.prompt_result")).toBe(false);
await connection.close();
});
it("keeps configuration observable state atomic when a later field fails", async () => {
const executable = await fakeAcp(`const readline = require("node:readline");
const lines = readline.createInterface({ input: process.stdin });
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
let model = "a";
const options = () => [
{ id: "model", name: "Model", category: "model", type: "select", currentValue: model, options: [{ name: "A", value: "a" }, { name: "B", value: "b" }] },
{ id: "verbosity", name: "Verbosity", type: "select", currentValue: "quiet", options: [{ name: "Quiet", value: "quiet" }, { name: "Loud", value: "loud" }] },
];
lines.on("line", (line) => {
const message = JSON.parse(line);
if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } });
else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: options() } });
else if (message.method === "session/set_config_option" && message.params.configId === "verbosity") send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "verbosity rejected" } });
else if (message.method === "session/set_config_option") { model = message.params.value; send({ jsonrpc: "2.0", id: message.id, result: { configOptions: options() } }); }
});`);
const { connection, events } = await connect(executable, [
"prompt.message",
"session.configure",
]);
await connection.send(openInput());
await waitForEvent(events, (event) => event.type === "session.ready");
await connection.send({
type: "session.configure",
requestId: "configure-1",
sessionId: "session-1",
changes: { model: "b", settings: { verbosity: "loud" } },
});
await waitForEvent(
events,
(event) => event.type === "request.failed" && event.requestId === "configure-1",
);
const configs = events.filter((event) => event.type === "session.config");
expect(configs).toHaveLength(1);
expect(configs[0]).toMatchObject({ config: { model: "a" } });
await connection.close();
});
it("commits transformer config notifications once and discards them on rollback", async () => {
const executable = await fakeAcp(`const readline = require("node:readline");
const lines = readline.createInterface({ input: process.stdin });
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
let model = "a";
const options = () => [
{ id: "model", name: "Model", category: "model", type: "select", currentValue: model, options: [{ name: "A", value: "a" }, { name: "B", value: "b" }] },
{ id: "late", name: "Late", type: "select", currentValue: "ok", options: [{ name: "Ok", value: "ok" }, { name: "Fail", value: "fail" }] },
];
lines.on("line", (line) => {
const message = JSON.parse(line);
if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } });
else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: options() } });
else if (message.method === "session/set_config_option" && message.params.configId === "late") send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "late rejected" } });
else if (message.method === "session/set_config_option") {
model = message.params.value;
send({ jsonrpc: "2.0", method: "vendor/config", params: { model } });
send({ jsonrpc: "2.0", id: message.id, result: { configOptions: options() } });
}
});`);
const transformer = {
notification(notification: { method: string; params: unknown }) {
if (notification.method !== "vendor/config") return null;
const params = notification.params as { model: string };
return {
type: "config" as const,
config: {
model: params.model,
models: [{ id: params.model, label: `Transformer ${params.model}` }],
modes: [],
thinkingOptions: [],
settings: [],
},
};
},
};
const { connection, events } = await connect(
executable,
["prompt.message", "session.configure"],
[transformer],
);
await connection.send(openInput());
await waitForEvent(events, (event) => event.type === "session.ready");
const initialConfigCount = events.filter((event) => event.type === "session.config").length;
await connection.send({
type: "session.configure",
requestId: "transformer-success",
sessionId: "session-1",
changes: { model: "b" },
});
await waitForEvent(
events,
(event) => event.type === "request.completed" && event.requestId === "transformer-success",
);
let configs = events.filter((event) => event.type === "session.config");
expect(configs).toHaveLength(initialConfigCount + 1);
expect(configs.at(-1)).toMatchObject({
config: { model: "b", models: [{ label: "Transformer b" }] },
});
await connection.send({
type: "session.configure",
requestId: "transformer-failure",
sessionId: "session-1",
changes: { model: "a", settings: { late: "fail" } },
});
await waitForEvent(
events,
(event) => event.type === "request.failed" && event.requestId === "transformer-failure",
);
configs = events.filter((event) => event.type === "session.config");
expect(configs).toHaveLength(initialConfigCount + 1);
expect(configs.at(-1)).toMatchObject({ config: { model: "b" } });
await connection.close();
});
it("gives discovery transformers the discovered session configuration facade", async () => {
const executable = await fakeAcp(`const readline = require("node:readline");
const lines = readline.createInterface({ input: process.stdin });
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
lines.on("line", (line) => {
const message = JSON.parse(line);
if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } });
else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "catalog-native", modes: null, configOptions: [{ id: "flavor", name: "Flavor", type: "select", currentValue: "mint", options: [{ name: "Mint", value: "mint" }] }] } });
});`);
let discovered: Readonly<Record<string, unknown>> | null = null;
const { connection, events } = await connect(
executable,
[],
[
{
async discover(catalog, context) {
discovered = await context.config.read();
return catalog;
},
},
],
);
await connection.send({ type: "catalog", requestId: "catalog-1" });
await waitForEvent(events, (event) => event.type === "catalog");
expect(discovered).toEqual({ flavor: "mint" });
await connection.close();
});
it("drains heavy ACP stderr while initializing", async () => {
const executable = await fakeAcp(
`process.stderr.write("x".repeat(2 * 1024 * 1024));\n${basicAgent}`,
);
const { connection, events } = await connect(executable, ["prompt.message"]);
await connection.send(openInput());
await waitForEvent(events, (event) => event.type === "session.ready");
await connection.close();
});
it("publishes runtime failure when the ACP process dies", async () => {
const executable = await fakeAcp(`const readline = require("node:readline");
const lines = readline.createInterface({ input: process.stdin });
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
lines.on("line", (line) => {
const message = JSON.parse(line);
if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } });
else if (message.method === "session/new") {
send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: [] } });
setTimeout(() => process.exit(17), 20);
}
});`);
const { connection, events } = await connect(executable, ["prompt.message"]);
await connection.send(openInput());
await waitForEvent(events, (event) => event.type === "session.ready");
const failure = await waitForEvent(events, (event) => event.type === "session.runtime_failed");
expect(failure).toMatchObject({
sessionId: "session-1",
error: { message: expect.stringContaining("17") },
});
await connection.close();
});
it("escalates shutdown when an ACP process ignores SIGTERM", async () => {
const executable = await fakeAcp(`const readline = require("node:readline");
process.on("SIGTERM", () => {});
const lines = readline.createInterface({ input: process.stdin });
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
lines.on("line", (line) => {
const message = JSON.parse(line);
if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } });
else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: [] } });
});`);
const { connection, events } = await connect(executable, ["prompt.message"]);
await connection.send(openInput());
await waitForEvent(events, (event) => event.type === "session.ready");
const startedAt = Date.now();
await connection.close();
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(900);
expect(Date.now() - startedAt).toBeLessThan(2_500);
});
});
+122
View File
@@ -0,0 +1,122 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type {
ProviderCatalog,
ProviderCommand,
ProviderConfigState,
ProviderNotice,
ProviderRegistration,
ProviderTimelineItem,
} from "./provider.js";
import { createAcpProviderConnection } from "./acp-internal/connection.js";
interface RunAcpProviderBaseOptions {
id: string;
label: string;
description?: string;
icon?: string;
acpOptions?: AcpOptions;
transformers?: readonly AcpTransformer[];
}
export type RunAcpProviderOptions = RunAcpProviderBaseOptions &
(
| { command: readonly [string, ...string[]]; connector?: never }
| { connector: AcpConnector; command?: never }
);
export type AcpStreamMessage =
| { jsonrpc: "2.0"; id: string | number | null; method: string; params?: unknown }
| { jsonrpc: "2.0"; method: string; params?: unknown }
| { jsonrpc: "2.0"; id: string | number | null; result: unknown }
| {
jsonrpc: "2.0";
id: string | number | null;
error: { code: number; message: string; data?: unknown };
};
export interface AcpStream {
writable: WritableStream<AcpStreamMessage>;
readable: ReadableStream<AcpStreamMessage>;
}
export type AcpConnector = () => AcpStream | Promise<AcpStream>;
export interface AcpOptions {
protocol?: "auto" | "v1";
startupTimeoutMs?: number;
waitForInitialCommands?: boolean;
initialCommandsTimeoutMs?: number;
}
export interface AcpContext {
sessionId: string;
}
export interface AcpConfigAccess {
read(): Promise<Readonly<Record<string, JsonValue>>>;
set(id: string, value: JsonValue): Promise<void>;
}
export interface AcpDiscoveryContext extends AcpContext {
config: AcpConfigAccess;
}
export interface AcpConfigureContext extends AcpContext {
config: AcpConfigAccess;
}
export type AcpConfigChange =
| { target: "model"; value: string | null }
| { target: "mode"; value: string | null }
| { target: "thinking"; value: string | null }
| { target: "setting"; id: string; value: JsonValue };
export interface AcpToolCallSnapshot {
id: string;
name?: string;
title: string;
kind?: string;
status: "pending" | "in_progress" | "completed" | "failed";
input: JsonValue;
output: JsonValue;
locations: readonly string[];
}
export type AcpVendorUpdate =
| { type: "commands"; commands: readonly ProviderCommand[] }
| { type: "config"; config: ProviderConfigState }
| { type: "timeline"; item: ProviderTimelineItem }
| { type: "notice"; notice: ProviderNotice };
export interface AcpTransformer {
discover?(
catalog: ProviderCatalog,
context: AcpDiscoveryContext,
): Promise<ProviderCatalog> | ProviderCatalog;
configure?(change: AcpConfigChange, context: AcpConfigureContext): Promise<"handled" | "pass">;
notification?(
notification: { method: string; params: JsonValue },
context: AcpContext,
): AcpVendorUpdate | readonly AcpVendorUpdate[] | null;
toolCall?(toolCall: AcpToolCallSnapshot, context: AcpContext): AcpToolCallSnapshot;
}
export function runAcpProvider(options: RunAcpProviderOptions): ProviderRegistration {
const hasCommand = options.command !== undefined;
const hasConnector = options.connector !== undefined;
if (hasCommand === hasConnector) {
throw new Error("ACP provider requires exactly one command or SDK connector");
}
if (options.command && (options.command.length === 0 || options.command[0].trim().length === 0)) {
throw new Error("ACP provider command must contain an executable");
}
return {
id: options.id,
label: options.label,
description: options.description,
icon: options.icon,
connect(request) {
return createAcpProviderConnection(options, request);
},
};
}
+26
View File
@@ -0,0 +1,26 @@
import type { PaseoApi } from "@getpaseo/client";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginRpcContract } from "../rpc.js";
import type { PluginCleanup } from "../contracts.js";
import type { ProviderRegistration } from "./provider.js";
import type { PluginLifecycleRegistration } from "./lifecycle.js";
export interface PluginHandlerContext {
paseo: PaseoApi;
}
export interface PluginServerContext extends PluginLifecycleRegistration {
registerSettings<Schema extends ZodType>(
definition: import("../settings.js").SettingsDefinition<Schema>,
): void;
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
handler: (
input: ZodOutput<InputSchema>,
context: PluginHandlerContext,
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
): void;
registerProvider(provider: ProviderRegistration): void;
}
export type PluginServerContribution = (server: PluginServerContext) => PluginCleanup;
+15
View File
@@ -0,0 +1,15 @@
export type {
PluginHandlerContext,
PluginServerContext,
PluginServerContribution,
} from "./contracts.js";
export type {
PluginHookContext,
PluginHookWorkspace,
PluginHookAgent,
PluginSessionOpenRequest,
PluginTurnOutcome,
PluginLifecycleEvents,
PluginBeforeRequests,
PluginLifecycleRegistration,
} from "./lifecycle.js";
+88
View File
@@ -0,0 +1,88 @@
import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentTimelineItem,
AgentSessionConfig,
} from "@getpaseo/protocol/agent-types";
import type { PaseoApi } from "@getpaseo/client";
import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages";
export interface PluginHookContext {
paseo: PaseoApi;
signal: AbortSignal;
}
export interface PluginHookWorkspace {
id: string;
projectId: string;
cwd: string;
name: string | null;
archivedAt: string | null;
}
export interface PluginHookAgent {
id: string;
workspaceId: string | null;
parentAgentId: string | null;
provider: string;
cwd: string;
title: string | null;
}
export interface PluginSessionOpenRequest {
agentId: string;
workspaceId: string | null;
provider: string;
cwd: string;
reason: "create" | "resume" | "refresh" | "import";
purpose: "interactive" | "history";
env: Record<string, string>;
}
export type PluginTurnOutcome =
| { kind: "completed" }
| { kind: "failed"; error: { message: string; code?: string } }
| { kind: "canceled"; reason: string };
export interface PluginLifecycleEvents {
"agent.turn_started": { agent: PluginHookAgent; turnId: string | null };
"agent.turn_ended": {
agent: PluginHookAgent;
turnId: string | null;
outcome: PluginTurnOutcome;
timeline: readonly AgentTimelineItem[];
};
"agent.permission_requested": { agent: PluginHookAgent; request: AgentPermissionRequest };
"agent.permission_resolved": {
agent: PluginHookAgent;
requestId: string;
resolution: AgentPermissionResponse;
};
"agent.archived": { agent: PluginHookAgent; archivedAt: string };
"agent.created": { agent: PluginHookAgent };
"workspace.created": { workspace: PluginHookWorkspace };
"workspace.archived": { workspace: PluginHookWorkspace };
}
export interface PluginBeforeRequests {
"agent.create": { config: AgentSessionConfig; env?: Record<string, string> };
"agent.session_open": PluginSessionOpenRequest;
"workspace.create": Omit<WorkspaceCreateRequest, "type" | "requestId">;
}
export interface PluginLifecycleRegistration {
on<Name extends keyof PluginLifecycleEvents>(
name: Name,
handler: (
event: PluginLifecycleEvents[Name],
context: PluginHookContext,
) => void | Promise<void>,
): () => void;
before<Name extends keyof PluginBeforeRequests>(
name: Name,
handler: (
input: { request: PluginBeforeRequests[Name] },
context: PluginHookContext,
) => PluginBeforeRequests[Name] | void | Promise<PluginBeforeRequests[Name] | void>,
): () => void;
}
File diff suppressed because it is too large Load Diff