Files
paseo/packages/plugin/src/boundaries.test.ts
T
Mohamed Boudra f665cc697c feat(plugins): reach a running agent's composer by reference
useComposers(selector) in any plugin component and composers(selector) on every command context return the composer of a running agent, named by id or by focus, whether or not its tab is open.

A running agent now has one store-backed composer model apart from any mounted tab. The mounted composer plugs its cursor-aware insert in while mounted; otherwise insertText and setPrompt write the agent's saved prompt. model and thinking resolve through the provider catalog with the picker's own function, so a read equals what the picker shows.

The host reads one ComposerModel from context and no longer knows whether it is in an agent, a draft or New workspace. The daemon rejects an unlisted model or thinking option for a running agent, replacing the app-side guard.
2026-11-05 23:25:42 +01:00

522 lines
24 KiB
TypeScript

import { existsSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { isBuiltin } from "node:module";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { describe, expect, it } from "vitest";
import * as server from "./server/index.js";
const sourceDirectory = path.dirname(fileURLToPath(import.meta.url));
const entries = {
".": "shared",
"./server": "server",
"./server/provider": "server",
"./server/usage": "server",
"./server/acp": "server",
"./internal/lifecycle": "server",
"./client": "client",
"./client/host": "client",
"./client/ui": "client",
} as const;
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
// Compatibility entrypoints inherit the boundary of their one re-export target.
const compatibilityEntries = Object.keys(manifest.exports).filter((entry) => {
const file = path.resolve(sourceDirectory, "..", manifest.exports[entry].source);
return readFileSync(file, "utf8").startsWith("// COMPAT(");
});
const uiDependency = /^(react(?:-dom|-native)?|use-sync-external-store)(\/|$)/;
const serverModule = /^server\//;
const clientModule = /^client\//;
function resolveLocal(importer: string, specifier: string): string {
const base = path.resolve(path.dirname(importer), specifier.replace(/\.js$/, ""));
const candidates = specifier.endsWith(".json") ? [base] : [base + ".ts", base + ".tsx"];
const result = candidates.find(existsSync);
if (!result) throw new Error(`Cannot resolve ${specifier} from ${importer}`);
return result;
}
function boundaryViolations(entry: string, runtime: "shared" | "server" | "client"): string[] {
const pending = [entry];
const visited = new Set<string>();
const violations: string[] = [];
while (pending.length) {
const file = pending.pop()!;
if (visited.has(file)) continue;
visited.add(file);
// Includes type imports, re-exports, import types, and literal dynamic imports.
const imports = ts.preProcessFile(readFileSync(file, "utf8"), true, true).importedFiles;
for (const { fileName: specifier } of imports) {
const label = `${path.relative(sourceDirectory, file)} -> ${specifier}`;
if (specifier.startsWith(".")) {
const local = resolveLocal(file, specifier);
const module = path
.relative(sourceDirectory, local)
.replace(/\\/g, "/")
.replace(/\.tsx?$/, ".js");
if (runtime !== "client" && clientModule.test(module)) violations.push(label);
if (runtime !== "server" && serverModule.test(module)) violations.push(label);
pending.push(local);
} else if (runtime === "shared") {
if (specifier !== "zod" && specifier !== "@getpaseo/protocol/agent-types")
violations.push(label);
} else if (runtime === "server") {
if (uiDependency.test(specifier) || /^@getpaseo\/plugin\/client(\/|$)/.test(specifier))
violations.push(label);
} else if (isBuiltin(specifier) || /^@getpaseo\/plugin\/server(\/|$)/.test(specifier)) {
violations.push(label);
}
}
}
return violations;
}
function typeErrors(code: string): string[] {
const configPath = path.resolve(sourceDirectory, "../tsconfig.examples.json");
const { config } = ts.readConfigFile(configPath, ts.sys.readFile);
const { options } = ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(configPath));
const file = path.resolve(
sourceDirectory,
"../../../plugin-examples/skill-example/client/example.tsx",
);
const host = ts.createCompilerHost(options);
const getSourceFile = host.getSourceFile;
host.getSourceFile = (name, language, ...rest) =>
path.resolve(name) === file
? ts.createSourceFile(name, code, language)
: getSourceFile.call(host, name, language, ...rest);
host.fileExists = (name) => path.resolve(name) === file || ts.sys.fileExists(name);
const program = ts.createProgram([file], options, host);
return ts
.getPreEmitDiagnostics(program)
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
}
describe("plugin SDK import boundaries", () => {
it("publishes process capabilities without their Windows implementation helpers", () => {
expect(Object.keys(server).sort()).toEqual([
"defineAgentTool",
"execCommand",
"spawnProcess",
"terminateProcess",
]);
});
it("classifies every published entry", () => {
expect(Object.keys(manifest.exports).sort()).toEqual(
[...Object.keys(entries), ...compatibilityEntries].sort(),
);
});
it.each(compatibilityEntries)("%s respects the client boundary", (entry) => {
expect(
boundaryViolations(
path.resolve(sourceDirectory, "..", manifest.exports[entry].source),
"client",
),
).toEqual([]);
});
it("typechecks a 0.12 reveal-hook consumer without exposing the hook on current UI", () => {
expect(
typeErrors(`
import { useRevealedText, Icon } from "@getpaseo/plugin/client/react-native";
import { StreamedText } from "@getpaseo/plugin/client/ui";
// @ts-expect-error the legacy hook is not part of the current UI API
import { useRevealedText as currentHook } from "@getpaseo/plugin/client/ui";
import { Text } from "react-native";
function LegacyRow({ text, phase }: { text: string; phase: "streaming" | "complete" }) {
const revealed: string = useRevealedText(text, phase);
return <Text>{revealed}</Text>;
}
`),
).toEqual([]);
});
it.each(Object.entries(entries))(
"%s respects its %s boundary, including type dependencies",
(specifier, runtime) => {
const file = path.resolve(sourceDirectory, "..", manifest.exports[specifier].source);
expect(boundaryViolations(file, runtime)).toEqual([]);
},
);
});
describe("plugin example import boundaries", () => {
const examples = path.resolve(sourceDirectory, "../../../plugin-examples");
const files = readdirSync(examples, { recursive: true }).filter(
(file) => !file.split(path.sep).includes("node_modules") && /(?<!\.test)\.tsx?$/.test(file),
);
const owner = (file: string) => {
const [, directory] = path.relative(examples, file).split(path.sep);
if (directory?.startsWith("index.client.")) return "client";
if (directory?.startsWith("index.server.")) return "server";
return directory;
};
it.each(files)("%s respects runtime ownership, including type imports", (relative) => {
const file = path.join(examples, relative);
const runtime = owner(file);
expect(["shared", "client", "server"]).toContain(runtime);
const violations: string[] = [];
for (const { fileName: specifier } of ts.preProcessFile(readFileSync(file, "utf8"), true, true)
.importedFiles) {
if (specifier.startsWith(".")) {
const imported = owner(resolveLocal(file, specifier));
if (imported !== "shared" && imported !== runtime) violations.push(specifier);
} else if (specifier.startsWith("@getpaseo/plugin")) {
const entry = specifier.replace("@getpaseo/plugin", ".") as keyof typeof entries;
if (
entry === "./client/host" ||
entry.startsWith("./internal/") ||
!(entry in entries) ||
(entries[entry] !== "shared" && entries[entry] !== runtime)
)
violations.push(specifier);
} else if (
(runtime !== "client" && uiDependency.test(specifier)) ||
(runtime !== "server" && isBuiltin(specifier))
) {
violations.push(specifier);
}
}
expect(violations).toEqual([]);
});
});
describe("plugin panel SDK boundary", () => {
it("accepts uniform params and persisted tab state alongside 0.12 panels", () => {
expect(
typeErrors(`
import { type PluginClientContext, type PluginFilePanelProps, type PluginWorkspacePanelProps, type PluginAgentPanelProps, type PluginTab, useTabState } from "@getpaseo/plugin/client";
import { z } from "zod";
function FilePanel({ params, revision, tab, file }: PluginFilePanelProps) {
const [state, setState] = useTabState(tab, z.object({ view: z.enum(["preview", "source"]) }), { view: "preview" });
setState({ view: "source" });
tab.setState({ nested: [true, null, 3] });
// @ts-expect-error only JSON state crosses the host boundary
tab.setState(() => {});
// @ts-expect-error params belong to the opener
params.path = "other";
return null;
}
function OldWorkspace({ context, workspaceId }: PluginWorkspacePanelProps) { return null; }
function OldAgent({ context, workspaceId, agentId }: PluginAgentPanelProps) { return null; }
function Agent({ params }: PluginAgentPanelProps) { params.agentId.toUpperCase(); return null; }
function contribute(client: PluginClientContext) {
client.addWorkspacePanel({ id: "old-workspace", title: "Old", icon: "File", context: "workspace", Component: OldWorkspace });
client.addWorkspacePanel({ id: "old-agent", title: "Old", icon: "File", context: "agent", Component: OldAgent });
client.addWorkspacePanel({ id: "workspace", title: "Workspace", icon: "File", Component: OldWorkspace });
client.addAgentPanel({ id: "agent", title: "Agent", icon: "File", Component: Agent });
}
`),
).toEqual([]);
});
});
describe("plugin composer SDK boundary", () => {
it("lets a composer item read and change the composer through typed commands and controls", () => {
expect(
typeErrors(`
import { useComposer, type PluginClientContext } from "@getpaseo/plugin/client";
import { Action, Select, Toggle } from "@getpaseo/plugin/client/ui";
function Item() {
const composer = useComposer();
const context: "agent" | "draft" | "new-workspace" = composer.context;
const provider: string | null = composer.agent.provider;
const flag: boolean = composer.agent.metadata.flag === true;
// @ts-expect-error reads are not writable
composer.agent.model = "other";
composer.setProvider("codex").then(() => composer.setModel("gpt"));
composer.setOptions({ effort: null });
composer.setMetadata({ flag: true, stale: null });
composer.insertText("text");
composer.addAttachment({ sourceId: "issues", item: { id: "1", identifier: "1", title: "t", url: "https://e.com", text: "t", resourceType: "issue" } });
// @ts-expect-error commands take JSON values
composer.setMetadata({ fn: () => {} });
return (
<>
<Toggle title="Flag" icon="Flag" value={flag} onValueChange={({ value }: { value: boolean }) => composer.setMetadata({ flag: value })} />
<Select title="Size" icon="Gauge" value="a" options={[{ value: "a", label: "A" }, { value: "b", label: "B" }]} onValueChange={({ value }: { value: "a" | "b" }) => {}} />
<Action title="Insert" icon="Plus" onPress={() => composer.setPrompt("")} />
</>
);
}
export default function contribute(client: PluginClientContext) {
client.addComposerItem({ id: "item", Component: Item });
return () => {};
}
`),
).toEqual([]);
});
});
describe("plugin composers by reference SDK boundary", () => {
it("reaches a running agent's composer from any component and from every command context", () => {
expect(
typeErrors(`
import { useComposers, type PluginClientContext } from "@getpaseo/plugin/client";
function Button() {
const [focused] = useComposers({ focused: true });
const byId = useComposers({ agentId: "agent-1" });
if (!focused) return null;
const first: string = byId[0]?.prompt ?? "";
focused.insertText("text");
focused.setMetadata({ flag: true });
// @ts-expect-error a selector names an agent or the focused agent
useComposers({ active: true });
// @ts-expect-error focused is true or absent
useComposers({ focused: false });
return null;
}
export default function contribute(client: PluginClientContext) {
client.addWorkspacePanel({ id: "panel", title: "P", icon: "File", context: "workspace", Component: Button });
client.addCommandCenterItem({
id: "mark",
title: "Mark",
icon: "Flag",
context: "agent",
async onSelect(context) {
for (const composer of context.composers({ agentId: context.agent.id })) {
await composer.setMetadata({ review: true });
}
},
});
client.addCommandCenterItem({
id: "global",
title: "Global",
icon: "Flag",
context: "global",
onSelect: ({ composers }) => composers({ focused: true })[0]?.insertText("x"),
});
return () => {};
}
`),
).toEqual([]);
});
});
describe("plugin New workspace SDK boundary", () => {
it("lets a New workspace item read the setup and change it through typed commands and a Combobox", () => {
expect(
typeErrors(`
import { useNewWorkspace, type PluginClientContext } from "@getpaseo/plugin/client";
import { Combobox } from "@getpaseo/plugin/client/ui";
interface Issue { id: string; label: string; branch: string }
function Item() {
const newWorkspace = useNewWorkspace();
const isolation: "local" | "worktree" = newWorkspace.isolation;
const branch: string | null = newWorkspace.branch;
const project: string | undefined = newWorkspace.project?.name;
const host: string = newWorkspace.host.label;
const base = newWorkspace.baseRef;
if (base?.type === "branch") { const name: string = base.name; }
// @ts-expect-error project is read-only
newWorkspace.project = null;
// @ts-expect-error host is read-only
newWorkspace.setHost("other");
// @ts-expect-error metadata reads are not writable
newWorkspace.metadata.key = "x";
newWorkspace.setIsolation("worktree").then(() => newWorkspace.setBaseRef({ type: "branch", name: "main" }));
newWorkspace.setBranch(null);
newWorkspace.setMetadata({ issue: "1", stale: null });
// @ts-expect-error commands take JSON values
newWorkspace.setMetadata({ fn: () => {} });
return (
<Combobox<Issue>
title="Issue"
icon="CircleDot"
value={{ id: "1", label: "One" }}
search={async ({ query }: { query: string }) => [{ id: query, label: query, branch: "b" }]}
onSelect={({ item }) => newWorkspace.setBranch(item.branch)}
/>
);
}
export default function contribute(client: PluginClientContext) {
client.addNewWorkspaceItem({ id: "item", Component: Item });
return () => {};
}
`),
).toEqual([]);
});
});
describe("plugin agent tools SDK boundary", () => {
it("lets a session hook attach typed tools without exposing their transport", () => {
expect(
typeErrors(`
import { z } from "zod";
import { defineAgentTool, type PluginServerContext, type AgentToolResult, type AgentToolContext, type AgentToolDefinition } from "@getpaseo/plugin/server";
const tool: AgentToolDefinition = defineAgentTool({
name: "create_issue",
description: "Create an issue",
input: z.object({ title: z.string(), count: z.string().transform(Number) }),
async execute(input, { agent, paseo, signal }) {
const count: number = input.count;
// @ts-expect-error input is inferred from the schema
input.missing;
return { text: agent.id + input.title, structuredContent: { count } };
},
});
export default function contribute(server: PluginServerContext) {
server.addHook("agent.open.before", async ({ request }) => {
const title: string | null = request.title;
// @ts-expect-error labels are read-only
request.labels.x = "changed";
if (request.labels.x !== "y") return;
// @ts-expect-error MCP servers use the same typed transport config as saved servers
request.mcpServers.invalid = { type: "http", url: 42 };
return { ...request, tools: [...request.tools, tool], mcpServers: {
...request.mcpServers, company: { type: "http", url: "https://tools.example.com/mcp" },
} };
});
return () => {};
}
`),
).toEqual([]);
});
});
describe("lifecycle hook SDK types", () => {
it("infers payloads and replacement requests and requires async handlers and new names", () => {
expect(
typeErrors(`
import type { PluginServerContext, PluginHooks, PluginHookPayload, PluginHookResult } from "@getpaseo/plugin/server";
export default function contribute(server: PluginServerContext) {
server.addHook("worktree.create.before", async ({ request }) => {
if (request.kind === "checkout-branch") {
// @ts-expect-error Branch checkout has no branch-off base.
request.baseRef;
} else {
const base: string = request.baseRef;
}
const branch: string = request.branch;
// @ts-expect-error Repository identity is read-only.
request.repoRoot = "/other";
// @ts-expect-error Source checkout identity is read-only.
request.sourceCheckout = "/other";
if (request.kind === "checkout-change-request") {
const number: number = request.changeRequestNumber;
}
return { ...request, branch: "feature", baseRef: "main", slug: "feature", path: "/work/feature" };
});
server.addHook("worktree.create.before", async () => {});
server.addHook("worktree.create.after", async ({ worktree }) => {
const path: string = worktree.path;
const branch: string = worktree.branch;
const source: string = worktree.sourceCheckout;
// @ts-expect-error Worktree facts have no workspace identity.
worktree.workspaceId;
});
server.addHook("workspace.create.before", async ({ request }) => ({ ...request, title: "Changed" }));
server.addHook("workspace.create.before", async () => {});
server.addHook("agent.open.before", async () => {});
server.addHook("agent.open.before", async ({ request }) => {
const { metadata, ...withoutMetadata } = request;
return withoutMetadata;
});
server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => {
const kind: "completed" | "failed" | "canceled" = outcome.kind;
// @ts-expect-error Notification payloads have no creation request.
outcome.request;
});
// @ts-expect-error Async handlers are required even for notifications.
server.addHook("agent.turn.before", () => {});
// @ts-expect-error Legacy names are only accepted by deprecated registration methods.
server.addHook("agent.session_open", async () => {});
// @ts-expect-error Unknown names fail typechecking.
server.addHook("nonsense", async () => {});
// @ts-expect-error A prepare handler must return the whole request or undefined.
server.addHook("agent.create.before", async () => ({ invalid: true }));
server.before("agent.session_open", ({ request }) => request);
server.on("agent.turn_ended", () => {});
return () => {};
}
`),
).toEqual([]);
});
});
describe("agent session reload SDK boundary", () => {
it("exposes reload separately from snapshot refresh", () => {
expect(
typeErrors(`
import { type PluginServerContext } from "@getpaseo/plugin/server";
import { type PaseoAgentRefetchResult } from "@getpaseo/client";
async function reload(server: PluginServerContext, paseo: import("@getpaseo/client").PaseoApi) {
const agent = paseo.agents.ref("chosen-agent");
const read: PaseoAgentRefetchResult | null = await agent.refresh("read-snapshot");
const reopened: PaseoAgentRefetchResult | null = await agent.reload({ requestId: "reload-session" });
await agent.reload();
// @ts-expect-error reload options can grow without a positional signature change
await agent.reload("reload-session");
}
`),
).toEqual([]);
});
});
describe("plugin subscription SDK boundary", () => {
it("types contracts, overloaded handlers, the client hook, and the client entry handle", () => {
expect(
typeErrors(`
import { defineRpc, defineSubscription, type SubscriptionEvent, type SubscriptionInput } from "@getpaseo/plugin";
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { useRpc, useSubscription } from "@getpaseo/plugin/client";
import type { PluginServerContext, PluginSubscriptionContext } from "@getpaseo/plugin/server";
import { z } from "zod";
const diffWatch = defineSubscription({
name: "diff.watch",
input: z.object({ a: z.string(), b: z.string() }),
event: z.object({ path: z.string(), status: z.enum(["added", "modified", "deleted"]) }),
});
const frames = defineSubscription({
name: "screen.capture",
input: z.object({ display: z.number() }),
event: z.union([z.instanceof(Uint8Array), z.object({ type: z.literal("resized"), width: z.number() })]),
});
const thumbnail = defineRpc({ name: "thumbnail.render", input: z.object({ path: z.string() }), output: z.instanceof(Uint8Array) });
const input: SubscriptionInput<typeof diffWatch> = { a: "main", b: "HEAD" };
const event: SubscriptionEvent<typeof diffWatch> = { path: "a.ts", status: "added" };
function server(server: PluginServerContext) {
server.handle(diffWatch, ({ a, b }, { push, signal, paseo }: PluginSubscriptionContext<SubscriptionEvent<typeof diffWatch>>) => {
const pushed: Promise<void> = push({ path: a + b, status: "modified" });
// @ts-expect-error events follow the contract
void push({ path: 1, status: "modified" });
signal.addEventListener("abort", () => undefined, { once: true });
});
server.handle(frames, async (_input, { push }) => {
await push(new Uint8Array(4));
await push({ type: "resized", width: 2 });
});
server.handle(thumbnail, ({ path }) => new Uint8Array(path.length));
// @ts-expect-error subscription handlers receive push, not an output
server.handle(diffWatch, () => ({ path: "a", status: "added" as const }) as unknown as number);
}
function Panel() {
const state = useSubscription(diffWatch, { a: "main", b: "HEAD" }, (change) => {
const status: "added" | "modified" | "deleted" = change.status;
});
if (state.status === "error") state.error.message;
// @ts-expect-error the hook has no ready state, only live
if (state.status === "ready") return null;
useSubscription(frames, { display: 1 }, (event) => {
if (event instanceof Uint8Array) event.byteLength;
else event.width;
});
const render = useRpc(thumbnail);
void render({ path: "x" }).then((png: Uint8Array) => png.byteLength);
return null;
}
function client(client: PluginClientContext) {
const controller = new AbortController();
const remover = client.subscribe(diffWatch, { a: "main", b: "HEAD" }, (change) => change.path, { signal: controller.signal });
const ready: Promise<void> = remover.ready;
const released: Promise<void> = remover.release();
remover();
return () => {};
}
`),
).toEqual([]);
});
});