mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-20 07:04:34 +00:00
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:
parent
9794868b38
commit
c6fc084534
112 changed files with 2842 additions and 6747 deletions
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -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();
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
270
packages/coding-agent/test/extensions-runner.test.ts
Normal file
270
packages/coding-agent/test/extensions-runner.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue