clanker-agent/packages/coding-agent/test/extensions-runner.test.ts
Harivansh Rathi 536241053c refactor: finish companion rename migration
Complete the remaining pi-to-companion rename across companion-os, web, vm-orchestrator, docker, and archived fixtures.

Verification:
- semantic rg sweeps for Pi/piConfig/getPi/.pi runtime references
- npm run check in apps/companion-os (fails in this worktree: biome not found)

Co-authored-by: Codex <noreply@openai.com>
2026-03-10 07:39:32 -05:00

856 lines
25 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(), "companion-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: () => {},
refreshTools: () => {},
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(companion) {
companion.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(companion) {
companion.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(companion) {
companion.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(companion) {
companion.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(companion) {
companion.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(companion) {
companion.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(companion) {
companion.registerShortcut("ctrl+shift+x", {
description: "First extension",
handler: async () => {},
});
}
`;
const extCode2 = `
export default function(companion) {
companion.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(companion) {
companion.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(companion) {
companion.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(companion) {
companion.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(companion) {
companion.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(companion) {
companion.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(companion) {
companion.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(companion) {
companion.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(companion) {
companion.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(companion) {
companion.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(companion) {
companion.registerFlag("shared-flag", {
description: "first",
type: "boolean",
default: true,
});
}
`;
const second = `
export default function(companion) {
companion.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(companion) {
companion.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(companion) {
companion.on("tool_result", async (event) => {
return {
content: [...event.content, { type: "text", text: "ext1" }],
};
});
}
`;
const extCode2 = `
export default function(companion) {
companion.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(companion) {
companion.on("tool_result", async () => {
return {
content: [{ type: "text", text: "first" }],
details: { source: "ext1" },
};
});
}
`;
const extCode2 = `
export default function(companion) {
companion.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(companion) {
companion.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);
});
});
});