1// @effect-diagnostics nodeBuiltinImport:off -- Pi extensions run outside Effect; these tests exercise their native filesystem boundary.
2import * as NodeFSP from "node:fs/promises";
3import * as NodePath from "node:path";
4import * as NodeOS from "node:os";
5import * as NodeModule from "node:module";
6import * as NodeVM from "node:vm";
7import { assert, describe, expect, it } from "@effect/vitest";
8
9import { PI_T3_MCP_EXTENSION_SOURCE } from "./mcpExtensionSource.ts";
10import { loadMcpBridge } from "./mcpBridge.testkit.ts";
11
12type RequestHook = (
13 event: { payload: unknown },
14 ctx: { model: { provider: string } },
15) => Record<string, unknown> | undefined;
16
17interface SkillCommand {
18 readonly name: string;
19 readonly source: string;
20 readonly sourceInfo: { readonly path: string };
21}
22
23type InputHook = (
24 event: { text: string; images?: ReadonlyArray<unknown> },
25 ctx: { ui: { notify: (message: string, level: string) => void } },
26) => Promise<{ action: string; text: string; images?: ReadonlyArray<unknown> } | undefined>;
27
28async function loadHooks(commands: ReadonlyArray<SkillCommand> = []) {
29 const handlers = new Map<string, unknown>();
30 // Execute the shipped extension with MCP disabled; this path needs no Typebox.
31 const source = NodeModule.stripTypeScriptTypes(
32 PI_T3_MCP_EXTENSION_SOURCE.replace(/^import .*;$/gm, "").replace(
33 "export default async function",
34 "async function",
35 ),
36 );
37 await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, {
38 process: { env: {} },
39 NodeFSP,
40 NodePath,
41 // Frontmatter parsing belongs to Pi. These fixtures contain only skill bodies.
42 stripFrontmatter: (content: string) => content,
43 pi: {
44 on: (name: string, handler: unknown) => handlers.set(name, handler),
45 getCommands: () => commands,
46 },
47 });
48 return handlers;
49}
50
51describe("Pi MCP tool results", () => {
52 it("returns mixed screenshot blocks and typed script output without repeating structured text", async () => {
53 const content = [
54 { type: "text", text: "Browser screenshot" },
55 { type: "image", data: "AAAA", mimeType: "image/png", _meta: { private: true } },
56 { type: "text", text: "Screenshot captured" },
57 ] as const;
58 const result = { content, structuredContent: { count: 2 }, _meta: { private: true } };
59 const bridge = await loadMcpBridge({ modern: true, result });
60 const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot")!;
61 const output = await tool.execute("snapshot", {});
62 assert.deepEqual(output.content, [
63 content[0],
64 { type: "image", data: "AAAA", mimeType: "image/png" },
65 content[2],
66 ]);
67 assert.deepEqual(output.structuredContent, { content, structuredContent: { count: 2 } });
68 assert.deepEqual(tool.outputSchema, {
69 type: "object",
70 properties: {
71 content: { type: "array", items: { type: "object" } },
72 structuredContent: { type: "object", properties: { count: { type: "number" } } },
73 isError: { type: "boolean" },
74 _meta: { type: "object" },
75 },
76 required: ["content"],
77 });
78 });
79
80 it.each([false, true])(
81 "preserves image-only results and error status, isError=%s",
82 async (isError) => {
83 const image = { type: "image", data: "AAAA", mimeType: "image/png" };
84 const result = { content: [image], isError };
85 const bridge = await loadMcpBridge({ result });
86 const output = await bridge.tools[0]!.execute("image", {});
87 assert.deepEqual(output.content[0], image);
88 assert.equal(output.content.length, isError ? 2 : 1);
89 if (isError) assert.include(output.content[1]?.text ?? "", "returned an error");
90 assert.equal(output.isError, isError ? true : undefined);
91 assert.deepEqual(output.structuredContent, result);
92 },
93 );
94
95 it("uses structured output as model text when content is empty", async () => {
96 const result = { content: [], structuredContent: { count: 2 } };
97 const bridge = await loadMcpBridge({ result });
98 const output = await bridge.tools[0]!.execute("structured", {});
99 assert.deepEqual(output.content, [{ type: "text", text: '{"count":2}' }]);
100 assert.deepEqual(output.structuredContent, result);
101 });
102
103 it("propagates cancellation without returning a successful result", async () => {
104 const bridge = await loadMcpBridge();
105 const controller = new AbortController();
106 controller.abort();
107 await expect(bridge.tools[0]!.execute("cancelled", {}, controller.signal)).rejects.toThrow(
108 "aborted",
109 );
110 });
111});
112
113describe("Pi MCP tool exposure", () => {
114 const discoveryContext = {
115 model: { api: "anthropic-messages" },
116 ui: { notify: () => undefined },
117 };
118
119 it.each(["session_start", "session_tree", "model_select", "before_agent_start"])(
120 "declares optional T3 tools for OpenAI-compatible gateways at %s",
121 async (hookName) => {
122 const bridge = await loadMcpBridge({ modern: true, toolSearchAvailable: true });
123 const hook = bridge.handlers.get(hookName);
124 assert.isDefined(hook);
125 await hook!(
126 { systemPrompt: "Pi system prompt" },
127 { model: { api: "openai-completions" }, ui: { notify: assert.fail } },
128 );
129 assert.isTrue(
130 bridge.tools
131 .filter((tool) => tool.name.startsWith("mcp__t3-code__"))
132 .every((tool) => bridge.getActiveTools().includes(tool.name)),
133 );
134 const result = await bridge.tools
135 .find((tool) => tool.name === "mcp__t3-code__preview_snapshot")!
136 .execute("gateway-call", {});
137 assert.equal(result.content[0]?.text, "browser snapshot");
138 assert.equal(bridge.tools.filter((tool) => tool.exposure === "hidden").length, 4);
139 },
140 );
141
142 it("keeps orchestration direct and optional bridge tools discoverable on modern Pi", async () => {
143 const bridge = await loadMcpBridge({ modern: true, toolSearchAvailable: true });
144 await bridge.handlers.get("session_start")!({ systemPrompt: "" }, discoveryContext);
145 const start = bridge.handlers.get("before_agent_start");
146 assert.isDefined(start);
147 const prompt = await start!({ systemPrompt: "Pi system prompt" }, discoveryContext);
148 assert.include(prompt.systemPrompt, "orchestrator_capabilities");
149 assert.equal(bridge.servers.length, 0);
150 assert.equal(bridge.tools.length, 8);
151 assert.deepEqual(bridge.getActiveTools(), ["read", "tool_search"]);
152 assert.deepEqual(
153 bridge.tools
154 .filter((tool) => tool.exposure !== "hidden")
155 .map((tool) => [tool.name, tool.exposure]),
156 [
157 ["mcp__t3-code__orchestrator_capabilities", "direct"],
158 ["mcp__t3-code__delegate_task", "direct"],
159 ["mcp__t3-code__task_status", "direct"],
160 ["mcp__t3-code__preview_snapshot", "deferred"],
161 ],
162 );
163 assert.deepEqual(
164 bridge.tools.filter((tool) => tool.exposure === "hidden").map((tool) => tool.name),
165 [
166 "mcp__t3_code__orchestrator_capabilities",
167 "mcp__t3_code__delegate_task",
168 "mcp__t3_code__task_status",
169 "mcp__t3_code__preview_snapshot",
170 ],
171 );
172 const result = await bridge.tools
173 .find((tool) => tool.name === "mcp__t3-code__preview_snapshot")!
174 .execute("call-1", { depth: 2 });
175 assert.equal(result.content[0]?.text, "browser snapshot");
176 assert.equal(bridge.requests.at(-1)?.method, "tools/call");
177 });
178
179 it("restores gateway declarations after model switches and older tree loadouts without excluded tools", async () => {
180 const bridge = await loadMcpBridge({
181 modern: true,
182 toolSearchAvailable: true,
183 allowsTool: (name) => name !== "mcp__t3-code__delegate_task",
184 });
185 const event = { systemPrompt: "" };
186 await bridge.handlers.get("session_start")!(event, discoveryContext);
187 const gatewayContext = { model: { api: "openai-completions" }, ui: { notify: assert.fail } };
188 await bridge.handlers.get("model_select")!(event, gatewayContext);
189 bridge.restoreActiveTools(["read", "mcp__t3-code__task_status"]);
190 await bridge.handlers.get("session_tree")!(event, gatewayContext);
191 assert.deepEqual(bridge.getActiveTools(), [
192 "read",
193 "mcp__t3-code__task_status",
194 "tool_search",
195 "mcp__t3-code__orchestrator_capabilities",
196 "mcp__t3-code__preview_snapshot",
197 ]);
198 await bridge.handlers.get("model_select")!(event, discoveryContext);
199 assert.equal(
200 bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot")?.exposure,
201 "deferred",
202 );
203 assert.include(bridge.getActiveTools(), "mcp__t3-code__preview_snapshot");
204 });
205
206 it.each(["legacy Pi", "disabled tool search"])(
207 "keeps tool execution available with %s",
208 async (mode) => {
209 const bridge = await loadMcpBridge({
210 modern: mode !== "legacy Pi",
211 toolSearchAvailable: mode === "disabled tool search",
212 toolSearchDisabled: mode === "disabled tool search",
213 });
214 if (mode !== "legacy Pi") {
215 const start = bridge.handlers.get("session_start");
216 await start!({ systemPrompt: "Pi system prompt" }, discoveryContext);
217 }
218 assert.equal(bridge.tools.filter((tool) => tool.exposure !== "hidden").length, 4);
219 assert.isTrue(
220 bridge.tools
221 .filter((tool) => tool.exposure !== "hidden")
222 .every((tool) => tool.exposure === undefined || tool.exposure === "direct"),
223 );
224 const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot");
225 assert.isDefined(tool);
226 const controller = new AbortController();
227 const result = await tool!.execute("call-1", { depth: 2 }, controller.signal);
228 assert.equal(result.content[0]?.text, "browser snapshot");
229 assert.strictEqual(bridge.transports.at(-1)?.signal, controller.signal);
230 assert.equal(bridge.transports.at(-1)?.url, "http://fixture.invalid/mcp");
231 assert.equal(bridge.transports.at(-1)?.authorization, "Bearer fixture-token");
232 assert.deepEqual(JSON.parse(JSON.stringify(bridge.requests.at(-1))), {
233 jsonrpc: "2.0",
234 id: 3,
235 method: "tools/call",
236 params: { name: "preview_snapshot", arguments: { depth: 2 } },
237 });
238 assert.isUndefined(tool?.promptSnippet);
239 assert.isUndefined(tool?.promptGuidelines);
240 },
241 );
242
243 it("preserves legacy wildcard tool selection", async () => {
244 const bridge = await loadMcpBridge({
245 modern: true,
246 toolSearchAvailable: true,
247 allowsTool: (name) =>
248 name === "read" || name === "tool_search" || name.startsWith("mcp__t3-code__"),
249 });
250 await bridge.handlers.get("session_start")!({ systemPrompt: "" }, discoveryContext);
251 assert.equal(bridge.tools.length, 4);
252 assert.equal(bridge.tools.filter((tool) => tool.exposure === "direct").length, 3);
253 const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot");
254 assert.isDefined(tool);
255 assert.equal((await tool!.execute("selected", {})).content[0]?.text, "browser snapshot");
256 });
257
258 it.each([false, true])(
259 "reconciles tree loadouts while honoring search exclusion: %s",
260 async (excludeSearch) => {
261 const bridge = await loadMcpBridge({
262 modern: true,
263 toolSearchAvailable: true,
264 allowsTool: (name) =>
265 name !== "mcp__t3-code__delegate_task" && (!excludeSearch || name !== "tool_search"),
266 });
267 await bridge.handlers.get("session_start")!({ systemPrompt: "" }, discoveryContext);
268 bridge.restoreActiveTools([
269 "read",
270 "mcp__t3-code__task_status",
271 "mcp__t3-code__preview_snapshot",
272 ]);
273 const tree = bridge.handlers.get("session_tree");
274 assert.isDefined(tree);
275 await tree!({ systemPrompt: "" }, discoveryContext);
276 assert.deepEqual(bridge.getActiveTools(), [
277 "read",
278 "mcp__t3-code__task_status",
279 "mcp__t3-code__preview_snapshot",
280 ...(!excludeSearch ? ["tool_search"] : []),
281 ]);
282 assert.isFalse(
283 bridge.tools.some(
284 (tool) => tool.exposure !== "hidden" && tool.name.endsWith("__delegate_task"),
285 ),
286 );
287 },
288 );
289});
290
291describe("Pi tool discovery permissions", () => {
292 it.each(["approval-required", "auto-accept-edits", "auto"])(
293 "allows annotated T3 reads in %s while gating mutations and replacements",
294 async (runtimeMode) => {
295 const bridge = await loadMcpBridge({ modern: true, runtimeMode });
296 const hook = bridge.handlers.get("tool_call") as unknown as (
297 event: { toolName: string; input: unknown },
298 ctx: { ui: { confirm: (title: string) => Promise<boolean> } },
299 ) => Promise<{ block: true; reason: string } | undefined>;
300 const confirmations: string[] = [];
301 const ctx = {
302 ui: {
303 confirm: async (title: string) => {
304 confirmations.push(title);
305 return false;
306 },
307 },
308 };
309 for (const prefix of ["mcp__t3-code__", "mcp__t3_code__"]) {
310 assert.isUndefined(
311 await hook({ toolName: prefix + "orchestrator_capabilities", input: {} }, ctx),
312 );
313 assert.equal(
314 (await hook({ toolName: prefix + "delegate_task", input: {} }, ctx))?.block,
315 true,
316 );
317 // task_status acknowledges result delivery, and is intentionally not read-only.
318 assert.equal(
319 (await hook({ toolName: prefix + "task_status", input: {} }, ctx))?.block,
320 true,
321 );
322 }
323 assert.equal(confirmations.length, 4);
324 bridge.setBridgeSourcePath("/user/extensions/replacement.ts");
325 assert.equal(
326 (await hook({ toolName: "mcp__t3-code__orchestrator_capabilities", input: {} }, ctx))
327 ?.block,
328 true,
329 );
330 assert.equal(
331 (await hook({ toolName: "mcp__other__orchestrator_capabilities", input: {} }, ctx))?.block,
332 true,
333 );
334 assert.equal(confirmations.length, 6);
335 },
336 );
337
338 it("allows discovery without confirmation and still gates the discovered tool", async () => {
339 type ToolCallHook = (
340 event: { toolName: string; input: unknown },
341 ctx: {
342 signal: AbortSignal;
343 ui: {
344 confirm: (
345 title: string,
346 detail: string,
347 options: { signal: AbortSignal },
348 ) => Promise<boolean>;
349 };
350 },
351 ) => Promise<{ block: true; reason: string } | undefined>;
352 let toolCall: ToolCallHook | undefined;
353 let searchPath = "builtin:tool-search";
354 const source = NodeModule.stripTypeScriptTypes(
355 PI_T3_MCP_EXTENSION_SOURCE.replace(/^import .*;$/gm, "").replace(
356 "export default async function",
357 "async function",
358 ),
359 );
360 await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, {
361 process: { env: { T3_PI_RUNTIME_MODE: "approval-required" } },
362 pi: {
363 on: (name: string, handler: ToolCallHook) => {
364 if (name === "tool_call") toolCall = handler;
365 },
366 getAllTools: () => [{ name: "tool_search", sourceInfo: { path: searchPath } }],
367 },
368 });
369 assert.isDefined(toolCall);
370 const confirmations: string[] = [];
371 const controller = new AbortController();
372 const ctx = {
373 signal: controller.signal,
374 ui: {
375 confirm: async (title: string, _detail: string, options: { signal: AbortSignal }) => {
376 assert.strictEqual(options.signal, controller.signal);
377 confirmations.push(title);
378 return false;
379 },
380 },
381 };
382 assert.isUndefined(
383 await toolCall!({ toolName: "tool_search", input: { query: "preview_snapshot" } }, ctx),
384 );
385 assert.equal(confirmations.length, 0);
386 const result = await toolCall!({ toolName: "mcp__t3-code__preview_snapshot", input: {} }, ctx);
387 assert.equal(result?.block, true);
388 assert.deepEqual(confirmations, ["Allow mcp__t3-code__preview_snapshot?"]);
389
390 // An extension that replaces the search builtin is not known to be read-only.
391 searchPath = "/extensions/custom-search.ts";
392 const replaced = await toolCall!({ toolName: "tool_search", input: {} }, ctx);
393 assert.equal(replaced?.block, true);
394 assert.equal(confirmations.at(-1), "Allow tool_search?");
395 });
396});
397
398async function loadRequestHook(): Promise<RequestHook> {
399 const handlers = new Map<string, RequestHook>();
400 // Execute the shipped extension with MCP disabled; this path needs no Typebox.
401 const source = NodeModule.stripTypeScriptTypes(
402 PI_T3_MCP_EXTENSION_SOURCE.replace(/^import .*;$/gm, "").replace(
403 "export default async function",
404 "async function",
405 ),
406 );
407 await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, {
408 process: { env: {} },
409 pi: { on: (name: string, handler: RequestHook) => handlers.set(name, handler) },
410 });
411 const hook = handlers.get("before_provider_request");
412 assert.isDefined(hook);
413 return hook!;
414}
415
416describe("Pi upstream output-budget workaround", () => {
417 it.each(["max_tokens", "max_completion_tokens"])(
418 "caps %s without changing the conversation or tools",
419 async (key) => {
420 const hook = await loadRequestHook();
421 const payload = {
422 model: "moonshotai/kimi-k2.6",
423 messages: [{ role: "user", content: "hello" }],
424 tools: [{ type: "function", function: { name: "read" } }],
425 [key]: 231_969,
426 };
427 const result = hook({ payload }, { model: { provider: "openrouter" } });
428 assert.equal(result?.[key], 32_768);
429 assert.strictEqual(result?.messages, payload.messages);
430 assert.strictEqual(result?.tools, payload.tools);
431 assert.equal(result?.model, payload.model);
432 assert.equal(payload[key], 231_969);
433 },
434 );
435
436 it("preserves smaller budgets and other providers' payloads", async () => {
437 const hook = await loadRequestHook();
438 for (const payload of [{ max_tokens: 8192 }, { max_completion_tokens: 32_768 }, {}, null]) {
439 assert.isUndefined(hook({ payload }, { model: { provider: "openrouter" } }));
440 }
441 assert.isUndefined(
442 hook({ payload: { max_tokens: 231_969 } }, { model: { provider: "anthropic" } }),
443 );
444 });
445});
446
447async function loadInputHook(commands: ReadonlyArray<SkillCommand>) {
448 const handlers = await loadHooks(commands);
449 const hook = handlers.get("input");
450 assert.isDefined(hook);
451 return hook as InputHook;
452}
453
454describe("Pi skill references", () => {
455 it("loads every selected skill once while preserving inline prose, whitespace, and images", async () => {
456 const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pi-skill-mentions-"));
457 try {
458 const alpha = NodePath.join(directory, "alpha.md");
459 const beta = NodePath.join(directory, "beta.md");
460 await NodeFSP.writeFile(alpha, "ALPHA_INSTRUCTIONS");
461 await NodeFSP.writeFile(beta, "BETA_INSTRUCTIONS");
462 const hook = await loadInputHook([
463 { name: "skill:alpha", source: "skill", sourceInfo: { path: alpha } },
464 { name: "skill:beta", source: "skill", sourceInfo: { path: beta } },
465 ]);
466 const text =
467 "Please use the $alpha philosophy, then $beta and $alpha\n```ts\n const x = 1;\n```";
468 const images = [{ type: "image", data: "fixture" }];
469 const result = await hook({ text, images }, { ui: { notify: assert.fail } });
470 assert.equal(result?.action, "transform");
471 assert.isTrue(result?.text.startsWith(text + "\n\n"));
472 assert.equal(result?.text.split("ALPHA_INSTRUCTIONS").length, 2);
473 assert.equal(result?.text.split("BETA_INSTRUCTIONS").length, 2);
474 assert.include(result?.text ?? "", `References are relative to ${directory}.`);
475 assert.strictEqual(result?.images, images);
476 } finally {
477 await NodeFSP.rm(directory, { recursive: true });
478 }
479 });
480
481 it("leaves unknown references and native skill commands for Pi", async () => {
482 const hook = await loadInputHook([
483 { name: "skill:alpha", source: "skill", sourceInfo: { path: "/unused" } },
484 ]);
485 // Punctuation-adjacent references are plain text in the composer, not selected chips.
486 for (const text of [
487 "Explain $HOME",
488 "/skill:alpha use $alpha",
489 "Hello",
490 "$missing",
491 "Use $alpha, then continue",
492 "Use ($alpha)",
493 ]) {
494 assert.isUndefined(await hook({ text }, { ui: { notify: assert.fail } }));
495 }
496 });
497
498 it("loads additional chips without duplicating a leading native skill", async () => {
499 const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pi-skill-mentions-"));
500 try {
501 const path = NodePath.join(directory, "beta.md");
502 await NodeFSP.writeFile(path, "BETA_INSTRUCTIONS");
503 const hook = await loadInputHook([
504 { name: "skill:alpha", source: "skill", sourceInfo: { path: "/unused" } },
505 { name: "skill:beta", source: "skill", sourceInfo: { path } },
506 ]);
507 const text = "/skill:alpha use $alpha and $beta";
508 const result = await hook({ text }, { ui: { notify: assert.fail } });
509 assert.isTrue(result?.text.startsWith(text + "\n\n"));
510 assert.notInclude(result?.text ?? "", '<skill name="alpha"');
511 assert.include(result?.text ?? "", "BETA_INSTRUCTIONS");
512 } finally {
513 await NodeFSP.rm(directory, { recursive: true });
514 }
515 });
516
517 it("reports an unreadable skill without deleting its reference or other selected instructions", async () => {
518 const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pi-skill-mentions-"));
519 try {
520 const path = NodePath.join(directory, "readable.md");
521 await NodeFSP.writeFile(path, "READABLE_INSTRUCTIONS");
522 const hook = await loadInputHook([
523 {
524 name: "skill:missing",
525 source: "skill",
526 sourceInfo: { path: NodePath.join(directory, "missing.md") },
527 },
528 { name: "skill:readable", source: "skill", sourceInfo: { path } },
529 ]);
530 const notices: string[] = [];
531 const text = "Use $missing and $readable";
532 const result = await hook(
533 { text },
534 {
535 ui: {
536 notify: (message) => {
537 notices.push(message);
538 },
539 },
540 },
541 );
542 assert.isTrue(result?.text.startsWith(text + "\n\n"));
543 assert.include(result?.text ?? "", "READABLE_INSTRUCTIONS");
544 assert.equal(notices.length, 1);
545 assert.include(notices[0] ?? "", "Could not load skill missing");
546 } finally {
547 await NodeFSP.rm(directory, { recursive: true });
548 }
549 });
550});