mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-16 12:03:23 +00:00
move pi-mono into companion-cloud as apps/companion-os
- Copy all pi-mono source into apps/companion-os/ - Update Dockerfile to COPY pre-built binary instead of downloading from GitHub Releases - Update deploy-staging.yml to build pi from source (bun compile) before Docker build - Add apps/companion-os/** to path triggers - No more cross-repo dispatch needed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
0250f72976
579 changed files with 206942 additions and 0 deletions
856
packages/coding-agent/test/extensions-runner.test.ts
Normal file
856
packages/coding-agent/test/extensions-runner.test.ts
Normal file
|
|
@ -0,0 +1,856 @@
|
|||
/**
|
||||
* 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: () => {},
|
||||
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(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue