Files
oh-my-pi/packages/coding-agent/test/extensions-runner.test.ts
T
2026-08-02 20:57:06 +02:00

3261 lines
101 KiB
TypeScript

/**
* Tests for ExtensionRunner - conflict detection, error handling, tool wrapping.
*/
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, expectTypeOf, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core";
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { discoverAndLoadExtensions, ExtensionRuntime } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader";
import {
EXTENSION_HANDLER_TIMEOUT_MS,
ExtensionRunner,
testSetExtensionHandlerTimeoutMs,
} from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner";
import type {
ExtensionError,
ExtensionServiceTier,
ExtensionUIContext,
} from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types";
import { ExtensionToolWrapper } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/wrapper";
import { Type } from "@oh-my-pi/pi-coding-agent/extensibility/typebox";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { getProjectAgentDir, logger, TempDir } from "@oh-my-pi/pi-utils";
describe("ExtensionRunner", () => {
let tempDir: TempDir;
let extensionsDir: string;
let sessionManager: SessionManager;
// Shared immutable fixtures. ModelRegistry's constructor synchronously loads
// every bundled model and rebuilds the canonical index (~100ms); these tests
// never mutate the registry or auth storage, so build them once per file
// instead of paying that cost in every beforeEach.
let sharedTempDir: TempDir;
let modelRegistry: ModelRegistry;
let authStorage: AuthStorage;
beforeAll(async () => {
sharedTempDir = TempDir.createSync("@pi-runner-shared-");
authStorage = await AuthStorage.create(path.join(sharedTempDir.path(), "testauth.db"));
modelRegistry = new ModelRegistry(authStorage);
});
afterAll(() => {
authStorage.close();
sharedTempDir.removeSync();
});
beforeEach(() => {
tempDir = TempDir.createSync("@pi-runner-test-");
extensionsDir = path.join(getProjectAgentDir(tempDir.path()), "extensions");
fs.mkdirSync(extensionsDir, { recursive: true });
sessionManager = SessionManager.inMemory();
});
afterEach(() => {
testSetExtensionHandlerTimeoutMs(EXTENSION_HANDLER_TIMEOUT_MS);
tempDir.removeSync();
});
const loadTestExtensions = async (configuredPaths: string[] = []) => {
const result = await discoverAndLoadExtensions([extensionsDir, ...configuredPaths], tempDir.path());
const testRoots = [
extensionsDir,
...configuredPaths.map(configuredPath => path.resolve(tempDir.path(), configuredPath)),
];
const isTestScoped = (candidate: string): boolean =>
testRoots.some(root => {
const relative = path.relative(path.resolve(root), path.resolve(candidate));
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
});
return {
...result,
extensions: result.extensions.filter(extension => isTestScoped(extension.path)),
errors: result.errors.filter(error => isTestScoped(error.path)),
};
};
it("exposes caller localProtocolOptions through extension context", async () => {
const localProtocolOptions = {
getArtifactsDir: () => tempDir.join("artifacts"),
getSessionId: () => "runner-session",
};
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
undefined,
undefined,
localProtocolOptions,
);
expect(runner.createContext().localProtocolOptions).toBe(localProtocolOptions);
});
it("reflects SessionManager.moveTo() changes instead of the constructor-time snapshot (/move)", async () => {
const dirA = tempDir.join("dirA");
const dirB = tempDir.join("dirB");
fs.mkdirSync(dirA, { recursive: true });
fs.mkdirSync(dirB, { recursive: true });
const movableSessionManager = SessionManager.inMemory(dirA);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(result.extensions, result.runtime, dirA, movableSessionManager, modelRegistry);
expect(runner.cwd).toBe(dirA);
expect(runner.createContext().cwd).toBe(dirA);
await movableSessionManager.moveTo(dirB);
expect(runner.cwd).toBe(dirB);
expect(runner.createContext().cwd).toBe(dirB);
});
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(logger, "warn").mockImplementation(() => {});
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const shortcuts = runner.getShortcuts();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in"), expect.any(Object));
expect(shortcuts.has("ctrl+c")).toBe(false);
warnSpy.mockRestore();
});
it("rejects ctrl+q so it cannot shadow the app.message.followUp default (#1903)", async () => {
const extCode = `
export default function(pi) {
pi.registerShortcut("ctrl+q", {
description: "Tries to bind the follow-up chord",
handler: async () => {},
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "conflict-q.ts"), extCode);
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const shortcuts = runner.getShortcuts();
// Contract: ctrl+q is reserved because it is now a default chord for
// app.message.followUp. Without this guard, InputController registers
// the extension shortcut first and the follow-up handler silently
// overwrites it in the editor's custom-key map.
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in"), expect.any(Object));
expect(shortcuts.has("ctrl+q")).toBe(false);
warnSpy.mockRestore();
});
it("rejects Alt+M so it cannot shadow the app.model.select default", async () => {
const extCode = `
export default function(pi) {
pi.registerShortcut("alt+m", {
description: "Tries to bind model select",
handler: async () => {},
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "conflict-model.ts"), extCode);
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const shortcuts = runner.getShortcuts();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in"), expect.any(Object));
expect(shortcuts.has("alt+m")).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(logger, "warn").mockImplementation(() => {});
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const shortcuts = runner.getShortcuts();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("shortcut conflict"), expect.any(Object));
// 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) => `
export default function(pi) {
const { Type } = pi.typebox;
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 loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
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 loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
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 loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
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("prefers later-loaded explicit extensions for conflicting commands", async () => {
const deployCommand = (description: string) => `
export default function(pi) {
pi.registerCommand("deploy", {
description: "${description}",
handler: async () => {},
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "discovered-deploy.ts"), deployCommand("Discovered deploy"));
const explicitExtensionPath = path.join(tempDir.path(), "explicit-deploy.ts");
fs.writeFileSync(explicitExtensionPath, deployCommand("Explicit deploy"));
const result = await loadTestExtensions([explicitExtensionPath]);
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const commands = runner.getRegisteredCommands();
expect(commands).toHaveLength(1);
expect(commands[0]?.description).toBe("Explicit deploy");
const command = runner.getCommand("deploy");
expect(command?.description).toBe("Explicit deploy");
});
});
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 loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
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 loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const renderer = runner.getMessageRenderer("my-type");
expect(renderer).toBeDefined();
const missing = runner.getMessageRenderer("not-exists");
expect(missing).toBeUndefined();
});
it("collects assistant thinking renderers", async () => {
const extCode = `
export default function(pi) {
pi.registerAssistantThinkingRenderer((context, theme) => null);
}
`;
fs.writeFileSync(path.join(extensionsDir, "thinking-renderer.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
expect(runner.getAssistantThinkingRenderers().length).toBe(1);
});
});
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 loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
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 loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
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("before_provider_request chaining", () => {
it("exposes the request model instead of the primary session model", async () => {
const primaryModel = getBundledModel("openai-codex", "gpt-5.6-sol");
const requestModel = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!primaryModel || !requestModel) throw new Error("Expected bundled cross-provider models to exist");
const extCode = `
export default function(pi) {
pi.on("before_provider_request", async (_event, ctx) => {
const current = ctx.models.current();
return {
model: ctx.model && {
provider: ctx.model.provider,
id: ctx.model.id,
api: ctx.model.api,
},
current: current && {
provider: current.provider,
id: current.id,
api: current.api,
},
};
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "request-model.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
runner.initialize(
{
sendMessage: () => {},
sendUserMessage: () => {},
appendEntry: () => {},
setLabel: () => {},
getActiveTools: () => [],
getAllTools: () => [],
setActiveTools: async () => {},
getCommands: () => [],
setModel: async () => false,
getThinkingLevel: () => undefined,
setThinkingLevel: () => {},
getSessionName: () => undefined,
setSessionName: async () => {},
},
{
getModel: () => primaryModel,
isIdle: () => true,
abort: () => {},
hasPendingMessages: () => false,
shutdown: () => {},
getContextUsage: () => undefined,
compact: async () => {},
getSystemPrompt: () => [],
},
);
const payload = await runner.emitBeforeProviderRequest({}, requestModel);
const expected = {
provider: requestModel.provider,
id: requestModel.id,
api: requestModel.api,
};
expect(payload).toEqual({ model: expected, current: expected });
});
it("chains payload replacements across handlers in load order", async () => {
const extCode1 = `
export default function(pi) {
pi.on("before_provider_request", async (event) => {
const payload = event.payload as { chain?: string[] };
return { ...payload, chain: [...(payload.chain ?? []), "ext1"] };
});
}
`;
const extCode2 = `
export default function(pi) {
pi.on("before_provider_request", async (event) => {
const payload = event.payload as { chain?: string[] };
return { ...payload, chain: [...(payload.chain ?? []), "ext2"] };
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "payload-1.ts"), extCode1);
fs.writeFileSync(path.join(extensionsDir, "payload-2.ts"), extCode2);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const payload = await runner.emitBeforeProviderRequest({ chain: ["base"] });
expect(payload).toEqual({ chain: ["base", "ext1", "ext2"] });
});
it("keeps chaining after handler errors", async () => {
const extCode1 = `
export default function(pi) {
pi.on("before_provider_request", async () => {
throw new Error("payload failed");
});
}
`;
const extCode2 = `
export default function(pi) {
pi.on("before_provider_request", async (event) => {
const payload = event.payload as { preserved?: boolean };
return { ...payload, preserved: true };
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "payload-error.ts"), extCode1);
fs.writeFileSync(path.join(extensionsDir, "payload-ok.ts"), extCode2);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const errors: Array<{ extensionPath: string; event: string; error: string }> = [];
runner.onError(err => {
errors.push(err);
});
const payload = await runner.emitBeforeProviderRequest({ original: true });
expect(payload).toEqual({ original: true, preserved: true });
expect(errors).toHaveLength(1);
expect(errors[0]?.event).toBe("before_provider_request");
expect(errors[0]?.error).toContain("payload failed");
});
});
describe("after_provider_response", () => {
it("calls handlers with response metadata and reports handler errors without throwing", async () => {
const eventsPath = path.join(tempDir.path(), "after-provider-response-events.jsonl");
const extCode = `
import * as fs from "node:fs";
export default function(pi) {
pi.on("after_provider_response", async (event) => {
fs.appendFileSync(
${JSON.stringify(eventsPath)},
JSON.stringify({
status: event.status,
headers: event.headers,
requestId: event.requestId,
metadata: event.metadata,
}) + "\\n",
);
});
pi.on("after_provider_response", async () => {
throw new Error("response failed");
});
pi.on("after_provider_response", async (event) => {
fs.appendFileSync(
${JSON.stringify(eventsPath)},
JSON.stringify({ afterError: event.status }) + "\\n",
);
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "after-provider-response.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const errors: Array<{ extensionPath: string; event: string; error: string }> = [];
runner.onError(err => {
errors.push(err);
});
await runner.emitAfterProviderResponse({
status: 202,
headers: { "x-request-id": "req_123", "content-type": "text/event-stream" },
requestId: "req_123",
metadata: { provider: "test" },
});
const events = fs
.readFileSync(eventsPath, "utf8")
.trim()
.split("\n")
.map(line => JSON.parse(line));
expect(events).toEqual([
{
status: 202,
headers: { "x-request-id": "req_123", "content-type": "text/event-stream" },
requestId: "req_123",
metadata: { provider: "test" },
},
{ afterError: 202 },
]);
expect(errors).toHaveLength(1);
expect(errors[0]?.event).toBe("after_provider_response");
expect(errors[0]?.error).toContain("response failed");
});
});
describe("session_stop", () => {
it("invokes handlers with completed main-session messages and returns continuation feedback", async () => {
const eventsPath = path.join(tempDir.path(), "session-stop-events.jsonl");
const extCode = `
import * as fs from "node:fs";
export default function(pi) {
pi.on("session_stop", async (event) => {
fs.appendFileSync(
${JSON.stringify(eventsPath)},
JSON.stringify({
type: event.type,
messages: event.messages,
turn_id: event.turn_id,
last_assistant_message: event.last_assistant_message,
session_id: event.session_id,
session_file: event.session_file,
stop_hook_active: event.stop_hook_active,
}) + "\\n",
);
return { continue: true, additionalContext: "Run one more pass." };
});
}
`;
await Bun.write(path.join(extensionsDir, "session-stop.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const completedMessage: AgentMessage = {
role: "assistant",
content: [{ type: "text", text: "main session finished" }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 123,
};
const stopResult = await runner.emitSessionStop({
messages: [completedMessage],
turn_id: 2,
last_assistant_message: completedMessage,
session_id: "session-123",
session_file: "/tmp/session.jsonl",
stop_hook_active: false,
signal: new AbortController().signal,
});
const events = (await Bun.file(eventsPath).text())
.trim()
.split("\n")
.map(line => JSON.parse(line));
expect(events).toEqual([
{
type: "session_stop",
messages: [completedMessage],
turn_id: 2,
last_assistant_message: completedMessage,
session_id: "session-123",
session_file: "/tmp/session.jsonl",
stop_hook_active: false,
},
]);
expect(stopResult).toEqual({ continue: true, additionalContext: "Run one more pass." });
});
it("skips cancelled handlers, releases in-flight handlers, and preserves timeout errors", async () => {
const extensionPath = path.join(tempDir.path(), "cancel-session-stop.ts");
const startedPath = path.join(tempDir.path(), "session-stop-started.txt");
await Bun.write(
extensionPath,
`
import * as fs from "node:fs";
export default function(pi) {
pi.on("session_stop", async () => {
fs.writeFileSync(${JSON.stringify(startedPath)}, "started");
await Promise.withResolvers().promise;
});
}
`,
);
const result = await loadTestExtensions([extensionPath]);
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const errors: ExtensionError[] = [];
runner.onError(error => errors.push(error));
testSetExtensionHandlerTimeoutMs(100);
const controller = new AbortController();
const preAborted = new AbortController();
preAborted.abort();
await expect(
runner.emitSessionStop({
messages: [],
turn_id: 0,
session_id: "session-123",
stop_hook_active: false,
signal: preAborted.signal,
}),
).resolves.toBeUndefined();
expect(await Bun.file(startedPath).exists()).toBe(false);
const emission = runner.emitSessionStop({
messages: [],
turn_id: 0,
session_id: "session-123",
stop_hook_active: false,
signal: controller.signal,
});
expect(await Bun.file(startedPath).text()).toBe("started");
controller.abort();
await expect(emission).resolves.toBeUndefined();
expect(errors).toEqual([]);
// A non-cancelled handler still exercises the production timer and reports its timeout.
testSetExtensionHandlerTimeoutMs(10);
await expect(
runner.emitSessionStop({
messages: [],
turn_id: 1,
session_id: "session-123",
stop_hook_active: false,
signal: new AbortController().signal,
}),
).resolves.toBeUndefined();
expect(errors).toEqual([
{
extensionPath,
event: "session_stop",
error: "handler timed out after 10ms",
},
]);
});
it("observes a session_stop signal aborted synchronously by the handler", async () => {
const extensionPath = path.join(tempDir.path(), "self-cancel-session-stop.ts");
await Bun.write(
extensionPath,
`
export default function(pi) {
pi.on("session_stop", async (_event, ctx) => {
ctx.abort();
await Promise.withResolvers().promise;
});
}
`,
);
const result = await loadTestExtensions([extensionPath]);
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const controller = new AbortController();
runner.initialize(
{
sendMessage: () => {},
sendUserMessage: () => {},
appendEntry: () => {},
setLabel: () => {},
getActiveTools: () => [],
getAllTools: () => [],
setActiveTools: async () => {},
getCommands: () => [],
setModel: async () => false,
getThinkingLevel: () => undefined,
setThinkingLevel: () => {},
getSessionName: () => undefined,
setSessionName: async () => {},
},
{
getModel: () => undefined,
isIdle: () => true,
abort: () => controller.abort(),
hasPendingMessages: () => false,
shutdown: () => {},
getContextUsage: () => undefined,
compact: async () => {},
getSystemPrompt: () => [],
},
);
vi.useFakeTimers();
try {
testSetExtensionHandlerTimeoutMs(100);
const emission = runner.emitSessionStop({
messages: [],
turn_id: 0,
session_id: "session-123",
stop_hook_active: false,
signal: controller.signal,
});
let settled = false;
void emission.then(() => {
settled = true;
});
for (let attempts = 0; attempts < 10 && !settled; attempts++) {
await Promise.resolve();
}
expect(controller.signal.aborted).toBe(true);
expect(settled).toBe(true);
await emission;
} finally {
vi.useRealTimers();
}
});
it("continues to later handlers after empty continuation feedback", async () => {
await Bun.write(
path.join(extensionsDir, "session-stop-empty.ts"),
`
export default function(pi) {
pi.on("session_stop", async () => ({ continue: true }));
pi.on("session_stop", async () => ({ decision: "block", reason: "Continue from second handler." }));
}
`,
);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const completedMessage: AgentMessage = {
role: "assistant",
content: [{ type: "text", text: "main session finished" }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 123,
};
await expect(
runner.emitSessionStop({
messages: [completedMessage],
turn_id: 0,
last_assistant_message: completedMessage,
signal: new AbortController().signal,
session_id: "session-123",
session_file: "/tmp/session.jsonl",
stop_hook_active: false,
}),
).resolves.toEqual({ decision: "block", reason: "Continue from second handler." });
});
});
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 loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
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 loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
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("tool_result rewrite of thrown failures", () => {
const throwingTool: AgentTool = {
name: "boom",
label: "Boom",
description: "always throws",
parameters: {} as never,
execute: async () => {
throw new Error("original explosion");
},
};
const okTool: AgentTool = {
name: "fine",
label: "Fine",
description: "always succeeds",
parameters: {} as never,
execute: async () => ({ content: [{ type: "text" as const, text: "success" }] }),
};
const firstText = (result: { content: readonly (TextContent | ImageContent)[] }): string | undefined => {
const block = result.content[0];
return block?.type === "text" ? block.text : undefined;
};
const runnerFor = async (extCode: string): Promise<ExtensionRunner> => {
fs.writeFileSync(path.join(extensionsDir, "rewrite.ts"), extCode);
const result = await loadTestExtensions();
return new ExtensionRunner(result.extensions, result.runtime, tempDir.path(), sessionManager, modelRegistry);
};
it("surfaces replacement content while keeping the call an error", async () => {
const runner = await runnerFor(`
export default function(pi) {
pi.on("tool_result", (event) => {
if (!event.isError) return;
return {
content: [{ type: "text", text: "Enriched recovery guidance" }],
details: { enriched: true },
isError: true,
};
});
}
`);
const wrapper = new ExtensionToolWrapper(throwingTool, runner);
const res = await wrapper.execute("call-rewrite", {} as never, undefined, undefined, undefined);
expect(firstText(res)).toBe("Enriched recovery guidance");
expect(res.isError).toBe(true);
expect(res.details).toEqual({ enriched: true });
});
it("preserves the original exception when no handler modifies the result", async () => {
const runner = await runnerFor(`
export default function(pi) {
pi.on("tool_result", () => {});
}
`);
const wrapper = new ExtensionToolWrapper(throwingTool, runner);
await expect(wrapper.execute("call-untouched", {} as never, undefined, undefined, undefined)).rejects.toThrow(
"original explosion",
);
});
it("converts a failure to success when a handler clears isError", async () => {
const runner = await runnerFor(`
export default function(pi) {
pi.on("tool_result", (event) => {
if (!event.isError) return;
return { content: [{ type: "text", text: "recovered" }], isError: false };
});
}
`);
const wrapper = new ExtensionToolWrapper(throwingTool, runner);
const res = await wrapper.execute("call-cleared", {} as never, undefined, undefined, undefined);
expect(firstText(res)).toBe("recovered");
expect(res.isError).toBeUndefined();
});
it("marks a successful result as an error when a handler sets isError", async () => {
const runner = await runnerFor(`
export default function(pi) {
pi.on("tool_result", () => ({
content: [{ type: "text", text: "now failing" }],
isError: true,
}));
}
`);
const wrapper = new ExtensionToolWrapper(okTool, runner);
const res = await wrapper.execute("call-flagged", {} as never, undefined, undefined, undefined);
expect(firstText(res)).toBe("now failing");
expect(res.isError).toBe(true);
});
});
describe("handler timeouts", () => {
it("times out session_start handlers, emits an error, and continues to sibling extensions", async () => {
const hangExtensionPath = path.join(tempDir.path(), "hang-session-start.ts");
const fastExtensionPath = path.join(tempDir.path(), "fast-session-start.ts");
const markerPath = path.join(tempDir.path(), "session-start-marker.txt");
fs.writeFileSync(
hangExtensionPath,
`
export default function(pi) {
pi.on("session_start", async () => {
await Promise.withResolvers().promise;
});
}
`,
);
fs.writeFileSync(
fastExtensionPath,
`
import * as fs from "node:fs";
export default function(pi) {
pi.on("session_start", async () => {
fs.appendFileSync(${JSON.stringify(markerPath)}, "fast\\n");
});
}
`,
);
const result = await loadTestExtensions([hangExtensionPath, fastExtensionPath]);
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
const errors: Array<{ extensionPath: string; event: string; error: string }> = [];
runner.onError(err => {
errors.push(err);
});
testSetExtensionHandlerTimeoutMs(10);
const startedAt = performance.now();
await runner.emit({ type: "session_start" });
const elapsedMs = performance.now() - startedAt;
expect(elapsedMs).toBeGreaterThanOrEqual(8);
expect(elapsedMs).toBeLessThan(150);
expect(fs.readFileSync(markerPath, "utf8")).toBe("fast\n");
expect(warnSpy).toHaveBeenCalledWith("Extension handler timed out", {
extensionPath: hangExtensionPath,
event: "session_start",
timeoutMs: 10,
});
expect(errors).toEqual([
{
extensionPath: hangExtensionPath,
event: "session_start",
error: "handler timed out after 10ms",
},
]);
warnSpy.mockRestore();
});
it("times out tool_call handlers with fail-closed policy so a hung extension cannot indefinitely block tool execution (#3948)", async () => {
const hangExtensionPath = path.join(tempDir.path(), "hang-tool-call.ts");
fs.writeFileSync(
hangExtensionPath,
`
export default function(pi) {
pi.on("tool_call", async () => {
await Promise.withResolvers().promise;
});
}
`,
);
const result = await loadTestExtensions([hangExtensionPath]);
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
const errors: Array<{ extensionPath: string; event: string; error: string }> = [];
runner.onError(err => {
errors.push(err);
});
testSetExtensionHandlerTimeoutMs(10);
const executeCalls: unknown[] = [];
const tool: AgentTool = {
name: "sleepy",
label: "Sleepy",
description: "records execute() invocations",
parameters: Type.Object({}),
strict: true,
execute: async (_id, params) => {
executeCalls.push(params);
return { content: [{ type: "text", text: "ran" }] };
},
};
const wrapped = new ExtensionToolWrapper(tool, runner);
const startedAt = performance.now();
await expect(wrapped.execute("tool-call-id", {})).rejects.toThrow(
`Extension ${hangExtensionPath} timed out after 10ms`,
);
const elapsedMs = performance.now() - startedAt;
expect(elapsedMs).toBeGreaterThanOrEqual(8);
expect(elapsedMs).toBeLessThan(500);
// Fail-closed: the underlying tool MUST NOT run when a gate handler timed out.
expect(executeCalls).toEqual([]);
expect(warnSpy).toHaveBeenCalledWith("Extension handler timed out", {
extensionPath: hangExtensionPath,
event: "tool_call",
timeoutMs: 10,
});
expect(errors).toEqual([
{
extensionPath: hangExtensionPath,
event: "tool_call",
error: "handler timed out after 10ms",
},
]);
warnSpy.mockRestore();
});
it("aborts a tool_call handler's confirmation before returning its timeout block", async () => {
const extensionPath = path.join(tempDir.path(), "confirm-tool-call.ts");
const markerPath = path.join(tempDir.path(), "confirm-settled.txt");
fs.writeFileSync(
extensionPath,
`
import * as fs from "node:fs";
export default function(pi) {
pi.on("tool_call", async (_event, ctx) => {
ctx.ui.notify("Waiting for confirmation");
await ctx.ui.confirm("High-risk command", "Allow this command?");
fs.writeFileSync(${JSON.stringify(markerPath)}, "settled");
});
}
`,
);
const result = await loadTestExtensions([extensionPath]);
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const dialog = Promise.withResolvers<boolean>();
let dialogSignal: AbortSignal | undefined;
const notify = vi.fn<ExtensionUIContext["notify"]>();
const confirm: ExtensionUIContext["confirm"] = async (_title, _message, dialogOptions) => {
dialogSignal = dialogOptions?.signal;
dialogSignal?.addEventListener("abort", () => dialog.resolve(false), { once: true });
return await dialog.promise;
};
const uiPrototype = Object.create(runner.getUIContext(), {
confirm: { value: confirm },
notify: { value: notify },
});
const uiContext: ExtensionUIContext = Object.create(uiPrototype);
runner.initialize(
{
sendMessage: () => {},
sendUserMessage: () => {},
appendEntry: () => {},
setLabel: () => {},
getActiveTools: () => [],
getAllTools: () => [],
setActiveTools: async () => {},
getCommands: () => [],
setModel: async () => false,
getThinkingLevel: () => undefined,
setThinkingLevel: () => {},
getSessionName: () => undefined,
setSessionName: async () => {},
},
{
getModel: () => undefined,
isIdle: () => true,
abort: () => {},
hasPendingMessages: () => false,
shutdown: () => {},
getContextUsage: () => undefined,
compact: async () => {},
getSystemPrompt: () => [],
},
undefined,
uiContext,
);
testSetExtensionHandlerTimeoutMs(10);
const tool: AgentTool = {
name: "guarded",
label: "Guarded",
description: "must not execute after the extension gate times out",
parameters: Type.Object({}),
strict: true,
execute: async () => ({ content: [{ type: "text", text: "ran" }] }),
};
const wrapped = new ExtensionToolWrapper(tool, runner);
await expect(wrapped.execute("tool-call-id", {})).rejects.toThrow(
`Extension ${extensionPath} timed out after 10ms`,
);
expect(notify).toHaveBeenCalledWith("Waiting for confirmation");
expect(dialogSignal?.aborted).toBe(true);
expect(fs.readFileSync(markerPath, "utf8")).toBe("settled");
});
});
describe("memory context", () => {
it("exposes the lazy memory runtime after initialization", async () => {
const extCode = `
export default function(pi) {
pi.on("session_start", async (_event, ctx) => {
globalThis.__ompMemoryStatus = await ctx.memory.status();
});
}
`;
const explicitExtensionPath = path.join(tempDir.path(), "memory-context.ts");
fs.writeFileSync(explicitExtensionPath, extCode);
const globalState = globalThis as typeof globalThis & { __ompMemoryStatus?: unknown };
delete globalState.__ompMemoryStatus;
const result = await loadTestExtensions([explicitExtensionPath]);
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
() => ({
status: async () => ({
backend: "mnemopi",
active: true,
writable: true,
searchable: true,
}),
search: async query => ({ backend: "mnemopi", query, count: 0, items: [] }),
save: async () => ({ backend: "mnemopi", stored: 1 }),
}),
);
runner.initialize(
{
sendMessage: () => {},
sendUserMessage: () => {},
appendEntry: () => {},
setLabel: () => {},
getActiveTools: () => [],
getAllTools: () => [],
setActiveTools: async () => {},
getCommands: () => [],
setModel: async () => false,
getThinkingLevel: () => undefined,
setThinkingLevel: () => {},
getSessionName: () => undefined,
setSessionName: async () => {},
},
{
getModel: () => undefined,
isIdle: () => true,
abort: () => {},
hasPendingMessages: () => false,
shutdown: () => {},
getContextUsage: () => undefined,
compact: async () => {},
getSystemPrompt: () => [],
},
);
await runner.emit({ type: "session_start" });
expect(globalState.__ompMemoryStatus).toMatchObject({
backend: "mnemopi",
active: true,
searchable: true,
});
delete globalState.__ompMemoryStatus;
});
});
describe("service tier API", () => {
it("restricts tiers to values supported by each provider family", () => {
expectTypeOf<"scale">().toExtend<ExtensionServiceTier<"openai">>();
expectTypeOf<"flex">().toExtend<ExtensionServiceTier<"google">>();
expectTypeOf<"priority">().toExtend<ExtensionServiceTier<"anthropic">>();
expectTypeOf<"scale">().not.toExtend<ExtensionServiceTier<"google">>();
expectTypeOf<"flex">().not.toExtend<ExtensionServiceTier<"anthropic">>();
});
it("returns a detached snapshot, forwards valid changes, and rejects invalid family tiers", async () => {
const extCode = `
export default function(pi) {
pi.on("session_start", () => {
const tiers = pi.getServiceTiers();
tiers.openai = "scale";
pi.appendEntry("service-tier-snapshot", tiers);
pi.setServiceTier("google", "flex");
pi.setServiceTier("openai", undefined);
});
pi.on("session_start", () => {
pi.setServiceTier("anthropic", "scale");
});
pi.on("session_start", () => {
pi.setServiceTier("bogus", "priority");
});
}
`;
const explicitExtensionPath = path.join(tempDir.path(), "service-tiers.ts");
await Bun.write(explicitExtensionPath, extCode);
const result = await loadTestExtensions([explicitExtensionPath]);
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const serviceTiers = { openai: "priority" as const };
const snapshots: unknown[] = [];
const setCalls: Array<[string, unknown]> = [];
const errors: string[] = [];
runner.onError(error => {
errors.push(error.error);
});
runner.initialize(
{
sendMessage: () => {},
sendUserMessage: () => {},
appendEntry: (_customType, data) => {
snapshots.push(data);
},
setLabel: () => {},
getActiveTools: () => [],
getAllTools: () => [],
setActiveTools: async () => {},
getCommands: () => [],
setModel: async () => false,
getThinkingLevel: () => undefined,
setThinkingLevel: () => {},
getServiceTiers: () => serviceTiers,
setServiceTier: (family, tier) => {
setCalls.push([family, tier]);
},
getSessionName: () => undefined,
setSessionName: async () => {},
},
{
getModel: () => undefined,
isIdle: () => true,
abort: () => {},
hasPendingMessages: () => false,
shutdown: () => {},
getContextUsage: () => undefined,
compact: async () => {},
getSystemPrompt: () => [],
},
);
await runner.emit({ type: "session_start" });
expect(serviceTiers).toEqual({ openai: "priority" });
expect(snapshots).toEqual([{ openai: "scale" }]);
expect(setCalls).toEqual([
["google", "flex"],
["openai", undefined],
]);
expect(errors).toHaveLength(2);
expect(errors[0]).toContain('Invalid service tier "scale" for family "anthropic"');
expect(errors[1]).toContain('Invalid service tier "priority" for family "bogus"');
});
});
describe("session name API", () => {
it("lets extensions read and set the session name after initialization", async () => {
const extCode = `
export default function(pi) {
pi.on("session_start", async () => {
if (pi.getSessionName() !== undefined) {
throw new Error("expected unnamed session");
}
await pi.setSessionName("Named by extension");
});
}
`;
const explicitExtensionPath = path.join(tempDir.path(), "session-name.ts");
fs.writeFileSync(explicitExtensionPath, extCode);
const result = await loadTestExtensions([explicitExtensionPath]);
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
runner.initialize(
{
sendMessage: () => {},
sendUserMessage: () => {},
appendEntry: () => {},
setLabel: () => {},
getActiveTools: () => [],
getAllTools: () => [],
setActiveTools: async () => {},
getCommands: () => [],
setModel: async () => false,
getThinkingLevel: () => undefined,
setThinkingLevel: () => {},
getSessionName: () => sessionManager.getSessionName(),
setSessionName: async name => {
await sessionManager.setSessionName(name);
},
},
{
getModel: () => undefined,
isIdle: () => true,
abort: () => {},
hasPendingMessages: () => false,
shutdown: () => {},
getContextUsage: () => undefined,
compact: async () => {},
getSystemPrompt: () => [],
},
);
await runner.emit({ type: "session_start" });
expect(sessionManager.getSessionName()).toBe("Named by extension");
expect(sessionManager.getHeader()?.title).toBe("Named by extension");
});
it("keeps session naming unavailable during extension load", async () => {
const extCode = `
export default function(pi) {
pi.getSessionName();
}
`;
const explicitExtensionPath = path.join(tempDir.path(), "session-name-load.ts");
fs.writeFileSync(explicitExtensionPath, extCode);
const result = await loadTestExtensions([explicitExtensionPath]);
const loadError = result.errors.find(error => error.path.includes("session-name-load.ts"));
expect(loadError).toBeDefined();
expect(loadError?.error).toContain("Extension runtime not initialized");
});
});
describe("tool approval lifecycle", () => {
const initializeRunner = (
runner: ExtensionRunner,
select: (title: string, options: string[]) => Promise<string | undefined>,
) => {
runner.initialize(
{
sendMessage: () => {},
sendUserMessage: () => {},
appendEntry: () => {},
setLabel: () => {},
getActiveTools: () => [],
getAllTools: () => [],
setActiveTools: async () => {},
getCommands: () => [],
setModel: async () => false,
getThinkingLevel: () => undefined,
setThinkingLevel: () => {},
getSessionName: () => undefined,
setSessionName: async () => {},
},
{
getModel: () => undefined,
isIdle: () => true,
abort: () => {},
hasPendingMessages: () => false,
shutdown: () => {},
getContextUsage: () => undefined,
compact: async () => {},
getSystemPrompt: () => [],
},
undefined,
{
select,
confirm: async () => false,
input: async () => undefined,
notify: () => {},
onTerminalInput: () => () => {},
setStatus: () => {},
setWorkingMessage: () => {},
setWidget: () => {},
setFooter: () => {},
setHeader: () => {},
setTitle: () => {},
custom: async <T>() => undefined as T,
pasteToEditor: () => {},
setEditorText: () => {},
getEditorText: () => "",
editor: async () => undefined,
addAutocompleteProvider: () => {},
setEditorComponent: () => {},
get theme() {
return {} as never;
},
getAllThemes: async () => [],
getTheme: async () => undefined,
setTheme: async () => ({ success: false, error: "not implemented" }),
getToolsExpanded: () => false,
setToolsExpanded: () => {},
},
);
};
const approvalTool = {
name: "dangerous_tool",
label: "Dangerous Tool",
description: "Test tool",
parameters: {} as never,
approval: "exec" as const,
execute: async () => ({ content: [{ type: "text" as const, text: "ok" }] }),
};
it("emits requested before waiting and resolved after approval", async () => {
const events: Array<{ type: string; approved?: boolean }> = [];
const extCode = `
export default function(pi) {
pi.on("tool_approval_requested", async (event) => {
globalThis.__approvalEvents.push({ type: event.type });
});
pi.on("tool_approval_resolved", async (event) => {
globalThis.__approvalEvents.push({ type: event.type, approved: event.approved });
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "approval-events.ts"), extCode);
const globalState = globalThis as typeof globalThis & { __approvalEvents?: typeof events };
globalState.__approvalEvents = events;
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const select = vi.fn(async () => {
events.push({ type: "ui_select" });
return "Approve";
});
initializeRunner(runner, select);
const wrapper = new ExtensionToolWrapper(approvalTool, runner);
await (wrapper as ExtensionToolWrapper<any>).execute("call-approval", {}, undefined, undefined, {
sessionManager,
modelRegistry,
model: undefined,
isIdle: () => true,
hasQueuedMessages: () => false,
abort: () => {},
settings: { get: (key: string) => (key === "tools.approvalMode" ? "always-ask" : {}) } as never,
});
expect(events).toEqual([
{ type: "tool_approval_requested" },
{ type: "ui_select" },
{ type: "tool_approval_resolved", approved: true },
]);
expect(select).toHaveBeenCalledWith(expect.stringContaining("Allow tool: dangerous_tool"), [
"Approve",
"Deny",
]);
delete globalState.__approvalEvents;
});
it("emits resolved false when approval is denied", async () => {
const events: Array<{ type: string; approved?: boolean; reason?: string }> = [];
const extCode = `
export default function(pi) {
pi.on("tool_approval_requested", async (event) => {
globalThis.__deniedApprovalEvents.push({ type: event.type, reason: event.reason });
});
pi.on("tool_approval_resolved", async (event) => {
globalThis.__deniedApprovalEvents.push({
type: event.type,
approved: event.approved,
reason: event.reason,
});
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "denied-approval-events.ts"), extCode);
const globalState = globalThis as typeof globalThis & { __deniedApprovalEvents?: typeof events };
globalState.__deniedApprovalEvents = events;
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
initializeRunner(runner, async () => "Deny");
const wrapper = new ExtensionToolWrapper(approvalTool, runner);
await expect(
(wrapper as ExtensionToolWrapper<any>).execute("call-denied", {}, undefined, undefined, {
sessionManager,
modelRegistry,
model: undefined,
isIdle: () => true,
hasQueuedMessages: () => false,
abort: () => {},
settings: { get: (key: string) => (key === "tools.approvalMode" ? "always-ask" : {}) } as never,
}),
).rejects.toThrow("Tool call denied by user: dangerous_tool");
expect(events).toEqual([
{ type: "tool_approval_requested", reason: undefined },
{ type: "tool_approval_resolved", approved: false, reason: "denied by user" },
]);
delete globalState.__deniedApprovalEvents;
});
it("emits resolved false when the approval prompt throws", async () => {
const events: Array<{ type: string; approved?: boolean; reason?: string }> = [];
const extCode = `
export default function(pi) {
pi.on("tool_approval_requested", async (event) => {
globalThis.__thrownApprovalEvents.push({ type: event.type, reason: event.reason });
});
pi.on("tool_approval_resolved", async (event) => {
globalThis.__thrownApprovalEvents.push({
type: event.type,
approved: event.approved,
reason: event.reason,
});
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "thrown-approval-events.ts"), extCode);
const globalState = globalThis as typeof globalThis & { __thrownApprovalEvents?: typeof events };
globalState.__thrownApprovalEvents = events;
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
initializeRunner(runner, async () => {
throw new Error("dialog aborted");
});
const wrapper = new ExtensionToolWrapper(approvalTool, runner);
await expect(
(wrapper as ExtensionToolWrapper<any>).execute("call-thrown", {}, undefined, undefined, {
sessionManager,
modelRegistry,
model: undefined,
isIdle: () => true,
hasQueuedMessages: () => false,
abort: () => {},
settings: { get: (key: string) => (key === "tools.approvalMode" ? "always-ask" : {}) } as never,
}),
).rejects.toThrow("dialog aborted");
expect(events).toEqual([
{ type: "tool_approval_requested", reason: undefined },
{ type: "tool_approval_resolved", approved: false, reason: "dialog aborted" },
]);
delete globalState.__thrownApprovalEvents;
});
it("emits lifecycle events when partial context has no session manager", async () => {
const events: Array<{ type: string; approved?: boolean; reason?: string; sessionId?: string }> = [];
const extCode = `
export default function(pi) {
pi.on("tool_approval_requested", async (event) => {
globalThis.__partialContextApprovalEvents.push({
type: event.type,
sessionId: event.sessionId,
reason: event.reason,
});
});
pi.on("tool_approval_resolved", async (event) => {
globalThis.__partialContextApprovalEvents.push({
type: event.type,
sessionId: event.sessionId,
approved: event.approved,
reason: event.reason,
});
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "partial-context-approval-events.ts"), extCode);
const globalState = globalThis as typeof globalThis & { __partialContextApprovalEvents?: typeof events };
globalState.__partialContextApprovalEvents = events;
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const wrapper = new ExtensionToolWrapper(approvalTool, runner);
await expect(
(wrapper as ExtensionToolWrapper<any>).execute("call-partial-context", {}, undefined, undefined, {
settings: { get: (key: string) => (key === "tools.approvalMode" ? "always-ask" : {}) },
} as never),
).rejects.toThrow('Tool "dangerous_tool" requires approval but no interactive UI available.');
expect(events).toEqual([
{ type: "tool_approval_requested", sessionId: "", reason: undefined },
{
type: "tool_approval_resolved",
sessionId: "",
approved: false,
reason: "no interactive UI available",
},
]);
delete globalState.__partialContextApprovalEvents;
});
});
describe("tool_call input", () => {
function createHashlineEditTool(): AgentTool {
return {
name: "edit",
label: "Edit",
description: "Test edit tool",
parameters: Type.Object({ input: Type.String() }),
strict: true,
execute: async () => ({ content: [{ type: "text", text: "ok" }] }),
};
}
it("exposes a single hashline edit path to extension gate handlers", async () => {
const eventsPath = path.join(tempDir.path(), "tool-call-events.jsonl");
const extCode = `
import * as fs from "node:fs";
export default function(pi) {
pi.on("tool_call", async (event) => {
if (event.toolName !== "edit") return;
fs.appendFileSync(
${JSON.stringify(eventsPath)},
JSON.stringify({ path: event.input.path, paths: event.input.paths }) + "\\n",
);
if (typeof event.input.path !== "string") {
return { block: true, reason: \`Blocked: \${event.input.path}\` };
}
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "tool-call-path.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const wrapped = new ExtensionToolWrapper(createHashlineEditTool(), runner);
const resultMessage = await wrapped.execute("tool-call-id", {
input: "¶plans/switch-case-array-syntax.md#ABC1\n27 27\n+new content",
});
expect(resultMessage.content).toEqual([{ type: "text", text: "ok" }]);
const events = fs
.readFileSync(eventsPath, "utf8")
.trim()
.split("\n")
.map(line => JSON.parse(line));
expect(events).toEqual([
{ path: "plans/switch-case-array-syntax.md", paths: ["plans/switch-case-array-syntax.md"] },
]);
});
it("keeps non-tag hash suffixes in hashline edit paths", async () => {
const eventsPath = path.join(tempDir.path(), "tool-call-non-tag-path-events.jsonl");
const extCode = `
import * as fs from "node:fs";
export default function(pi) {
pi.on("tool_call", async (event) => {
if (event.toolName !== "edit") return;
fs.appendFileSync(
${JSON.stringify(eventsPath)},
JSON.stringify({ path: event.input.path, paths: event.input.paths }) + "\\n",
);
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "tool-call-non-tag-path.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const wrapped = new ExtensionToolWrapper(createHashlineEditTool(), runner);
await wrapped.execute("tool-call-id", {
input: "¶plans/foo.md#notatag\n27 27\n+new content",
});
const events = fs
.readFileSync(eventsPath, "utf8")
.trim()
.split("\n")
.map(line => JSON.parse(line));
expect(events).toEqual([{ path: "plans/foo.md#notatag", paths: ["plans/foo.md#notatag"] }]);
});
it("ignores _path passthrough when the hashline input names a different target", async () => {
const eventsPath = path.join(tempDir.path(), "tool-call-spoof-path-events.jsonl");
const extCode = `
import * as fs from "node:fs";
export default function(pi) {
pi.on("tool_call", async (event) => {
if (event.toolName !== "edit") return;
fs.appendFileSync(
${JSON.stringify(eventsPath)},
JSON.stringify({ path: event.input.path, paths: event.input.paths }) + "\\n",
);
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "tool-call-spoof-path.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const wrapped = new ExtensionToolWrapper(createHashlineEditTool(), runner);
await wrapped.execute("tool-call-id", {
_path: "plans/allowed.md",
input: "¶src/secret.ts#ABC1\n27 27\n+evil content",
});
const events = fs
.readFileSync(eventsPath, "utf8")
.trim()
.split("\n")
.map(line => JSON.parse(line));
expect(events).toEqual([{ path: "src/secret.ts", paths: ["src/secret.ts"] }]);
});
it("leaves path unset and reports all targets for multi-file hashline edits", async () => {
const eventsPath = path.join(tempDir.path(), "tool-call-multi-path-events.jsonl");
const extCode = `
import * as fs from "node:fs";
export default function(pi) {
pi.on("tool_call", async (event) => {
if (event.toolName !== "edit") return;
fs.appendFileSync(
${JSON.stringify(eventsPath)},
JSON.stringify({ path: event.input.path ?? null, paths: event.input.paths }) + "\\n",
);
if (typeof event.input.path !== "string") {
return { block: true, reason: \`Blocked: \${event.input.path}\` };
}
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "tool-call-multi-path.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const wrapped = new ExtensionToolWrapper(createHashlineEditTool(), runner);
await expect(
wrapped.execute("tool-call-id", {
input: "¶plans/switch-case-array-syntax.md#ABC1\n27 27\n+new content\n¶packages/coding-agent/src/main.ts#DEF2\n1 1\n+changed",
}),
).rejects.toThrow("Blocked: undefined");
const events = fs
.readFileSync(eventsPath, "utf8")
.trim()
.split("\n")
.map(line => JSON.parse(line));
expect(events).toEqual([
{
path: null,
paths: ["plans/switch-case-array-syntax.md", "packages/coding-agent/src/main.ts"],
},
]);
});
// A tool that records the exact params it executed with, so an input override is observable.
function createRecordingTool(recordPath: string): AgentTool {
return {
name: "bash",
label: "Bash",
description: "Test bash tool",
parameters: Type.Object({ command: Type.String() }),
strict: true,
execute: async (_id: string, params: unknown) => {
fs.appendFileSync(recordPath, `${JSON.stringify(params)}\n`);
return { content: [{ type: "text", text: "ran" }] };
},
} as AgentTool;
}
it("executes the tool with a non-blocking handler's replacement input", async () => {
const recordPath = path.join(tempDir.path(), "override-executed.jsonl");
const extCode = `
export default function(pi) {
pi.on("tool_call", async (event) => {
if (event.toolName !== "bash") return;
return { input: { command: "echo revised" } };
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "tool-call-override.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const wrapped = new ExtensionToolWrapper(createRecordingTool(recordPath), runner);
const resultMessage = await wrapped.execute("tool-call-id", { command: "echo original" });
expect(resultMessage.content).toEqual([{ type: "text", text: "ran" }]);
const executed = fs
.readFileSync(recordPath, "utf8")
.trim()
.split("\n")
.map(line => JSON.parse(line));
expect(executed).toEqual([{ command: "echo revised" }]);
});
it("ignores a replacement input when the handler also blocks", async () => {
const recordPath = path.join(tempDir.path(), "override-blocked.jsonl");
const extCode = `
export default function(pi) {
pi.on("tool_call", async (event) => {
if (event.toolName !== "bash") return;
return { block: true, reason: "nope", input: { command: "echo revised" } };
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "tool-call-override-blocked.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const wrapped = new ExtensionToolWrapper(createRecordingTool(recordPath), runner);
await expect(wrapped.execute("tool-call-id", { command: "echo original" })).rejects.toThrow("nope");
expect(fs.existsSync(recordPath)).toBe(false); // tool never executed
});
it("executes with the original input when no handler returns a replacement", async () => {
const recordPath = path.join(tempDir.path(), "override-absent.jsonl");
const extCode = `
export default function(pi) {
pi.on("tool_call", async (event) => {
if (event.toolName !== "bash") return;
// observe only; no input override
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "tool-call-no-override.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const wrapped = new ExtensionToolWrapper(createRecordingTool(recordPath), runner);
await wrapped.execute("tool-call-id", { command: "echo original" });
const executed = fs
.readFileSync(recordPath, "utf8")
.trim()
.split("\n")
.map(line => JSON.parse(line));
expect(executed).toEqual([{ command: "echo original" }]);
});
// A tool whose approval policy depends on its args: the command "rm -rf" resolves to deny,
// anything else is exec. Lets a test prove the post-override approval re-check (P1).
function createArgGatedTool(recordPath: string): AgentTool {
return {
name: "bash",
label: "Bash",
description: "Test bash tool",
parameters: Type.Object({ command: Type.String() }),
strict: true,
approval: (args: unknown) => {
const command = args && typeof args === "object" && "command" in args ? args.command : undefined;
return command === "rm -rf" ? { policy: "deny" as const, reason: "dangerous" } : ("exec" as const);
},
execute: async (_id: string, params: unknown) => {
fs.appendFileSync(recordPath, `${JSON.stringify(params)}\n`);
return { content: [{ type: "text", text: "ran" }] };
},
} as AgentTool;
}
const yoloContext = {
settings: { get: (key: string) => (key === "tools.approvalMode" ? "yolo" : {}) },
} as never;
// Minimal runtime init so the approval gate's interactive `select` is wired for prompt-path tests.
const initApprovalRunner = (
runner: ExtensionRunner,
select: (title: string, options: string[]) => Promise<string | undefined>,
) => {
runner.initialize(
{
sendMessage: () => {},
sendUserMessage: () => {},
appendEntry: () => {},
setLabel: () => {},
getActiveTools: () => [],
getAllTools: () => [],
setActiveTools: async () => {},
getCommands: () => [],
setModel: async () => false,
getThinkingLevel: () => undefined,
setThinkingLevel: () => {},
getSessionName: () => undefined,
setSessionName: async () => {},
} as never,
{
getModel: () => undefined,
isIdle: () => true,
abort: () => {},
hasPendingMessages: () => false,
shutdown: () => {},
getContextUsage: () => undefined,
compact: async () => {},
getSystemPrompt: () => [],
} as never,
undefined,
{ select, notify: () => {} } as never,
);
};
const alwaysAskContext = {
sessionManager,
modelRegistry,
model: undefined,
isIdle: () => true,
hasQueuedMessages: () => false,
abort: () => {},
settings: { get: (key: string) => (key === "tools.approvalMode" ? "always-ask" : {}) },
} as never;
it("blocks a revised input that resolves to a deny policy (approval gates the revised args)", async () => {
const recordPath = path.join(tempDir.path(), "regate-blocked.jsonl");
const extCode = `
export default function(pi) {
pi.on("tool_call", async (event) => {
if (event.toolName !== "bash") return;
return { input: { command: "rm -rf" } };
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "tool-call-regate.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const wrapped = new ExtensionToolWrapper(createArgGatedTool(recordPath), runner);
// Original "echo original" resolves to exec; the handler rewrites it to "rm -rf", which the
// tool's approval declares deny. Because tool_call fires before the approval gate, the gate
// resolves against the revised args and blocks — the tool never runs.
await expect(
wrapped.execute("tool-call-id", { command: "echo original" }, undefined, undefined, yoloContext),
).rejects.toThrow(/blocked by user policy/);
expect(fs.existsSync(recordPath)).toBe(false); // tool never executed
});
it("allows a revised input that still passes policy", async () => {
const recordPath = path.join(tempDir.path(), "regate-allowed.jsonl");
const extCode = `
export default function(pi) {
pi.on("tool_call", async (event) => {
if (event.toolName !== "bash") return;
return { input: { command: "echo revised" } };
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "tool-call-regate-ok.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const wrapped = new ExtensionToolWrapper(createArgGatedTool(recordPath), runner);
await wrapped.execute("tool-call-id", { command: "echo original" }, undefined, undefined, yoloContext);
const executed = fs
.readFileSync(recordPath, "utf8")
.trim()
.split("\n")
.map(line => JSON.parse(line));
expect(executed).toEqual([{ command: "echo revised" }]);
});
it("uses the last handler's input when several handlers set it", async () => {
const recordPath = path.join(tempDir.path(), "regate-multi.jsonl");
const first = `
export default function(pi) {
pi.on("tool_call", async (event) => {
if (event.toolName !== "bash") return;
return { input: { command: "echo first" } };
});
}
`;
const second = `
export default function(pi) {
pi.on("tool_call", async (event) => {
if (event.toolName !== "bash") return;
return { input: { command: "echo second" } };
});
}
`;
// File names sort first < second, so the loader loads them in that order and second wins.
fs.writeFileSync(path.join(extensionsDir, "tool-call-multi-a.ts"), first);
fs.writeFileSync(path.join(extensionsDir, "tool-call-multi-b.ts"), second);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const wrapped = new ExtensionToolWrapper(createRecordingTool(recordPath), runner);
await wrapped.execute("tool-call-id", { command: "echo original" });
const executed = fs
.readFileSync(recordPath, "utf8")
.trim()
.split("\n")
.map(line => JSON.parse(line));
expect(executed).toEqual([{ command: "echo second" }]);
});
it("prompts for the revised input, not the original, on an approval-gated tool (P1 prompt→prompt)", async () => {
// The Codex P1 follow-up: original and revised args are both prompt-gated, so a stale re-check
// on policy alone would let the revised args run under approval granted for the original.
// Because tool_call fires before the approval gate, the prompt must reflect the revised args.
const extCode = `
export default function(pi) {
pi.on("tool_call", async (event) => {
if (event.toolName !== "prompt_tool") return;
return { input: { command: "revised-command" } };
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "tool-call-prompt-revise.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
let promptedWith = "";
const select = vi.fn(async (title: string) => {
promptedWith = title;
return "Approve";
});
initApprovalRunner(runner, select);
const executed: unknown[] = [];
const promptTool = {
name: "prompt_tool",
label: "Prompt Tool",
description: "Always prompt-gated",
parameters: Type.Object({ command: Type.String() }),
strict: true,
approval: "exec" as const,
formatApprovalDetails: (args: unknown) =>
args && typeof args === "object" && "command" in args ? String(args.command) : "",
execute: async (_id: string, params: unknown) => {
executed.push(params);
return { content: [{ type: "text", text: "ran" }] };
},
} as AgentTool;
const wrapped = new ExtensionToolWrapper(promptTool, runner);
await (wrapped as ExtensionToolWrapper<any>).execute(
"call-p2p",
{ command: "original-command" },
undefined,
undefined,
alwaysAskContext,
);
// The user was prompted for the revised command, and that is what executed.
expect(promptedWith).toContain("revised-command");
expect(promptedWith).not.toContain("original-command");
expect(executed).toEqual([{ command: "revised-command" }]);
});
it("skips wrapper emission when the loop already emitted tool_call for the dispatch", async () => {
// The agent loop emits tool_call at arg-prep time (session beforeToolCall
// wiring) and marks the dispatch on the runner; the wrapper must not fire
// handlers a second time for the same call. The marker is consume-once,
// so a dispatch the loop never marked (nested xd://, Cursor direct)
// still emits.
const recordPath = path.join(tempDir.path(), "loop-marker.jsonl");
const extCode = `
export default function(pi) {
pi.on("tool_call", async (event) => {
if (event.toolName !== "bash") return;
return { input: { command: "echo revised" } };
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "tool-call-loop-marker.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const wrapped = new ExtensionToolWrapper(createRecordingTool(recordPath), runner);
runner.markToolCallEmitted("loop-call-id", "bash");
await wrapped.execute("loop-call-id", { command: "echo original" });
// Marker consumed above: an unmarked dispatch under the same id emits normally.
await wrapped.execute("loop-call-id", { command: "echo original" });
const executed = fs
.readFileSync(recordPath, "utf8")
.trim()
.split("\n")
.map(line => JSON.parse(line));
expect(executed).toEqual([{ command: "echo original" }, { command: "echo revised" }]);
});
it("forfeits the xdevApproved prompt bypass when a handler revises the input", async () => {
// write.ts dispatches xd:// devices with xdevApproved: true because its
// outer gate already approved the ORIGINAL device input. A tool_call
// revision may raise the tier, so revised input must face the full gate
// (here: no interactive UI => reject) instead of riding the outer approval.
const recordPath = path.join(tempDir.path(), "xdev-revised.jsonl");
const extCode = `
export default function(pi) {
pi.on("tool_call", async (event) => {
if (event.toolName !== "bash") return;
return { input: { command: "echo revised" } };
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "tool-call-xdev-revise.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const wrapped = new ExtensionToolWrapper(createRecordingTool(recordPath), runner);
const xdevContext = {
settings: { get: (key: string) => (key === "tools.approvalMode" ? "always-ask" : {}) },
xdevApproved: true,
} as never;
await expect(
wrapped.execute("xdev-call-id", { command: "echo original" }, undefined, undefined, xdevContext),
).rejects.toThrow(/requires approval but no interactive UI available/);
expect(fs.existsSync(recordPath)).toBe(false); // tool never executed
});
it("reports the effective tier after a tool_call handler revises xd:// input", async () => {
const recordPath = path.join(tempDir.path(), "xdev-effective-tier.jsonl");
const extCode = `
export default function(pi) {
pi.on("tool_call", async (event) => {
if (event.toolName !== "bash") return;
return { input: { command: "echo revised" } };
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "tool-call-xdev-tier.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const tool = createRecordingTool(recordPath);
tool.approval = args =>
args &&
typeof args === "object" &&
"command" in args &&
typeof args.command === "string" &&
args.command.includes("revised")
? "write"
: "read";
const wrapped = new ExtensionToolWrapper(tool, runner);
let effectiveTier: string | undefined;
const xdevContext = {
settings: { get: (key: string) => (key === "tools.approvalMode" ? "yolo" : {}) },
xdevApproved: true,
xdevTierResolved: (tier: string) => {
effectiveTier = tier;
},
} as never;
await wrapped.execute("xdev-tier-id", { command: "echo original" }, undefined, undefined, xdevContext);
expect(JSON.parse(fs.readFileSync(recordPath, "utf8"))).toEqual({ command: "echo revised" });
expect(effectiveTier).toBe("write");
});
it("emits tool_call before the approval prompt so approval sees the final input", async () => {
const order: string[] = [];
const extCode = `
export default function(pi) {
pi.on("tool_call", async (event) => {
if (event.toolName !== "prompt_tool") return;
globalThis.__orderEvents.push("tool_call");
return { input: { command: "revised" } };
});
pi.on("tool_approval_requested", async () => {
globalThis.__orderEvents.push("tool_approval_requested");
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "tool-call-order.ts"), extCode);
const globalState = globalThis as typeof globalThis & { __orderEvents?: string[] };
globalState.__orderEvents = order;
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const select = vi.fn(async () => {
order.push("ui_select");
return "Approve";
});
initApprovalRunner(runner, select);
const promptTool = {
name: "prompt_tool",
label: "Prompt Tool",
description: "Always prompt-gated",
parameters: Type.Object({ command: Type.String() }),
strict: true,
approval: "exec" as const,
execute: async () => ({ content: [{ type: "text", text: "ran" }] }),
} as AgentTool;
const wrapped = new ExtensionToolWrapper(promptTool, runner);
await (wrapped as ExtensionToolWrapper<any>).execute(
"call-order",
{ command: "original" },
undefined,
undefined,
alwaysAskContext,
);
expect(order).toEqual(["tool_call", "tool_approval_requested", "ui_select"]);
delete globalState.__orderEvents;
});
});
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 loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
expect(runner.hasHandlers("tool_call")).toBe(true);
expect(runner.hasHandlers("agent_end")).toBe(false);
});
});
describe("zero-handler fast path", () => {
it("skips context allocation and handler machinery for an unsubscribed event type, but still fires when subscribed", async () => {
// The fast path in ExtensionRunner.emit is event-type agnostic; the hot
// streaming events (message_update / tool_execution_*) traverse the same
// path. `turn_start` stands in as a subscribed event with a trivial payload.
const extCode = `
export default function(pi) {
pi.on("turn_start", async () => {
throw new Error("turn_start handler ran");
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "turn-start-handler.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
// createContext is the per-event allocation the fast path defers; spying on
// it (call-through preserved) proves the slow path is entered only when a
// handler exists for the emitted event type.
const createContextSpy = vi.spyOn(runner, "createContext");
const errors: Array<{ event: string; error: string }> = [];
runner.onError(err => {
errors.push({ event: err.event, error: err.error });
});
// No extension subscribes to `agent_start`: no context allocation, and the
// handler-timeout machinery is never entered.
await runner.emit({ type: "agent_start" });
expect(createContextSpy).not.toHaveBeenCalled();
expect(errors).toHaveLength(0);
// `turn_start` has a handler: context is allocated once and the handler runs
// (its throw surfaces via onError, proving #runHandlerWithTimeout executed).
await runner.emit({ type: "turn_start", turnIndex: 0, timestamp: Date.now() });
expect(createContextSpy).toHaveBeenCalledTimes(1);
expect(errors).toHaveLength(1);
expect(errors[0]?.event).toBe("turn_start");
expect(errors[0]?.error).toContain("turn_start handler ran");
});
});
describe("credential_disabled", () => {
it("delivers credential_disabled events to subscribed extensions with the typed payload", async () => {
const eventsPath = path.join(tempDir.path(), "credential-disabled-events.jsonl");
const extCode = `
import * as fs from "node:fs";
export default function(pi) {
pi.on("credential_disabled", async (event) => {
fs.appendFileSync(
${JSON.stringify(eventsPath)},
JSON.stringify({
type: event.type,
provider: event.provider,
disabledCause: event.disabledCause,
}) + "\\n",
);
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "credential-disabled.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
await runner.emit({ type: "credential_disabled", provider: "anthropic", disabledCause: "invalid_grant" });
const events = fs
.readFileSync(eventsPath, "utf8")
.trim()
.split("\n")
.map(line => JSON.parse(line));
expect(events).toEqual([
{ type: "credential_disabled", provider: "anthropic", disabledCause: "invalid_grant" },
]);
});
it("isolates subscriber failures so other handlers still receive the event", async () => {
const eventsPath = path.join(tempDir.path(), "credential-disabled-isolated.jsonl");
const ext1Code = `
export default function(pi) {
pi.on("credential_disabled", async () => {
throw new Error("subscriber exploded");
});
}
`;
const ext2Code = `
import * as fs from "node:fs";
export default function(pi) {
pi.on("credential_disabled", async (event) => {
fs.appendFileSync(
${JSON.stringify(eventsPath)},
JSON.stringify({ provider: event.provider }) + "\\n",
);
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "ext1-credential-disabled-throws.ts"), ext1Code);
fs.writeFileSync(path.join(extensionsDir, "ext2-credential-disabled-records.ts"), ext2Code);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const errors: Array<{ extensionPath: string; event: string; error: string }> = [];
runner.onError(err => {
errors.push(err);
});
await runner.emit({ type: "credential_disabled", provider: "anthropic", disabledCause: "invalid_grant" });
const events = fs
.readFileSync(eventsPath, "utf8")
.trim()
.split("\n")
.map(line => JSON.parse(line));
expect(events).toEqual([{ provider: "anthropic" }]);
expect(errors).toHaveLength(1);
expect(errors[0]?.event).toBe("credential_disabled");
expect(errors[0]?.error).toContain("subscriber exploded");
});
it("is a no-op when no extension subscribes", async () => {
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
expect(runner.hasHandlers("credential_disabled")).toBe(false);
await expect(
runner.emit({ type: "credential_disabled", provider: "anthropic", disabledCause: "invalid_grant" }),
).resolves.toBeUndefined();
});
it("caps the pre-initialize buffer and drops oldest events under pressure", async () => {
const eventsPath = path.join(tempDir.path(), "credential-disabled-cap.jsonl");
const extCode = `
import * as fs from "node:fs";
export default function(pi) {
pi.on("credential_disabled", async (event) => {
fs.appendFileSync(
${JSON.stringify(eventsPath)},
JSON.stringify({ provider: event.provider }) + "\\n",
);
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "credential-disabled-cap.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
// Push 33 events while uninitialized — the 1st should be dropped.
for (let i = 0; i < 33; i++) {
await runner.emitCredentialDisabled({ provider: `provider-${i}`, disabledCause: "invalid_grant" });
}
runner.initialize(
{
sendMessage: () => {},
sendUserMessage: () => {},
appendEntry: () => {},
setLabel: () => {},
getActiveTools: () => [],
getAllTools: () => [],
setActiveTools: async () => {},
getCommands: () => [],
setModel: async () => false,
getThinkingLevel: () => undefined,
setThinkingLevel: () => {},
getSessionName: () => sessionManager.getSessionName(),
setSessionName: async () => {},
},
{
getModel: () => undefined,
isIdle: () => true,
abort: () => {},
hasPendingMessages: () => false,
shutdown: () => {},
getContextUsage: () => undefined,
compact: async () => {},
getSystemPrompt: () => [],
},
);
// Drain microtasks so the fire-and-forget emit() calls inside initialize() complete.
for (let i = 0; i < 5; i++) await Promise.resolve();
const events = fs
.readFileSync(eventsPath, "utf8")
.trim()
.split("\n")
.map(line => JSON.parse(line));
expect(events).toHaveLength(32);
// Drop-oldest policy: provider-0 was evicted, provider-1 survived as the head.
expect(events[0]?.provider).toBe("provider-1");
});
});
describe("mcp_notification", () => {
it("delivers mcp_notification events to subscribed extensions with the typed payload", async () => {
const eventsPath = path.join(tempDir.path(), "mcp-notification-events.jsonl");
const extCode = `
import * as fs from "node:fs";
export default function(pi) {
pi.on("mcp_notification", async (event) => {
fs.appendFileSync(
${JSON.stringify(eventsPath)},
JSON.stringify({
type: event.type,
server: event.server,
method: event.method,
params: event.params,
}) + "\\n",
);
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "mcp-notification.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
runner.initialize(
{
sendMessage: () => {},
sendUserMessage: () => {},
appendEntry: () => {},
setLabel: () => {},
getActiveTools: () => [],
getAllTools: () => [],
setActiveTools: async () => {},
getCommands: () => [],
setModel: async () => false,
getThinkingLevel: () => undefined,
setThinkingLevel: () => {},
getSessionName: () => sessionManager.getSessionName(),
setSessionName: async () => {},
},
{
getModel: () => undefined,
isIdle: () => true,
abort: () => {},
hasPendingMessages: () => false,
shutdown: () => {},
getContextUsage: () => undefined,
compact: async () => {},
getSystemPrompt: () => [],
},
);
await runner.emitMcpNotification({
server: "peers",
method: "notifications/peer_message",
params: { from: "alice", text: "hi" },
});
const events = fs
.readFileSync(eventsPath, "utf8")
.trim()
.split("\n")
.map(line => JSON.parse(line));
expect(events).toEqual([
{
type: "mcp_notification",
server: "peers",
method: "notifications/peer_message",
params: { from: "alice", text: "hi" },
},
]);
});
it("buffers pre-initialize events and drains them on initialize (caps at 100, drops oldest)", async () => {
// Guard against regression of the two-layer startup race that Codex flagged
// on PR #6535 (commit ffa058aa8): the sdk.ts bridge wires
// mcpManager.addNotificationListener inside createAgentSession BEFORE the
// mode controller calls ExtensionRunner.initialize(). Frames the manager
// drains from its own buffer arrive here pre-init. Prior behavior silently
// dropped them; the fix buffers and drains on initialize (same shape as
// emitCredentialDisabled).
const eventsPath = path.join(tempDir.path(), "mcp-notification-cap.jsonl");
const extCode = `
import * as fs from "node:fs";
export default function(pi) {
pi.on("mcp_notification", async (event) => {
fs.appendFileSync(
${JSON.stringify(eventsPath)},
JSON.stringify({ server: event.server, method: event.method }) + "\\n",
);
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "mcp-notification-cap.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
// Push 101 events while uninitialized — the 1st should be dropped, next 100 buffered.
for (let i = 0; i < 101; i++) {
await runner.emitMcpNotification({
server: "peers",
method: `notifications/test/${i}`,
params: null,
});
}
runner.initialize(
{
sendMessage: () => {},
sendUserMessage: () => {},
appendEntry: () => {},
setLabel: () => {},
getActiveTools: () => [],
getAllTools: () => [],
setActiveTools: async () => {},
getCommands: () => [],
setModel: async () => false,
getThinkingLevel: () => undefined,
setThinkingLevel: () => {},
getSessionName: () => sessionManager.getSessionName(),
setSessionName: async () => {},
},
{
getModel: () => undefined,
isIdle: () => true,
abort: () => {},
hasPendingMessages: () => false,
shutdown: () => {},
getContextUsage: () => undefined,
compact: async () => {},
getSystemPrompt: () => [],
},
);
// Drain microtasks so the fire-and-forget emit() calls inside initialize() complete.
for (let i = 0; i < 5; i++) await Promise.resolve();
const events = fs
.readFileSync(eventsPath, "utf8")
.trim()
.split("\n")
.map(line => JSON.parse(line));
expect(events).toHaveLength(100);
// Drop-oldest policy: test/0 was evicted, test/1 survives as the head.
expect(events[0]?.method).toBe("notifications/test/1");
expect(events[99]?.method).toBe("notifications/test/100");
});
});
describe("managed timers (ctx.setInterval / ctx.setTimeout)", () => {
it("contains a throwing interval callback instead of letting it escape as uncaughtException", () => {
vi.useFakeTimers();
try {
const runner = new ExtensionRunner(
[],
new ExtensionRuntime(),
tempDir.path(),
sessionManager,
modelRegistry,
);
const errors: ExtensionError[] = [];
runner.onError(err => errors.push(err));
const ctx = runner.createContext();
let ticks = 0;
ctx.setInterval(() => {
ticks += 1;
throw new Error("boom from interval");
}, 1000);
// Two ticks: the throw is swallowed each time, so the interval keeps firing.
expect(() => vi.advanceTimersByTime(2000)).not.toThrow();
expect(ticks).toBe(2);
expect(errors).toHaveLength(2);
expect(errors[0]?.event).toBe("interval_callback");
expect(errors[0]?.extensionPath).toBe("<timer>");
expect(errors[0]?.error).toContain("boom from interval");
} finally {
vi.useRealTimers();
}
});
it("contains a throwing timeout callback and reports it once", () => {
vi.useFakeTimers();
try {
const runner = new ExtensionRunner(
[],
new ExtensionRuntime(),
tempDir.path(),
sessionManager,
modelRegistry,
);
const errors: ExtensionError[] = [];
runner.onError(err => errors.push(err));
runner.createContext().setTimeout(() => {
throw new Error("boom from timeout");
}, 500);
expect(() => vi.advanceTimersByTime(1000)).not.toThrow();
expect(errors).toHaveLength(1);
expect(errors[0]?.event).toBe("timeout_callback");
expect(errors[0]?.error).toContain("boom from timeout");
} finally {
vi.useRealTimers();
}
});
it("clearTimer stops a managed interval from firing again", () => {
vi.useFakeTimers();
try {
const runner = new ExtensionRunner(
[],
new ExtensionRuntime(),
tempDir.path(),
sessionManager,
modelRegistry,
);
const ctx = runner.createContext();
let ticks = 0;
const timer = ctx.setInterval(() => {
ticks += 1;
}, 1000);
vi.advanceTimersByTime(1000);
expect(ticks).toBe(1);
ctx.clearTimer(timer);
vi.advanceTimersByTime(3000);
expect(ticks).toBe(1);
} finally {
vi.useRealTimers();
}
});
it("clearManagedTimers cancels every outstanding timer on teardown", () => {
vi.useFakeTimers();
try {
const runner = new ExtensionRunner(
[],
new ExtensionRuntime(),
tempDir.path(),
sessionManager,
modelRegistry,
);
const ctx = runner.createContext();
let intervalTicks = 0;
let timeoutFired = false;
ctx.setInterval(() => {
intervalTicks += 1;
}, 1000);
ctx.setTimeout(() => {
timeoutFired = true;
}, 1000);
runner.clearManagedTimers();
vi.advanceTimersByTime(5000);
expect(intervalTicks).toBe(0);
expect(timeoutFired).toBe(false);
} finally {
vi.useRealTimers();
}
});
});
describe("invokeTool same-tool delegation", () => {
// Records what the native tool actually received, so the inherited abort/progress channels and
// the caller context are observable.
function nativeProbe(seen: { signal?: AbortSignal; onUpdate?: unknown; params?: unknown }): AgentTool {
return {
name: "bash",
label: "Bash",
description: "native bash",
parameters: Type.Object({ command: Type.String() }),
execute: async (_id: string, params: unknown, signal?: AbortSignal, onUpdate?: unknown) => {
seen.params = params;
seen.signal = signal;
seen.onUpdate = onUpdate;
return { content: [{ type: "text", text: "native ran" }], details: {} };
},
} as AgentTool;
}
const runnerWithNative = async (native: AgentTool) => {
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
runner.setNativeToolResolver(name =>
name === native.name ? { tool: native, makeContext: () => ({}) as never } : undefined,
);
return runner;
};
it("inherits the wrapper call's signal and onUpdate for a bare invokeTool", async () => {
const seen: { signal?: AbortSignal; onUpdate?: unknown; params?: unknown } = {};
const runner = await runnerWithNative(nativeProbe(seen));
const controller = new AbortController();
const onUpdate = () => {};
const ctx = runner.createContext(undefined, {
toolName: "bash",
signal: controller.signal,
onUpdate,
});
await ctx.invokeTool?.({ command: "echo hi" });
// Aborting the outer tool call must reach the native one, and native progress must stream.
expect(seen.signal).toBe(controller.signal);
expect(seen.onUpdate).toBe(onUpdate);
expect(seen.params).toEqual({ command: "echo hi" });
});
it("lets explicit invokeTool options override the inherited channels", async () => {
const seen: { signal?: AbortSignal; onUpdate?: unknown; params?: unknown } = {};
const runner = await runnerWithNative(nativeProbe(seen));
const outer = new AbortController();
const inner = new AbortController();
const innerOnUpdate = () => {};
const ctx = runner.createContext(undefined, {
toolName: "bash",
signal: outer.signal,
onUpdate: () => {},
});
await ctx.invokeTool?.({ command: "echo hi" }, { signal: inner.signal, onUpdate: innerOnUpdate });
expect(seen.signal).toBe(inner.signal);
expect(seen.onUpdate).toBe(innerOnUpdate);
});
it("omits invokeTool when no native built-in of that name exists", async () => {
const runner = await runnerWithNative(nativeProbe({}));
expect(runner.createContext(undefined, { toolName: "not_a_builtin" }).invokeTool).toBeUndefined();
// Also absent when the context is not scoped to a tool at all.
expect(runner.createContext().invokeTool).toBeUndefined();
});
it("bounds recursion per call chain", async () => {
const runner = await runnerWithNative(nativeProbe({}));
await expect(runner.invokeNativeTool("bash", { command: "echo hi" }, { depth: 8 })).rejects.toThrow(
/delegation depth exceeded/,
);
// A fresh chain at depth 0 is unaffected by another chain's depth.
await expect(runner.invokeNativeTool("bash", { command: "echo hi" }, { depth: 0 })).resolves.toBeDefined();
});
});
});