mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 10:57:07 +00:00
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:
co-authored by
liujin0506
Mohamed Boudra
parent
d779514f10
commit
1f5b6143d6
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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
Reference in New Issue
Block a user