/** * Tests for ExtensionRunner - conflict detection, error handling, tool wrapping. */ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.js"; import { discoverAndLoadExtensions } from "../src/core/extensions/loader.js"; import { ExtensionRunner } from "../src/core/extensions/runner.js"; import { DEFAULT_KEYBINDINGS, type KeyId } from "../src/core/keybindings.js"; import { ModelRegistry } from "../src/core/model-registry.js"; import { SessionManager } from "../src/core/session-manager.js"; describe("ExtensionRunner", () => { let tempDir: string; let extensionsDir: string; let sessionManager: SessionManager; let modelRegistry: ModelRegistry; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-runner-test-")); extensionsDir = path.join(tempDir, "extensions"); fs.mkdirSync(extensionsDir); sessionManager = SessionManager.inMemory(); const authStorage = AuthStorage.create(path.join(tempDir, "auth.json")); modelRegistry = new ModelRegistry(authStorage); }); afterEach(() => { fs.rmSync(tempDir, { recursive: true, force: true }); }); describe("shortcut conflicts", () => { it("warns when extension shortcut conflicts with built-in", async () => { const extCode = ` export default function(pi) { pi.registerShortcut("ctrl+c", { description: "Conflicts with built-in", handler: async () => {}, }); } `; fs.writeFileSync(path.join(extensionsDir, "conflict.ts"), extCode); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const shortcuts = runner.getShortcuts(DEFAULT_KEYBINDINGS); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in")); expect(shortcuts.has("ctrl+c")).toBe(false); warnSpy.mockRestore(); }); it("allows a shortcut when the reserved set no longer contains the default key", async () => { const extCode = ` export default function(pi) { pi.registerShortcut("ctrl+p", { description: "Uses freed default", handler: async () => {}, }); } `; fs.writeFileSync(path.join(extensionsDir, "rebinding.ts"), extCode); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const keybindings = { ...DEFAULT_KEYBINDINGS, cycleModelForward: "ctrl+n" as KeyId }; const shortcuts = runner.getShortcuts(keybindings); expect(shortcuts.has("ctrl+p")).toBe(true); expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in")); warnSpy.mockRestore(); }); it("warns but allows when extension uses non-reserved built-in shortcut", async () => { const extCode = ` export default function(pi) { pi.registerShortcut("ctrl+v", { description: "Overrides non-reserved", handler: async () => {}, }); } `; fs.writeFileSync(path.join(extensionsDir, "non-reserved.ts"), extCode); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const shortcuts = runner.getShortcuts(DEFAULT_KEYBINDINGS); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("built-in shortcut for pasteImage")); expect(shortcuts.has("ctrl+v")).toBe(true); warnSpy.mockRestore(); }); it("blocks shortcuts for reserved actions even when rebound", async () => { const extCode = ` export default function(pi) { pi.registerShortcut("ctrl+x", { description: "Conflicts with rebound reserved", handler: async () => {}, }); } `; fs.writeFileSync(path.join(extensionsDir, "rebound-reserved.ts"), extCode); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const keybindings = { ...DEFAULT_KEYBINDINGS, interrupt: "ctrl+x" as KeyId }; const shortcuts = runner.getShortcuts(keybindings); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in")); expect(shortcuts.has("ctrl+x")).toBe(false); warnSpy.mockRestore(); }); it("blocks shortcuts when reserved action has multiple keys", async () => { const extCode = ` export default function(pi) { pi.registerShortcut("ctrl+y", { description: "Conflicts with multi-key reserved", handler: async () => {}, }); } `; fs.writeFileSync(path.join(extensionsDir, "multi-reserved.ts"), extCode); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const keybindings = { ...DEFAULT_KEYBINDINGS, clear: ["ctrl+x", "ctrl+y"] as KeyId[] }; const shortcuts = runner.getShortcuts(keybindings); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in")); expect(shortcuts.has("ctrl+y")).toBe(false); warnSpy.mockRestore(); }); it("warns but allows when non-reserved action has multiple keys", async () => { const extCode = ` export default function(pi) { pi.registerShortcut("ctrl+y", { description: "Overrides multi-key non-reserved", handler: async () => {}, }); } `; fs.writeFileSync(path.join(extensionsDir, "multi-non-reserved.ts"), extCode); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const keybindings = { ...DEFAULT_KEYBINDINGS, pasteImage: ["ctrl+x", "ctrl+y"] as KeyId[] }; const shortcuts = runner.getShortcuts(keybindings); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("built-in shortcut for pasteImage")); expect(shortcuts.has("ctrl+y")).toBe(true); warnSpy.mockRestore(); }); it("warns when two extensions register same shortcut", async () => { // Use a non-reserved shortcut const extCode1 = ` export default function(pi) { pi.registerShortcut("ctrl+shift+x", { description: "First extension", handler: async () => {}, }); } `; const extCode2 = ` export default function(pi) { pi.registerShortcut("ctrl+shift+x", { description: "Second extension", handler: async () => {}, }); } `; fs.writeFileSync(path.join(extensionsDir, "ext1.ts"), extCode1); fs.writeFileSync(path.join(extensionsDir, "ext2.ts"), extCode2); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const shortcuts = runner.getShortcuts(DEFAULT_KEYBINDINGS); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("shortcut conflict")); // Last one wins expect(shortcuts.has("ctrl+shift+x")).toBe(true); warnSpy.mockRestore(); }); }); describe("tool collection", () => { it("collects tools from multiple extensions", async () => { const toolCode = (name: string) => ` import { Type } from "@sinclair/typebox"; export default function(pi) { pi.registerTool({ name: "${name}", label: "${name}", description: "Test tool", parameters: Type.Object({}), execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), }); } `; fs.writeFileSync(path.join(extensionsDir, "tool-a.ts"), toolCode("tool_a")); fs.writeFileSync(path.join(extensionsDir, "tool-b.ts"), toolCode("tool_b")); const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const tools = runner.getAllRegisteredTools(); expect(tools.length).toBe(2); expect(tools.map((t) => t.definition.name).sort()).toEqual(["tool_a", "tool_b"]); }); }); describe("command collection", () => { it("collects commands from multiple extensions", async () => { const cmdCode = (name: string) => ` export default function(pi) { pi.registerCommand("${name}", { description: "Test command", handler: async () => {}, }); } `; fs.writeFileSync(path.join(extensionsDir, "cmd-a.ts"), cmdCode("cmd-a")); fs.writeFileSync(path.join(extensionsDir, "cmd-b.ts"), cmdCode("cmd-b")); const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const commands = runner.getRegisteredCommands(); expect(commands.length).toBe(2); expect(commands.map((c) => c.name).sort()).toEqual(["cmd-a", "cmd-b"]); }); it("gets command by name", async () => { const cmdCode = ` export default function(pi) { pi.registerCommand("my-cmd", { description: "My command", handler: async () => {}, }); } `; fs.writeFileSync(path.join(extensionsDir, "cmd.ts"), cmdCode); const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const cmd = runner.getCommand("my-cmd"); expect(cmd).toBeDefined(); expect(cmd?.name).toBe("my-cmd"); expect(cmd?.description).toBe("My command"); const missing = runner.getCommand("not-exists"); expect(missing).toBeUndefined(); }); it("filters out commands conflict with reseved", async () => { const cmdCode = (name: string) => ` export default function(pi) { pi.registerCommand("${name}", { description: "Test command", handler: async () => {}, }); } `; fs.writeFileSync(path.join(extensionsDir, "cmd-a.ts"), cmdCode("cmd-a")); fs.writeFileSync(path.join(extensionsDir, "cmd-b.ts"), cmdCode("cmd-b")); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const commands = runner.getRegisteredCommands(new Set(["cmd-a"])); const diagnostics = runner.getCommandDiagnostics(); expect(commands.length).toBe(1); expect(commands.map((c) => c.name).sort()).toEqual(["cmd-b"]); expect(diagnostics.length).toBe(1); expect(diagnostics[0].path).toEqual(path.join(extensionsDir, "cmd-a.ts")); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in command")); warnSpy.mockRestore(); }); }); describe("error handling", () => { it("calls error listeners when handler throws", async () => { const extCode = ` export default function(pi) { pi.on("context", async () => { throw new Error("Handler error!"); }); } `; fs.writeFileSync(path.join(extensionsDir, "throws.ts"), extCode); const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const errors: Array<{ extensionPath: string; event: string; error: string }> = []; runner.onError((err) => { errors.push(err); }); // Emit context event which will trigger the throwing handler await runner.emitContext([]); expect(errors.length).toBe(1); expect(errors[0].error).toContain("Handler error!"); expect(errors[0].event).toBe("context"); }); }); describe("message renderers", () => { it("gets message renderer by type", async () => { const extCode = ` export default function(pi) { pi.registerMessageRenderer("my-type", (message, options, theme) => null); } `; fs.writeFileSync(path.join(extensionsDir, "renderer.ts"), extCode); const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const renderer = runner.getMessageRenderer("my-type"); expect(renderer).toBeDefined(); const missing = runner.getMessageRenderer("not-exists"); expect(missing).toBeUndefined(); }); }); describe("flags", () => { it("collects flags from extensions", async () => { const extCode = ` export default function(pi) { pi.registerFlag("my-flag", { description: "My flag", handler: async () => {}, }); } `; fs.writeFileSync(path.join(extensionsDir, "with-flag.ts"), extCode); const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const flags = runner.getFlags(); expect(flags.has("my-flag")).toBe(true); }); it("can set flag values", async () => { const extCode = ` export default function(pi) { pi.registerFlag("test-flag", { description: "Test flag", handler: async () => {}, }); } `; fs.writeFileSync(path.join(extensionsDir, "flag.ts"), extCode); const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); // Setting a flag value should not throw runner.setFlagValue("--test-flag", true); // The flag values are stored in the shared runtime expect(result.runtime.flagValues.get("--test-flag")).toBe(true); }); }); describe("tool_result chaining", () => { it("chains content modifications across handlers", async () => { const extCode1 = ` export default function(pi) { pi.on("tool_result", async (event) => { return { content: [...event.content, { type: "text", text: "ext1" }], }; }); } `; const extCode2 = ` export default function(pi) { pi.on("tool_result", async (event) => { return { content: [...event.content, { type: "text", text: "ext2" }], }; }); } `; fs.writeFileSync(path.join(extensionsDir, "tool-result-1.ts"), extCode1); fs.writeFileSync(path.join(extensionsDir, "tool-result-2.ts"), extCode2); const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const chained = await runner.emitToolResult({ type: "tool_result", toolName: "my_tool", toolCallId: "call-1", input: {}, content: [{ type: "text", text: "base" }], details: { initial: true }, isError: false, }); expect(chained).toBeDefined(); const chainedContent = chained?.content; expect(chainedContent).toBeDefined(); expect(chainedContent![0]).toEqual({ type: "text", text: "base" }); expect(chainedContent).toHaveLength(3); const appendedText = chainedContent! .slice(1) .filter((item): item is { type: "text"; text: string } => item.type === "text") .map((item) => item.text); expect(appendedText.sort()).toEqual(["ext1", "ext2"]); }); it("preserves previous modifications when later handlers return partial patches", async () => { const extCode1 = ` export default function(pi) { pi.on("tool_result", async () => { return { content: [{ type: "text", text: "first" }], details: { source: "ext1" }, }; }); } `; const extCode2 = ` export default function(pi) { pi.on("tool_result", async () => { return { isError: true, }; }); } `; fs.writeFileSync(path.join(extensionsDir, "tool-result-partial-1.ts"), extCode1); fs.writeFileSync(path.join(extensionsDir, "tool-result-partial-2.ts"), extCode2); const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const chained = await runner.emitToolResult({ type: "tool_result", toolName: "my_tool", toolCallId: "call-2", input: {}, content: [{ type: "text", text: "base" }], details: { initial: true }, isError: false, }); expect(chained).toEqual({ content: [{ type: "text", text: "first" }], details: { source: "ext1" }, isError: true, }); }); }); describe("hasHandlers", () => { it("returns true when handlers exist for event type", async () => { const extCode = ` export default function(pi) { pi.on("tool_call", async () => undefined); } `; fs.writeFileSync(path.join(extensionsDir, "handler.ts"), extCode); const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); expect(runner.hasHandlers("tool_call")).toBe(true); expect(runner.hasHandlers("agent_end")).toBe(false); }); }); });