mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 08:03:39 +00:00
# Conflicts: # package-lock.json # packages/ai/CHANGELOG.md # packages/coding-agent/CHANGELOG.md
662 lines
22 KiB
TypeScript
662 lines
22 KiB
TypeScript
/**
|
|
* 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 { createExtensionRuntime, discoverAndLoadExtensions } from "../src/core/extensions/loader.js";
|
|
import { ExtensionRunner } from "../src/core/extensions/runner.js";
|
|
import type { ExtensionActions, ExtensionContextActions, ProviderConfig } from "../src/core/extensions/types.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 });
|
|
});
|
|
|
|
const providerModelConfig: ProviderConfig = {
|
|
baseUrl: "https://provider.test/v1",
|
|
apiKey: "PROVIDER_TEST_KEY",
|
|
api: "openai-completions",
|
|
models: [
|
|
{
|
|
id: "instant-model",
|
|
name: "Instant Model",
|
|
reasoning: false,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 4096,
|
|
},
|
|
],
|
|
};
|
|
|
|
const extensionActions: ExtensionActions = {
|
|
sendMessage: () => {},
|
|
sendUserMessage: () => {},
|
|
appendEntry: () => {},
|
|
setSessionName: () => {},
|
|
getSessionName: () => undefined,
|
|
setLabel: () => {},
|
|
getActiveTools: () => [],
|
|
getAllTools: () => [],
|
|
setActiveTools: () => {},
|
|
getCommands: () => [],
|
|
setModel: async () => false,
|
|
getThinkingLevel: () => "off",
|
|
setThinkingLevel: () => {},
|
|
};
|
|
|
|
const extensionContextActions: ExtensionContextActions = {
|
|
getModel: () => undefined,
|
|
isIdle: () => true,
|
|
abort: () => {},
|
|
hasPendingMessages: () => false,
|
|
shutdown: () => {},
|
|
getContextUsage: () => undefined,
|
|
compact: () => {},
|
|
getSystemPrompt: () => "",
|
|
};
|
|
|
|
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"]);
|
|
});
|
|
|
|
it("keeps first tool when two extensions register the same name", async () => {
|
|
const first = `
|
|
import { Type } from "@sinclair/typebox";
|
|
export default function(pi) {
|
|
pi.registerTool({
|
|
name: "shared",
|
|
label: "shared",
|
|
description: "first",
|
|
parameters: Type.Object({}),
|
|
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
|
|
});
|
|
}
|
|
`;
|
|
const second = `
|
|
import { Type } from "@sinclair/typebox";
|
|
export default function(pi) {
|
|
pi.registerTool({
|
|
name: "shared",
|
|
label: "shared",
|
|
description: "second",
|
|
parameters: Type.Object({}),
|
|
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
|
|
});
|
|
}
|
|
`;
|
|
fs.writeFileSync(path.join(extensionsDir, "a-first.ts"), first);
|
|
fs.writeFileSync(path.join(extensionsDir, "b-second.ts"), second);
|
|
|
|
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
|
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
|
const tools = runner.getAllRegisteredTools();
|
|
|
|
expect(tools).toHaveLength(1);
|
|
expect(tools[0]?.definition.description).toBe("first");
|
|
});
|
|
});
|
|
|
|
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("keeps first flag when two extensions register the same name", async () => {
|
|
const first = `
|
|
export default function(pi) {
|
|
pi.registerFlag("shared-flag", {
|
|
description: "first",
|
|
type: "boolean",
|
|
default: true,
|
|
});
|
|
}
|
|
`;
|
|
const second = `
|
|
export default function(pi) {
|
|
pi.registerFlag("shared-flag", {
|
|
description: "second",
|
|
type: "boolean",
|
|
default: false,
|
|
});
|
|
}
|
|
`;
|
|
fs.writeFileSync(path.join(extensionsDir, "a-first.ts"), first);
|
|
fs.writeFileSync(path.join(extensionsDir, "b-second.ts"), second);
|
|
|
|
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
|
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
|
const flags = runner.getFlags();
|
|
|
|
expect(flags.get("shared-flag")?.description).toBe("first");
|
|
expect(result.runtime.flagValues.get("shared-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("provider registration", () => {
|
|
it("pre-bind unregister removes all queued registrations for a provider", () => {
|
|
const runtime = createExtensionRuntime();
|
|
|
|
runtime.registerProvider("queued-provider", providerModelConfig);
|
|
runtime.registerProvider("queued-provider", {
|
|
...providerModelConfig,
|
|
models: [
|
|
{
|
|
id: "instant-model-2",
|
|
name: "Instant Model 2",
|
|
reasoning: false,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 4096,
|
|
},
|
|
],
|
|
});
|
|
expect(runtime.pendingProviderRegistrations).toHaveLength(2);
|
|
|
|
runtime.unregisterProvider("queued-provider");
|
|
expect(runtime.pendingProviderRegistrations).toHaveLength(0);
|
|
});
|
|
|
|
it("post-bind register and unregister take effect immediately", () => {
|
|
const runtime = createExtensionRuntime();
|
|
const runner = new ExtensionRunner([], runtime, tempDir, sessionManager, modelRegistry);
|
|
|
|
runner.bindCore(extensionActions, extensionContextActions);
|
|
expect(runtime.pendingProviderRegistrations).toHaveLength(0);
|
|
|
|
runtime.registerProvider("instant-provider", providerModelConfig);
|
|
expect(runtime.pendingProviderRegistrations).toHaveLength(0);
|
|
expect(modelRegistry.find("instant-provider", "instant-model")).toBeDefined();
|
|
|
|
runtime.unregisterProvider("instant-provider");
|
|
expect(modelRegistry.find("instant-provider", "instant-model")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|