Merge hooks and custom-tools into unified extensions system (#454)

Breaking changes:
- Settings: 'hooks' and 'customTools' arrays replaced with 'extensions'
- CLI: '--hook' and '--tool' flags replaced with '--extension' / '-e'
- API: HookMessage renamed to CustomMessage, role 'hookMessage' to 'custom'
- API: FileSlashCommand renamed to PromptTemplate
- API: discoverSlashCommands() renamed to discoverPromptTemplates()
- Directories: commands/ renamed to prompts/ for prompt templates

Migration:
- Session version bumped to 3 (auto-migrates v2 sessions)
- Old 'hookMessage' role entries converted to 'custom'

Structural changes:
- src/core/hooks/ and src/core/custom-tools/ merged into src/core/extensions/
- src/core/slash-commands.ts renamed to src/core/prompt-templates.ts
- examples/hooks/ and examples/custom-tools/ merged into examples/extensions/
- docs/hooks.md and docs/custom-tools.md merged into docs/extensions.md

New test coverage:
- test/extensions-runner.test.ts (10 tests)
- test/extensions-discovery.test.ts (26 tests)
- test/prompt-templates.test.ts
This commit is contained in:
Mario Zechner 2026-01-05 01:43:35 +01:00
parent 9794868b38
commit c6fc084534
112 changed files with 2842 additions and 6747 deletions

View file

@ -133,15 +133,20 @@ describe("parseArgs", () => {
});
});
describe("--hook flag", () => {
test("parses single --hook", () => {
const result = parseArgs(["--hook", "./my-hook.ts"]);
expect(result.hooks).toEqual(["./my-hook.ts"]);
describe("--extension flag", () => {
test("parses single --extension", () => {
const result = parseArgs(["--extension", "./my-extension.ts"]);
expect(result.extensions).toEqual(["./my-extension.ts"]);
});
test("parses multiple --hook flags", () => {
const result = parseArgs(["--hook", "./hook1.ts", "--hook", "./hook2.ts"]);
expect(result.hooks).toEqual(["./hook1.ts", "./hook2.ts"]);
test("parses -e shorthand", () => {
const result = parseArgs(["-e", "./my-extension.ts"]);
expect(result.extensions).toEqual(["./my-extension.ts"]);
});
test("parses multiple --extension flags", () => {
const result = parseArgs(["--extension", "./ext1.ts", "-e", "./ext2.ts"]);
expect(result.extensions).toEqual(["./ext1.ts", "./ext2.ts"]);
});
});

View file

@ -1,14 +1,14 @@
/**
* Verify the documentation example from hooks.md compiles and works.
* Verify the documentation example from extensions.md compiles and works.
*/
import { describe, expect, it } from "vitest";
import type { HookAPI, SessionBeforeCompactEvent, SessionCompactEvent } from "../src/core/hooks/index.js";
import type { ExtensionAPI, SessionBeforeCompactEvent, SessionCompactEvent } from "../src/core/extensions/index.js";
describe("Documentation example", () => {
it("custom compaction example should type-check correctly", () => {
// This is the example from hooks.md - verify it compiles
const exampleHook = (pi: HookAPI) => {
// This is the example from extensions.md - verify it compiles
const exampleExtension = (pi: ExtensionAPI) => {
pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, ctx) => {
// All these should be accessible on the event
const { preparation, branchEntries } = event;
@ -32,7 +32,7 @@ describe("Documentation example", () => {
.map((m) => `- ${typeof m.content === "string" ? m.content.slice(0, 100) : "[complex]"}`)
.join("\n");
// Hooks return compaction content - SessionManager adds id/parentId
// Extensions return compaction content - SessionManager adds id/parentId
return {
compaction: {
summary: `User requests:\n${summary}`,
@ -44,20 +44,20 @@ describe("Documentation example", () => {
};
// Just verify the function exists and is callable
expect(typeof exampleHook).toBe("function");
expect(typeof exampleExtension).toBe("function");
});
it("compact event should have correct fields", () => {
const checkCompactEvent = (pi: HookAPI) => {
const checkCompactEvent = (pi: ExtensionAPI) => {
pi.on("session_compact", async (event: SessionCompactEvent) => {
// These should all be accessible
const entry = event.compactionEntry;
const fromHook = event.fromHook;
const fromExtension = event.fromExtension;
expect(entry.type).toBe("compaction");
expect(typeof entry.summary).toBe("string");
expect(typeof entry.tokensBefore).toBe("number");
expect(typeof fromHook).toBe("boolean");
expect(typeof fromExtension).toBe("boolean");
});
};

View file

@ -1,5 +1,5 @@
/**
* Tests for compaction hook events (before_compact / compact).
* Tests for compaction extension events (before_compact / compact).
*/
import { existsSync, mkdirSync, rmSync } from "node:fs";
@ -11,12 +11,12 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AgentSession } from "../src/core/agent-session.js";
import { AuthStorage } from "../src/core/auth-storage.js";
import {
HookRunner,
type LoadedHook,
ExtensionRunner,
type LoadedExtension,
type SessionBeforeCompactEvent,
type SessionCompactEvent,
type SessionEvent,
} from "../src/core/hooks/index.js";
} from "../src/core/extensions/index.js";
import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js";
@ -25,14 +25,14 @@ import { theme } from "../src/modes/interactive/theme/theme.js";
const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY;
describe.skipIf(!API_KEY)("Compaction hooks", () => {
describe.skipIf(!API_KEY)("Compaction extensions", () => {
let session: AgentSession;
let tempDir: string;
let hookRunner: HookRunner;
let extensionRunner: ExtensionRunner;
let capturedEvents: SessionEvent[];
beforeEach(() => {
tempDir = join(tmpdir(), `pi-compaction-hooks-test-${Date.now()}`);
tempDir = join(tmpdir(), `pi-compaction-extensions-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
capturedEvents = [];
});
@ -46,10 +46,10 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
}
});
function createHook(
function createExtension(
onBeforeCompact?: (event: SessionBeforeCompactEvent) => { cancel?: boolean; compaction?: any } | undefined,
onCompact?: (event: SessionCompactEvent) => void,
): LoadedHook {
): LoadedExtension {
const handlers = new Map<string, ((event: any, ctx: any) => Promise<any>)[]>();
handlers.set("session_before_compact", [
@ -73,9 +73,10 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
]);
return {
path: "test-hook",
resolvedPath: "/test/test-hook.ts",
path: "test-extension",
resolvedPath: "/test/test-extension.ts",
handlers,
tools: new Map(),
messageRenderers: new Map(),
commands: new Map(),
flags: new Map(),
@ -90,7 +91,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
};
}
function createSession(hooks: LoadedHook[]) {
function createSession(extensions: LoadedExtension[]) {
const model = getModel("anthropic", "claude-sonnet-4-5")!;
const agent = new Agent({
getApiKey: () => API_KEY,
@ -106,8 +107,8 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
const authStorage = new AuthStorage(join(tempDir, "auth.json"));
const modelRegistry = new ModelRegistry(authStorage);
hookRunner = new HookRunner(hooks, tempDir, sessionManager, modelRegistry);
hookRunner.initialize({
extensionRunner = new ExtensionRunner(extensions, tempDir, sessionManager, modelRegistry);
extensionRunner.initialize({
getModel: () => session.model,
sendMessageHandler: async () => {},
appendEntryHandler: async () => {},
@ -137,7 +138,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
agent,
sessionManager,
settingsManager,
hookRunner,
extensionRunner,
modelRegistry,
});
@ -145,8 +146,8 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
}
it("should emit before_compact and compact events", async () => {
const hook = createHook();
createSession([hook]);
const extension = createExtension();
createSession([extension]);
await session.prompt("What is 2+2? Reply with just the number.");
await session.agent.waitForIdle();
@ -177,12 +178,12 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
expect(afterEvent.compactionEntry).toBeDefined();
expect(afterEvent.compactionEntry.summary.length).toBeGreaterThan(0);
expect(afterEvent.compactionEntry.tokensBefore).toBeGreaterThanOrEqual(0);
expect(afterEvent.fromHook).toBe(false);
expect(afterEvent.fromExtension).toBe(false);
}, 120000);
it("should allow hooks to cancel compaction", async () => {
const hook = createHook(() => ({ cancel: true }));
createSession([hook]);
it("should allow extensions to cancel compaction", async () => {
const extension = createExtension(() => ({ cancel: true }));
createSession([extension]);
await session.prompt("What is 2+2? Reply with just the number.");
await session.agent.waitForIdle();
@ -193,10 +194,10 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
expect(compactEvents.length).toBe(0);
}, 120000);
it("should allow hooks to provide custom compaction", async () => {
const customSummary = "Custom summary from hook";
it("should allow extensions to provide custom compaction", async () => {
const customSummary = "Custom summary from extension";
const hook = createHook((event) => {
const extension = createExtension((event) => {
if (event.type === "session_before_compact") {
return {
compaction: {
@ -208,7 +209,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
}
return undefined;
});
createSession([hook]);
createSession([extension]);
await session.prompt("What is 2+2? Reply with just the number.");
await session.agent.waitForIdle();
@ -226,13 +227,13 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
const afterEvent = compactEvents[0];
if (afterEvent.type === "session_compact") {
expect(afterEvent.compactionEntry.summary).toBe(customSummary);
expect(afterEvent.fromHook).toBe(true);
expect(afterEvent.fromExtension).toBe(true);
}
}, 120000);
it("should include entries in compact event after compaction is saved", async () => {
const hook = createHook();
createSession([hook]);
const extension = createExtension();
createSession([extension]);
await session.prompt("What is 2+2? Reply with just the number.");
await session.agent.waitForIdle();
@ -251,17 +252,17 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
}
}, 120000);
it("should continue with default compaction if hook throws error", async () => {
const throwingHook: LoadedHook = {
path: "throwing-hook",
resolvedPath: "/test/throwing-hook.ts",
it("should continue with default compaction if extension throws error", async () => {
const throwingExtension: LoadedExtension = {
path: "throwing-extension",
resolvedPath: "/test/throwing-extension.ts",
handlers: new Map<string, ((event: any, ctx: any) => Promise<any>)[]>([
[
"session_before_compact",
[
async (event: SessionBeforeCompactEvent) => {
capturedEvents.push(event);
throw new Error("Hook intentionally throws");
throw new Error("Extension intentionally throws");
},
],
],
@ -275,6 +276,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
],
],
]),
tools: new Map(),
messageRenderers: new Map(),
commands: new Map(),
flags: new Map(),
@ -288,7 +290,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
setFlagValue: () => {},
};
createSession([throwingHook]);
createSession([throwingExtension]);
await session.prompt("What is 2+2? Reply with just the number.");
await session.agent.waitForIdle();
@ -300,21 +302,21 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
const compactEvents = capturedEvents.filter((e): e is SessionCompactEvent => e.type === "session_compact");
expect(compactEvents.length).toBe(1);
expect(compactEvents[0].fromHook).toBe(false);
expect(compactEvents[0].fromExtension).toBe(false);
}, 120000);
it("should call multiple hooks in order", async () => {
it("should call multiple extensions in order", async () => {
const callOrder: string[] = [];
const hook1: LoadedHook = {
path: "hook1",
resolvedPath: "/test/hook1.ts",
const extension1: LoadedExtension = {
path: "extension1",
resolvedPath: "/test/extension1.ts",
handlers: new Map<string, ((event: any, ctx: any) => Promise<any>)[]>([
[
"session_before_compact",
[
async () => {
callOrder.push("hook1-before");
callOrder.push("extension1-before");
return undefined;
},
],
@ -323,12 +325,13 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
"session_compact",
[
async () => {
callOrder.push("hook1-after");
callOrder.push("extension1-after");
return undefined;
},
],
],
]),
tools: new Map(),
messageRenderers: new Map(),
commands: new Map(),
flags: new Map(),
@ -342,15 +345,15 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
setFlagValue: () => {},
};
const hook2: LoadedHook = {
path: "hook2",
resolvedPath: "/test/hook2.ts",
const extension2: LoadedExtension = {
path: "extension2",
resolvedPath: "/test/extension2.ts",
handlers: new Map<string, ((event: any, ctx: any) => Promise<any>)[]>([
[
"session_before_compact",
[
async () => {
callOrder.push("hook2-before");
callOrder.push("extension2-before");
return undefined;
},
],
@ -359,12 +362,13 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
"session_compact",
[
async () => {
callOrder.push("hook2-after");
callOrder.push("extension2-after");
return undefined;
},
],
],
]),
tools: new Map(),
messageRenderers: new Map(),
commands: new Map(),
flags: new Map(),
@ -378,24 +382,24 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
setFlagValue: () => {},
};
createSession([hook1, hook2]);
createSession([extension1, extension2]);
await session.prompt("What is 2+2? Reply with just the number.");
await session.agent.waitForIdle();
await session.compact();
expect(callOrder).toEqual(["hook1-before", "hook2-before", "hook1-after", "hook2-after"]);
expect(callOrder).toEqual(["extension1-before", "extension2-before", "extension1-after", "extension2-after"]);
}, 120000);
it("should pass correct data in before_compact event", async () => {
let capturedBeforeEvent: SessionBeforeCompactEvent | null = null;
const hook = createHook((event) => {
const extension = createExtension((event) => {
capturedBeforeEvent = event;
return undefined;
});
createSession([hook]);
createSession([extension]);
await session.prompt("What is 2+2? Reply with just the number.");
await session.agent.waitForIdle();
@ -427,10 +431,10 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
expect(entries.length).toBeGreaterThan(0);
}, 120000);
it("should use hook compaction even with different values", async () => {
it("should use extension compaction even with different values", async () => {
const customSummary = "Custom summary with modified values";
const hook = createHook((event) => {
const extension = createExtension((event) => {
if (event.type === "session_before_compact") {
return {
compaction: {
@ -442,7 +446,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
}
return undefined;
});
createSession([hook]);
createSession([extension]);
await session.prompt("What is 2+2? Reply with just the number.");
await session.agent.waitForIdle();

View file

@ -1,9 +1,12 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { discoverAndLoadExtensions } from "../src/core/extensions/loader.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
describe("extensions discovery", () => {
let tempDir: string;
let extensionsDir: string;
@ -293,4 +296,151 @@ describe("extensions discovery", () => {
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(__dirname, "../examples/extensions/chalk-logger.ts");
const result = await discoverAndLoadExtensions([chalkLoggerPath], tempDir, tempDir);
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 () => {
// Load extension that has its own package.json and node_modules with 'ms' package
const extPath = path.resolve(__dirname, "../examples/extensions/with-deps");
const result = await discoverAndLoadExtensions([extPath], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].path).toContain("with-deps");
// The extension registers a 'parse_duration' tool
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 discoverAndLoadExtensions([], tempDir, tempDir);
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 discoverAndLoadExtensions([], tempDir, tempDir);
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 discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].error).toContain("must export a default 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 discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(2);
const allTools = new Set<string>();
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 discoverAndLoadExtensions([], tempDir, tempDir);
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 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 discoverAndLoadExtensions([], tempDir, tempDir);
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 discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].flags.has("--my-flag")).toBe(true);
});
});

View file

@ -0,0 +1,270 @@
/**
* 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 { 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 = new AuthStorage(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, tempDir, sessionManager, modelRegistry);
const shortcuts = runner.getShortcuts();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in"));
expect(shortcuts.has("ctrl+c")).toBe(false);
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, tempDir, sessionManager, modelRegistry);
const shortcuts = runner.getShortcuts();
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, 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, 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, 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();
});
});
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, 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, 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, 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, tempDir, sessionManager, modelRegistry);
// Setting a flag value should not throw
runner.setFlagValue("--test-flag", true);
// The flag values are stored in the extension's flagValues map
const ext = result.extensions[0];
expect(ext.flagValues.get("--test-flag")).toBe(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, tempDir, sessionManager, modelRegistry);
expect(runner.hasHandlers("tool_call")).toBe(true);
expect(runner.hasHandlers("agent_end")).toBe(false);
});
});
});

View file

@ -1,5 +1,5 @@
/**
* Tests for slash command argument parsing and substitution.
* Tests for prompt template argument parsing and substitution.
*
* Tests verify:
* - Argument parsing with quotes and special characters
@ -9,7 +9,7 @@
*/
import { describe, expect, test } from "vitest";
import { parseCommandArgs, substituteArgs } from "../src/core/slash-commands.js";
import { parseCommandArgs, substituteArgs } from "../src/core/prompt-templates.js";
// ============================================================================
// substituteArgs

View file

@ -24,8 +24,8 @@ describe("migrateSessionEntries", () => {
migrateSessionEntries(entries);
// Header should have version set
expect((entries[0] as any).version).toBe(2);
// Header should have version set (v3 is current after hookMessage->custom migration)
expect((entries[0] as any).version).toBe(3);
// Entries should have id/parentId
const msg1 = entries[1] as any;

View file

@ -9,7 +9,7 @@ describe("SessionManager.saveCustomEntry", () => {
const msgId = session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
// Save a custom entry
const customId = session.appendCustomEntry("my_hook", { foo: "bar" });
const customId = session.appendCustomEntry("my_data", { foo: "bar" });
// Save another message
const msg2Id = session.appendMessage({
@ -36,7 +36,7 @@ describe("SessionManager.saveCustomEntry", () => {
const customEntry = entries.find((e) => e.type === "custom") as CustomEntry;
expect(customEntry).toBeDefined();
expect(customEntry.customType).toBe("my_hook");
expect(customEntry.customType).toBe("my_data");
expect(customEntry.data).toEqual({ foo: "bar" });
expect(customEntry.id).toBe(customId);
expect(customEntry.parentId).toBe(msgId);

View file

@ -89,7 +89,7 @@ describe("SessionManager append and tree traversal", () => {
const session = SessionManager.inMemory();
const msgId = session.appendMessage(userMsg("hello"));
const customId = session.appendCustomEntry("my_hook", { key: "value" });
const customId = session.appendCustomEntry("my_data", { key: "value" });
const _msg2Id = session.appendMessage(assistantMsg("response"));
const entries = session.getEntries();
@ -97,7 +97,7 @@ describe("SessionManager append and tree traversal", () => {
expect(customEntry).toBeDefined();
expect(customEntry.id).toBe(customId);
expect(customEntry.parentId).toBe(msgId);
expect(customEntry.customType).toBe("my_hook");
expect(customEntry.customType).toBe("my_data");
expect(customEntry.data).toEqual({ key: "value" });
expect(entries[2].parentId).toBe(customId);