import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as path from "node:path"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { initializeWithSettings } from "@oh-my-pi/pi-coding-agent/discovery"; import { discoverAndLoadExtensions, loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; import { getProjectAgentDir, TempDir } from "@oh-my-pi/pi-utils"; import { filterUserScoped } from "./utils/filter-user-extensions"; describe("extensions discovery", () => { let tempDir: TempDir; let extensionsDir: string; beforeEach(() => { tempDir = TempDir.createSync("@pi-ext-test-"); extensionsDir = path.join(getProjectAgentDir(tempDir.path()), "extensions"); fs.mkdirSync(extensionsDir, { recursive: true }); resetSettingsForTest(); }); afterEach(() => { resetSettingsForTest(); tempDir.removeSync(); }); const discoverForTest = async (configuredPaths: string[] = []) => { const result = await discoverAndLoadExtensions(configuredPaths, tempDir.path()); return { ...result, extensions: filterUserScoped(result.extensions, [tempDir.path(), ...configuredPaths]), errors: filterUserScoped(result.errors, [tempDir.path(), ...configuredPaths]), }; }; const extensionCode = ` export default function(pi) { pi.registerCommand("test", { handler: async () => {} }); } `; const extensionCodeWithTool = (toolName: string) => ` export default function(pi) { const { Type } = pi.typebox; pi.registerTool({ name: "${toolName}", label: "${toolName}", description: "Test tool", parameters: Type.Object({}), execute: async () => ({ content: [{ type: "text", text: "ok" }] }), }); } `; it("discovers direct .ts files in extensions/", async () => { fs.writeFileSync(path.join(extensionsDir, "foo.ts"), extensionCode); fs.writeFileSync(path.join(extensionsDir, "bar.ts"), extensionCode); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(2); expect(result.extensions.map(e => path.basename(e.path)).sort()).toEqual(["bar.ts", "foo.ts"]); }); it("discovers direct .js files in extensions/", async () => { fs.writeFileSync(path.join(extensionsDir, "foo.js"), extensionCode); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(path.basename(result.extensions[0].path)).toBe("foo.js"); }); it("discovers subdirectory with index.ts", async () => { const subdir = path.join(extensionsDir, "my-extension"); fs.mkdirSync(subdir); fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].path).toContain("my-extension"); expect(result.extensions[0].path).toContain("index.ts"); }); it("discovers subdirectory with index.js", async () => { const subdir = path.join(extensionsDir, "my-extension"); fs.mkdirSync(subdir); fs.writeFileSync(path.join(subdir, "index.js"), extensionCode); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].path).toContain("index.js"); }); it("prefers index.ts over index.js", async () => { const subdir = path.join(extensionsDir, "my-extension"); fs.mkdirSync(subdir); fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode); fs.writeFileSync(path.join(subdir, "index.js"), extensionCode); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].path).toContain("index.ts"); }); it("discovers subdirectory with package.json pi field", async () => { const subdir = path.join(extensionsDir, "my-package"); const srcDir = path.join(subdir, "src"); fs.mkdirSync(subdir); fs.mkdirSync(srcDir); fs.writeFileSync(path.join(srcDir, "main.ts"), extensionCode); fs.writeFileSync( path.join(subdir, "package.json"), JSON.stringify({ name: "my-package", pi: { extensions: ["./src/main.ts"], }, }), ); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].path).toContain("src"); expect(result.extensions[0].path).toContain("main.ts"); }); it("discovers a symlinked extension package directory", async () => { const packageDir = path.join(tempDir.path(), "linked-package"); const sourceDir = path.join(packageDir, "src"); fs.mkdirSync(sourceDir, { recursive: true }); fs.writeFileSync(path.join(sourceDir, "main.ts"), extensionCode); fs.writeFileSync( path.join(packageDir, "package.json"), JSON.stringify({ name: "linked-package", pi: { extensions: ["./src/main.ts"], }, }), ); fs.symlinkSync(packageDir, path.join(extensionsDir, "linked-package"), "dir"); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].path).toContain(path.join("linked-package", "src", "main.ts")); }); it("discovers index.ts in a symlinked extension directory", async () => { const packageDir = path.join(tempDir.path(), "linked-index-ts"); fs.mkdirSync(packageDir); fs.writeFileSync(path.join(packageDir, "index.ts"), extensionCode); fs.symlinkSync(packageDir, path.join(extensionsDir, "linked-index-ts"), "dir"); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].path).toContain(path.join("linked-index-ts", "index.ts")); }); it("discovers index.js in a symlinked extension directory", async () => { const packageDir = path.join(tempDir.path(), "linked-index-js"); fs.mkdirSync(packageDir); fs.writeFileSync(path.join(packageDir, "index.js"), extensionCode); fs.symlinkSync(packageDir, path.join(extensionsDir, "linked-index-js"), "dir"); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].path).toContain(path.join("linked-index-js", "index.js")); }); it("package.json can declare multiple extensions", async () => { const subdir = path.join(extensionsDir, "my-package"); fs.mkdirSync(subdir); fs.writeFileSync(path.join(subdir, "ext1.ts"), extensionCode); fs.writeFileSync(path.join(subdir, "ext2.ts"), extensionCode); fs.writeFileSync( path.join(subdir, "package.json"), JSON.stringify({ name: "my-package", pi: { extensions: ["./ext1.ts", "./ext2.ts"], }, }), ); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(2); }); it("package.json with pi field takes precedence over index.ts", async () => { const subdir = path.join(extensionsDir, "my-package"); fs.mkdirSync(subdir); fs.writeFileSync(path.join(subdir, "index.ts"), extensionCodeWithTool("from-index")); fs.writeFileSync(path.join(subdir, "custom.ts"), extensionCodeWithTool("from-custom")); fs.writeFileSync( path.join(subdir, "package.json"), JSON.stringify({ name: "my-package", pi: { extensions: ["./custom.ts"], }, }), ); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].path).toContain("custom.ts"); // Verify the right tool was registered expect(result.extensions[0].tools.has("from-custom")).toBe(true); expect(result.extensions[0].tools.has("from-index")).toBe(false); }); it("ignores package.json without pi field, falls back to index.ts", async () => { const subdir = path.join(extensionsDir, "my-package"); fs.mkdirSync(subdir); fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode); fs.writeFileSync( path.join(subdir, "package.json"), JSON.stringify({ name: "my-package", version: "1.0.0", }), ); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].path).toContain("index.ts"); }); it("ignores subdirectory without index or package.json", async () => { const subdir = path.join(extensionsDir, "not-an-extension"); fs.mkdirSync(subdir); fs.writeFileSync(path.join(subdir, "helper.ts"), extensionCode); fs.writeFileSync(path.join(subdir, "utils.ts"), extensionCode); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(0); }); it("does not recurse beyond one level", async () => { const subdir = path.join(extensionsDir, "container"); const nested = path.join(subdir, "nested"); fs.mkdirSync(subdir); fs.mkdirSync(nested); fs.writeFileSync(path.join(nested, "index.ts"), extensionCode); // No index.ts or package.json in container/ const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(0); }); it("handles mixed direct files and subdirectories", async () => { // Direct file fs.writeFileSync(path.join(extensionsDir, "direct.ts"), extensionCode); // Subdirectory with index const subdir1 = path.join(extensionsDir, "with-index"); fs.mkdirSync(subdir1); fs.writeFileSync(path.join(subdir1, "index.ts"), extensionCode); // Subdirectory with package.json const subdir2 = path.join(extensionsDir, "with-manifest"); fs.mkdirSync(subdir2); fs.writeFileSync(path.join(subdir2, "entry.ts"), extensionCode); fs.writeFileSync(path.join(subdir2, "package.json"), JSON.stringify({ pi: { extensions: ["./entry.ts"] } })); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(3); }); it("discovers a symlinked extension directory with index.ts", async () => { // A single extension dir shared across profiles via a symlink: the real // directory lives outside extensions/ and is linked into it. Native glob // never descends into the symlink, so this exercises the symlink fallback. const realDir = path.join(tempDir.path(), "external", "shared-ext"); fs.mkdirSync(realDir, { recursive: true }); fs.writeFileSync(path.join(realDir, "index.ts"), extensionCode); fs.symlinkSync(realDir, path.join(extensionsDir, "linked-ext"), "dir"); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].path).toContain("linked-ext"); expect(result.extensions[0].path).toContain("index.ts"); }); it("discovers a symlinked extension directory with a package.json manifest", async () => { // Mirrors the real-world shape: a packaged extension (package.json + index.ts) // symlinked into a profile's extensions/ dir. const realDir = path.join(tempDir.path(), "external", "ctk"); fs.mkdirSync(realDir, { recursive: true }); fs.writeFileSync(path.join(realDir, "index.ts"), extensionCodeWithTool("ctk-tool")); fs.writeFileSync( path.join(realDir, "package.json"), JSON.stringify({ name: "ctk", omp: { extensions: ["./index.ts"] } }), ); fs.symlinkSync(realDir, path.join(extensionsDir, "ctk"), "dir"); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); // Manifest declares index.ts; it must be discovered exactly once (no double // from the synthesized index.ts match colliding with the manifest entry). expect(result.extensions).toHaveLength(1); expect(result.extensions[0].path).toContain("index.ts"); expect(result.extensions[0].tools.has("ctk-tool")).toBe(true); }); it("discovers a symlinked extension file", async () => { // Symlinked *files* resolve through the native file-type filter; guards that // the directory fallback does not regress the file case. const realFile = path.join(tempDir.path(), "external", "shared.ts"); fs.mkdirSync(path.dirname(realFile), { recursive: true }); fs.writeFileSync(realFile, extensionCode); fs.symlinkSync(realFile, path.join(extensionsDir, "linked.ts"), "file"); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].path).toContain("linked.ts"); }); it("does not crash on a dangling symlinked extension directory", async () => { // A profile symlink pointing at a since-deleted shared extension. The fallback // reads the (missing) target, gets [], and must yield no extension and no // error rather than throwing. fs.symlinkSync(path.join(tempDir.path(), "external", "gone"), path.join(extensionsDir, "broken"), "dir"); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(0); }); it("discovers a symlinked extension directory whose name ends in .ts", async () => { // Odd but legal: a *.ts-named symlink that targets a directory. The native // file-type filter rejects it as a direct file (target is a dir), so it must // resolve exactly once via the synthesized subdir index — never double-counted // as both a direct file and a subdir entry. const realDir = path.join(tempDir.path(), "external", "weird"); fs.mkdirSync(realDir, { recursive: true }); fs.writeFileSync(path.join(realDir, "index.ts"), extensionCode); fs.symlinkSync(realDir, path.join(extensionsDir, "weird.ts"), "dir"); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].path).toContain("weird.ts"); expect(result.extensions[0].path).toContain("index.ts"); }); it("skips non-existent paths declared in package.json", async () => { const subdir = path.join(extensionsDir, "my-package"); fs.mkdirSync(subdir); fs.writeFileSync(path.join(subdir, "exists.ts"), extensionCode); fs.writeFileSync( path.join(subdir, "package.json"), JSON.stringify({ pi: { extensions: ["./exists.ts", "./missing.ts"], }, }), ); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].path).toContain("exists.ts"); }); it("loads extensions and registers commands", async () => { fs.writeFileSync(path.join(extensionsDir, "with-command.ts"), extensionCode); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].commands.has("test")).toBe(true); }); it("loads extensions and registers tools", async () => { fs.writeFileSync(path.join(extensionsDir, "with-tool.ts"), extensionCodeWithTool("my-tool")); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].tools.has("my-tool")).toBe(true); }); it("reports errors for invalid extension code", async () => { fs.writeFileSync(path.join(extensionsDir, "invalid.ts"), "this is not valid typescript export"); const result = await discoverForTest(); expect(result.errors).toHaveLength(1); expect(result.errors[0].path).toContain("invalid.ts"); expect(result.extensions).toHaveLength(0); }); it("handles explicitly configured paths", async () => { const customPath = path.join(tempDir.path(), "custom-location", "my-ext.ts"); fs.mkdirSync(path.dirname(customPath), { recursive: true }); fs.writeFileSync(customPath, extensionCode); const result = await discoverForTest([customPath]); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].path).toContain("my-ext.ts"); }); it("resolves 3rd party npm dependencies (chalk)", async () => { // Load the real chalk-logger extension from examples const chalkLoggerPath = path.resolve(import.meta.dirname, "..", "examples", "extensions", "chalk-logger.ts"); const result = await discoverForTest([chalkLoggerPath]); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].path).toContain("chalk-logger.ts"); // The extension registers event handlers, not commands/tools expect(result.extensions[0].handlers.size).toBeGreaterThan(0); }); it("resolves dependencies from extension's own node_modules", async () => { const extDir = path.join(tempDir.path(), "with-deps"); const extNodeModules = path.join(extDir, "node_modules", "local-duration-parser"); fs.mkdirSync(extNodeModules, { recursive: true }); fs.writeFileSync( path.join(extDir, "package.json"), JSON.stringify({ name: "pi-extension-with-deps", version: "1.0.0", type: "module", omp: { extensions: ["./index.ts"] }, }), ); fs.writeFileSync( path.join(extDir, "index.ts"), ` import parseDuration from "local-duration-parser"; export default function (pi) { const { Type } = pi.typebox; pi.registerTool({ name: "parse_duration", label: "Parse Duration", description: "Parse duration strings", parameters: Type.Object({ duration: Type.String() }), execute: async (_toolCallId, params) => ({ content: [{ type: "text", text: String(parseDuration(params.duration)) }], details: {}, }), }); } `, ); fs.writeFileSync( path.join(extNodeModules, "package.json"), JSON.stringify({ name: "local-duration-parser", version: "1.0.0", type: "module", exports: "./index.js", }), ); fs.writeFileSync( path.join(extNodeModules, "index.js"), "export default function parseDuration(input) { return input.length; }", ); const result = await discoverForTest([extDir]); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].path).toContain("with-deps"); expect(result.extensions[0].tools.has("parse_duration")).toBe(true); }); it("registers message renderers", async () => { const extCode = ` export default function(pi) { pi.registerMessageRenderer("my-custom-type", (message, options, theme) => { return null; // Use default rendering }); } `; fs.writeFileSync(path.join(extensionsDir, "with-renderer.ts"), extCode); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].messageRenderers.has("my-custom-type")).toBe(true); }); it("reports error when extension throws during initialization", async () => { const extCode = ` export default function(pi) { throw new Error("Initialization failed!"); } `; fs.writeFileSync(path.join(extensionsDir, "throws.ts"), extCode); const result = await discoverForTest(); expect(result.errors).toHaveLength(1); expect(result.errors[0].error).toContain("Initialization failed!"); expect(result.extensions).toHaveLength(0); }); it("reports error when extension has no default export", async () => { const extCode = ` export function notDefault(pi) { pi.registerCommand("test", { handler: async () => {} }); } `; fs.writeFileSync(path.join(extensionsDir, "no-default.ts"), extCode); const result = await discoverForTest(); expect(result.errors).toHaveLength(1); expect(result.errors[0].error).toContain("does not export a valid factory function"); expect(result.extensions).toHaveLength(0); }); it("allows multiple extensions to register different tools", async () => { fs.writeFileSync(path.join(extensionsDir, "tool-a.ts"), extensionCodeWithTool("tool-a")); fs.writeFileSync(path.join(extensionsDir, "tool-b.ts"), extensionCodeWithTool("tool-b")); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(2); const allTools = new Set(); for (const ext of result.extensions) { for (const name of ext.tools.keys()) { allTools.add(name); } } expect(allTools.has("tool-a")).toBe(true); expect(allTools.has("tool-b")).toBe(true); }); it("loads extension with event handlers", async () => { const extCode = ` export default function(pi) { pi.on("agent_start", async () => {}); pi.on("tool_call", async (event) => undefined); pi.on("agent_end", async () => {}); } `; fs.writeFileSync(path.join(extensionsDir, "with-handlers.ts"), extCode); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].handlers.has("agent_start")).toBe(true); expect(result.extensions[0].handlers.has("tool_call")).toBe(true); expect(result.extensions[0].handlers.has("agent_end")).toBe(true); }); it("loads hookCapability JS factories as extension handlers", async () => { const hookDir = path.join(getProjectAgentDir(tempDir.path()), "hooks", "pre"); fs.mkdirSync(hookDir, { recursive: true }); const hookPath = path.join(hookDir, "guard-test.ts"); fs.writeFileSync( hookPath, ` export default function(pi) { pi.on("tool_call", async () => ({ block: true, reason: "blocked by hook" })); } `, ); const result = await discoverForTest(); const loadedHook = result.extensions.find(extension => extension.path === hookPath); expect(result.errors).toHaveLength(0); expect(loadedHook).toBeDefined(); expect(loadedHook?.handlers.has("tool_call")).toBe(true); }); it("keeps discovered hooks separate from disabled extension-module ids", async () => { const extensionPath = path.join(extensionsDir, "guard.ts"); fs.writeFileSync(extensionPath, extensionCode); const hookDir = path.join(getProjectAgentDir(tempDir.path()), "hooks", "pre"); fs.mkdirSync(hookDir, { recursive: true }); const hookPath = path.join(hookDir, "guard.ts"); fs.writeFileSync( hookPath, ` export default function(pi) { pi.on("tool_call", async () => ({ block: true, reason: "blocked by hook" })); } `, ); const settings = await Settings.init({ inMemory: true, cwd: tempDir.path(), overrides: { disabledExtensions: ["extension-module:guard"] }, }); initializeWithSettings(settings); const result = await discoverForTest(); const loadedHook = result.extensions.find(extension => extension.path === hookPath); expect(result.errors).toHaveLength(0); expect(result.extensions.find(extension => extension.path === extensionPath)).toBeUndefined(); expect(loadedHook).toBeDefined(); expect(loadedHook?.handlers.has("tool_call")).toBe(true); }); it("loads extension with shortcuts", async () => { const extCode = ` export default function(pi) { pi.registerShortcut("ctrl+t", { description: "Test shortcut", handler: async (ctx) => {}, }); } `; fs.writeFileSync(path.join(extensionsDir, "with-shortcut.ts"), extCode); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].shortcuts.has("ctrl+t")).toBe(true); }); it("loads extension with flags", async () => { const extCode = ` export default function(pi) { pi.registerFlag("--my-flag", { description: "My custom flag", handler: async (value) => {}, }); } `; fs.writeFileSync(path.join(extensionsDir, "with-flag.ts"), extCode); const result = await discoverForTest(); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].flags.has("--my-flag")).toBe(true); }); it("loadExtensions only loads explicit paths without discovery", async () => { // Create discoverable extensions (would be found by discoverAndLoadExtensions) fs.writeFileSync(path.join(extensionsDir, "discovered.ts"), extensionCodeWithTool("discovered")); // Create explicit extension outside discovery path const explicitPath = path.join(tempDir.path(), "explicit.ts"); fs.writeFileSync(explicitPath, extensionCodeWithTool("explicit")); // Use loadExtensions directly to skip discovery const result = await loadExtensions([explicitPath], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].tools.has("explicit")).toBe(true); expect(result.extensions[0].tools.has("discovered")).toBe(false); }); it("loadExtensions with no paths loads nothing", async () => { // Create discoverable extensions (would be found by discoverAndLoadExtensions) fs.writeFileSync(path.join(extensionsDir, "discovered.ts"), extensionCode); // Use loadExtensions directly with empty paths const result = await loadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(0); }); });