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