import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { type } from "@oh-my-pi/omptype"; import type { AgentTool, StreamFn } from "@oh-my-pi/pi-agent-core"; import type { Model, ToolResultMessage } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import type { CursorExecHandlers } from "@oh-my-pi/pi-coding-agent/cursor"; import { EXTENSION_HANDLER_TIMEOUT_MS, testSetExtensionHandlerTimeoutMs, } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner"; import type { MCPManager } from "@oh-my-pi/pi-coding-agent/mcp/manager"; import { initializeExtensions } from "@oh-my-pi/pi-coding-agent/modes/runtime-init"; import { type CreateAgentSessionOptions, type CustomTool, createAgentSession, discoverAuthStorage, type ExtensionFactory, } from "@oh-my-pi/pi-coding-agent/sdk"; import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { VIBE_TOOL_NAMES } from "@oh-my-pi/pi-coding-agent/tools/vibe"; import { logger, removeSyncWithRetries, Snowflake, untilAborted } from "@oh-my-pi/pi-utils"; const toolActivationExtension: ExtensionFactory = pi => { pi.registerTool({ name: "default_inactive_tool", label: "Default Inactive Tool", description: "Tool hidden from the initial active set unless explicitly requested.", parameters: type({}), defaultInactive: true, async execute() { return { content: [{ type: "text", text: "inactive" }] }; }, }); pi.registerTool({ name: "default_active_tool", label: "Default Active Tool", description: "Tool included in the initial active set.", parameters: type({}), async execute() { return { content: [{ type: "text", text: "active" }] }; }, }); }; const sdkCustomTool = { name: "sdk_custom_tool", label: "SDK Custom Tool", description: "SDK-provided custom tool used to verify activation boundaries.", parameters: type({}), async execute() { return { content: [{ type: "text", text: "sdk custom" }] }; }, } satisfies CustomTool; describe("createAgentSession defaultInactive tool activation", () => { const tempDirs: string[] = []; // Built once and shared by every session. `ModelRegistry` eagerly loads all // bundled + cached models and `discoverAuthStorage` opens the auth DB — the // dominant (~50ms) slice of a cold boot, and identical for every test here. // Injecting it drops each per-test boot to the ~4ms of activation-specific work // these tests vary, and skips the background model refresh the SDK would // otherwise start when it builds its own registry. let modelRegistry!: ModelRegistry; let registryAuthDir: string; const makeTempDir = (): string => { const tempDir = path.join(os.tmpdir(), `pi-sdk-tool-activation-${Snowflake.next()}`); tempDirs.push(tempDir); fs.mkdirSync(tempDir, { recursive: true }); return tempDir; }; beforeAll(async () => { registryAuthDir = path.join(os.tmpdir(), `pi-sdk-tool-activation-auth-${Snowflake.next()}`); fs.mkdirSync(registryAuthDir, { recursive: true }); modelRegistry = new ModelRegistry(await discoverAuthStorage(registryAuthDir)); }); // Shared options for every session. `rules: []` and `workspaceTree` short-circuit // the two slow startup scans (rule discovery + native workspace walk, ~100ms each) // that are irrelevant to tool activation: these tests assert only which tools are // registered/active and that tool names appear in the system prompt. The shared // `modelRegistry` is injected here; each call still returns fresh // `settings`/`sessionManager` instances to keep tests isolated. const baseOptions = (tempDir: string): CreateAgentSessionOptions => ({ cwd: tempDir, agentDir: tempDir, modelRegistry, sessionManager: SessionManager.inMemory(), settings: Settings.isolated(), model: getBundledModel("openai", "gpt-4o-mini"), disableExtensionDiscovery: true, skills: [], contextFiles: [], promptTemplates: [], slashCommands: [], enableMCP: false, enableLsp: false, rules: [], workspaceTree: { rootPath: tempDir, rendered: "", truncated: false, totalLines: 0, agentsMdFiles: [] }, }); afterEach(() => { for (const tempDir of tempDirs.splice(0)) { removeSyncWithRetries(tempDir); } vi.restoreAllMocks(); testSetExtensionHandlerTimeoutMs(EXTENSION_HANDLER_TIMEOUT_MS); }); afterAll(() => { removeSyncWithRetries(registryAuthDir); }); it("excludes defaultInactive extension tools from the initial active set unless explicitly requested", async () => { const tempDir = makeTempDir(); const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [toolActivationExtension], }); try { expect(session.getAllToolNames()).toEqual( expect.arrayContaining(["default_active_tool", "default_inactive_tool"]), ); // Discoverable extension tools mount as xd:// devices, not top-level active tools. const deviceNames = session.getXdevToolEntries().map(entry => entry.name); expect(deviceNames).toContain("default_active_tool"); expect(session.getActiveToolNames()).not.toContain("default_active_tool"); expect(deviceNames).not.toContain("default_inactive_tool"); expect(session.getActiveToolNames()).not.toContain("default_inactive_tool"); expect(session.systemPrompt.join("\n")).toContain("default_active_tool"); expect(session.systemPrompt.join("\n")).not.toContain("default_inactive_tool"); } finally { await session.dispose(); } }); it("activates the private think tool when external thinking is enabled at runtime", async () => { const tempDir = makeTempDir(); const settings = Settings.isolated(); const { session } = await createAgentSession({ ...baseOptions(tempDir), settings, }); try { expect(session.getToolByName("think")).toBeUndefined(); expect(session.getActiveToolNames()).not.toContain("think"); settings.set("externalThinking", true); await session.setThinkToolEnabled(true); expect(session.getToolByName("think")).toBeDefined(); expect(session.getActiveToolNames()).toContain("think"); expect(session.getXdevToolEntries().map(entry => entry.name)).not.toContain("think"); settings.set("externalThinking", false); await session.setThinkToolEnabled(false); expect(session.getActiveToolNames()).not.toContain("think"); } finally { await session.dispose(); } }); it("activates the private think tool at startup when external thinking is configured", async () => { const tempDir = makeTempDir(); const settings = Settings.isolated({ externalThinking: true }); const { session } = await createAgentSession({ ...baseOptions(tempDir), settings, }); try { expect(session.getToolByName("think")).toBeDefined(); expect(session.getActiveToolNames()).toContain("think"); expect(session.getXdevToolEntries().map(entry => entry.name)).not.toContain("think"); } finally { await session.dispose(); } }); it("forces think and sends reasoning effort off for a Responses turn", async () => { const tempDir = makeTempDir(); const settings = Settings.isolated({ externalThinking: true }); const requestTexts: string[] = []; const sse = (events: unknown[]): Response => new Response(events.map(event => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" }, }); const completed = (id: string) => ({ type: "response.completed", response: { id, status: "completed", usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2, input_tokens_details: { cached_tokens: 0 }, }, }, }); const server = Bun.serve({ port: 0, fetch: async request => { requestTexts.push(await request.text()); if (requestTexts.length === 1) { const argumentsJson = JSON.stringify({ thoughts: "Checked the request before answering." }); return sse([ { type: "response.output_item.added", output_index: 0, item: { type: "function_call", id: "fc_think", call_id: "call_think", name: "think", arguments: "", }, }, { type: "response.function_call_arguments.done", output_index: 0, item_id: "fc_think", arguments: argumentsJson, }, { type: "response.output_item.done", output_index: 0, item: { type: "function_call", id: "fc_think", call_id: "call_think", name: "think", arguments: argumentsJson, }, }, completed("resp_think"), ]); } return sse([ { type: "response.output_text.delta", output_index: 0, delta: "Done." }, { type: "response.output_item.done", output_index: 0, item: { type: "message", id: "msg_done", role: "assistant", status: "completed", content: [{ type: "output_text", text: "Done." }], }, }, completed("resp_done"), ]); }, }); const model = getBundledModel("openai", "gpt-5"); if (!model) throw new Error("Expected gpt-5 model to exist"); const { session } = await createAgentSession({ ...baseOptions(tempDir), settings, model: { ...model, baseUrl: `${server.url}v1` }, getApiKey: () => "test-key", }); expect(session.getActiveToolNames()).toContain("think"); try { await session.prompt("Use the scratchpad before answering."); const firstRequest = requestTexts.at(0); if (!firstRequest) throw new Error("Expected the initial provider request."); expect(requestTexts).toHaveLength(2); expect(JSON.parse(firstRequest)).toEqual( expect.objectContaining({ reasoning: { effort: "off" }, tool_choice: expect.objectContaining({ name: "think" }), }), ); } finally { await session.dispose(); server.stop(true); } }); it("publishes tools from lazy session startup before the input lifecycle completes", async () => { const tempDir = makeTempDir(); const startupGate = Promise.withResolvers(); const lateRegistrationExtension: ExtensionFactory = pi => { let startupPromise: Promise | undefined; pi.on("session_start", () => { startupPromise = (async () => { await startupGate.promise; pi.registerTool({ name: "late_active_tool", label: "Late Active Tool", description: "Registered after asynchronous session initialization.", parameters: type({}), async execute() { return { content: [{ type: "text", text: "late active" }] }; }, }); pi.registerTool({ name: "late_inactive_tool", label: "Late Inactive Tool", description: "Registered late but left disabled by default.", parameters: type({}), defaultInactive: true, async execute() { return { content: [{ type: "text", text: "late inactive" }] }; }, }); })(); }); pi.on("input", async () => { await startupPromise; await pi.setActiveTools([...pi.getActiveTools(), "late_active_tool"]); }); }; const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [lateRegistrationExtension], }); try { expect(session.getAllToolNames()).not.toContain("late_active_tool"); const runner = session.extensionRunner; if (!runner) throw new Error("expected extension runner"); const errors: string[] = []; const unsubscribe = runner.onError(error => { errors.push(error.error); }); await initializeExtensions(session, { reportSendError: vi.fn(), reportRuntimeError: vi.fn(), }); expect(session.getAllToolNames()).not.toContain("late_active_tool"); startupGate.resolve(); await runner.emitInput("probe", undefined, "interactive"); unsubscribe(); expect(errors).toEqual([]); expect(session.getAllToolNames()).toEqual(expect.arrayContaining(["late_active_tool", "late_inactive_tool"])); expect(session.getEnabledToolNames()).toContain("late_active_tool"); expect(session.getEnabledToolNames()).not.toContain("late_inactive_tool"); expect(session.getXdevToolEntries().map(entry => entry.name)).toContain("late_active_tool"); expect(session.getActiveToolNames()).not.toContain("late_active_tool"); expect(session.systemPrompt.join("\n")).toContain("late_active_tool"); expect(session.systemPrompt.join("\n")).not.toContain("late_inactive_tool"); } finally { await session.dispose(); } }); it("activates explicitly requested defaultInactive tools registered during session startup", async () => { const tempDir = makeTempDir(); const lateRequestedExtension: ExtensionFactory = pi => { pi.on("session_start", async () => { await Promise.resolve(); pi.registerTool({ name: "late_requested_tool", label: "Late Requested Tool", description: "Registered asynchronously after being explicitly requested.", parameters: type({}), defaultInactive: true, async execute() { return { content: [{ type: "text", text: "late requested" }] }; }, }); }); }; const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [lateRequestedExtension], toolNames: ["read", "write", "late_requested_tool"], }); try { const runner = session.extensionRunner; if (!runner) throw new Error("expected extension runner"); await runner.emit({ type: "session_start" }); expect(session.getAllToolNames()).toContain("late_requested_tool"); expect(session.getEnabledToolNames()).toContain("late_requested_tool"); expect(session.getActiveToolNames()).toContain("late_requested_tool"); expect(session.getXdevToolEntries().map(entry => entry.name)).not.toContain("late_requested_tool"); expect(session.systemPrompt.join("\n")).toContain("late_requested_tool"); } finally { await session.dispose(); } }); it("deactivates an enabled tool when a late replacement is default-inactive", async () => { const tempDir = makeTempDir(); const lateInactiveReplacement: ExtensionFactory = pi => { pi.on("session_start", async () => { await Promise.resolve(); pi.registerTool({ name: "bash", label: "Late Inactive Bash", description: "A late replacement that must remain disabled by default.", parameters: type({}), defaultInactive: true, async execute() { return { content: [{ type: "text", text: "late inactive bash" }] }; }, }); }); }; const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [lateInactiveReplacement], }); try { expect(session.getEnabledToolNames()).toContain("bash"); const runner = session.extensionRunner; if (!runner) throw new Error("expected extension runner"); await runner.emit({ type: "session_start" }); expect(session.getToolByName("bash")?.label).toBe("Late Inactive Bash"); expect(session.hasBuiltInTool("bash")).toBe(false); expect(session.getEnabledToolNames()).not.toContain("bash"); expect(session.getActiveToolNames()).not.toContain("bash"); expect(session.getMountedXdevToolNames()).not.toContain("bash"); } finally { await session.dispose(); } }); it("publishes late tools before returning from a failing lifecycle handler", async () => { const tempDir = makeTempDir(); const activationEntered = Promise.withResolvers(); const releaseActivation = Promise.withResolvers(); const failingRegistrationExtension: ExtensionFactory = pi => { pi.on("session_start", async () => { await Promise.resolve(); pi.registerTool({ name: "late_tool_before_failure", label: "Late Tool Before Failure", description: "Registered before its lifecycle handler fails.", parameters: type({}), async execute() { return { content: [{ type: "text", text: "late tool before failure" }] }; }, }); throw new Error("expected lifecycle failure"); }); }; const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [failingRegistrationExtension], }); const originalSetPresentation = session.setActiveToolPresentation.bind(session); vi.spyOn(session, "setActiveToolPresentation").mockImplementation(async (toolNames, mountedToolNames) => { activationEntered.resolve(); await releaseActivation.promise; await originalSetPresentation(toolNames, mountedToolNames); }); const runner = session.extensionRunner; if (!runner) throw new Error("expected extension runner"); let emissionCompleted = false; const emission = runner.emit({ type: "session_start" }).finally(() => { emissionCompleted = true; }); try { await activationEntered.promise; // Drain the handler rejection and outer emit continuations without releasing the registration apply. await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); expect(emissionCompleted).toBe(false); releaseActivation.resolve(); await emission; expect(session.getAllToolNames()).toContain("late_tool_before_failure"); expect(session.getEnabledToolNames()).toContain("late_tool_before_failure"); expect(session.systemPrompt.join("\n")).toContain("late_tool_before_failure"); } finally { releaseActivation.resolve(); await emission; await session.dispose(); } }); it("keeps the stable MCP tool-name collision winner during late registration", async () => { const tempDir = makeTempDir(); const warn = vi.spyOn(logger, "warn").mockImplementation(() => {}); const lateMcpCollisionExtension: ExtensionFactory = pi => { pi.on("session_start", async () => { await Promise.resolve(); for (const [serverName, label] of [ ["foo.bar", "foo.bar/lookup"], ["foo_bar", "foo_bar/lookup"], ] as const) { pi.registerTool({ name: "mcp__foo_bar_lookup", label, description: `Lookup from ${serverName}`, parameters: type({}), mcpServerName: serverName, mcpToolName: "lookup", async execute() { return { content: [{ type: "text", text: serverName }] }; }, }); } }); }; const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [lateMcpCollisionExtension], }); try { const runner = session.extensionRunner; if (!runner) throw new Error("expected extension runner"); await runner.emit({ type: "session_start" }); expect(session.getToolByName("mcp__foo_bar_lookup")?.label).toBe("foo.bar/lookup"); await session.refreshMCPTools([ { name: "mcp__foo_bar_lookup", label: "foo_bar/lookup manager", description: "Colliding manager tool with the losing stable origin.", parameters: type({}), mcpServerName: "foo_bar", mcpToolName: "lookup", async execute() { return { content: [{ type: "text", text: "manager" }] }; }, } satisfies CustomTool, ]); expect(session.getToolByName("mcp__foo_bar_lookup")?.label).toBe("foo.bar/lookup"); expect(session.getEnabledToolNames()).toContain("mcp__foo_bar_lookup"); expect(warn).toHaveBeenCalledWith("MCP tool name collision; keeping stable winner", { name: "mcp__foo_bar_lookup", keptServer: "foo.bar", keptTool: "lookup", ignoredServer: "foo_bar", ignoredTool: "lookup", }); } finally { await session.dispose(); } }); it("keeps an inactive extension MCP winner disabled when a manager collision loses", async () => { const tempDir = makeTempDir(); const inactiveMcpExtension: ExtensionFactory = pi => { pi.registerTool({ name: "mcp__foo_bar_inactive", label: "Inactive extension winner", description: "Stable extension winner that starts disabled.", parameters: type({}), mcpServerName: "foo.bar", mcpToolName: "inactive", defaultInactive: true, async execute() { return { content: [{ type: "text", text: "extension" }] }; }, }); }; const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [inactiveMcpExtension], }); try { expect(session.getEnabledToolNames()).not.toContain("mcp__foo_bar_inactive"); await session.refreshMCPTools([ { name: "mcp__foo_bar_inactive", label: "Losing manager collision", description: "Manager origin loses stable deduplication.", parameters: type({}), mcpServerName: "foo_bar", mcpToolName: "inactive", async execute() { return { content: [{ type: "text", text: "manager" }] }; }, } satisfies CustomTool, ]); expect(session.getToolByName("mcp__foo_bar_inactive")?.label).toBe("Inactive extension winner"); expect(session.getEnabledToolNames()).not.toContain("mcp__foo_bar_inactive"); } finally { await session.dispose(); } }); it("refreshes an earlier extension's stable MCP winner instead of the later colliding registrant", async () => { const tempDir = makeTempDir(); const stableWinnerExtension: ExtensionFactory = pi => { pi.registerTool({ name: "mcp__foo_bar_refresh", label: "foo.bar/refresh connected", description: "Initial stable MCP winner.", parameters: type({}), mcpServerName: "foo.bar", mcpToolName: "refresh", async execute() { return { content: [{ type: "text", text: "connected" }] }; }, }); pi.on("session_start", async () => { await Promise.resolve(); pi.registerTool({ name: "mcp__foo_bar_refresh", label: "foo.bar/refresh reconnected", description: "Reconnected stable MCP winner.", parameters: type({}), mcpServerName: "foo.bar", mcpToolName: "refresh", async execute() { return { content: [{ type: "text", text: "reconnected" }] }; }, }); }); }; const collidingLoserExtension: ExtensionFactory = pi => { pi.registerTool({ name: "mcp__foo_bar_refresh", label: "foo_bar/refresh", description: "Later extension with the losing MCP origin.", parameters: type({}), mcpServerName: "foo_bar", mcpToolName: "refresh", async execute() { return { content: [{ type: "text", text: "loser" }] }; }, }); }; const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [stableWinnerExtension, collidingLoserExtension], }); try { expect(session.getToolByName("mcp__foo_bar_refresh")?.label).toBe("foo.bar/refresh connected"); const runner = session.extensionRunner; if (!runner) throw new Error("expected extension runner"); await runner.emit({ type: "session_start" }); expect(session.getToolByName("mcp__foo_bar_refresh")?.label).toBe("foo.bar/refresh reconnected"); } finally { await session.dispose(); } }); it("retains later-extension precedence when an earlier non-MCP registrant updates", async () => { const tempDir = makeTempDir(); const earlierExtension: ExtensionFactory = pi => { pi.registerTool({ name: "shared_lifecycle_tool", label: "Earlier Tool", description: "Earlier extension tool.", parameters: type({}), async execute() { return { content: [{ type: "text", text: "earlier" }] }; }, }); pi.on("session_start", async () => { await Promise.resolve(); pi.registerTool({ name: "shared_lifecycle_tool", label: "Updated Earlier Tool", description: "Updated earlier extension tool.", parameters: type({}), async execute() { return { content: [{ type: "text", text: "updated earlier" }] }; }, }); }); }; const laterExtension: ExtensionFactory = pi => { pi.registerTool({ name: "shared_lifecycle_tool", label: "Later Tool", description: "Later extension winner.", parameters: type({}), async execute() { return { content: [{ type: "text", text: "later" }] }; }, }); }; const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [earlierExtension, laterExtension], }); try { expect(session.getToolByName("shared_lifecycle_tool")?.label).toBe("Later Tool"); const runner = session.extensionRunner; if (!runner) throw new Error("expected extension runner"); await runner.emit({ type: "session_start" }); expect(session.getToolByName("shared_lifecycle_tool")?.label).toBe("Later Tool"); } finally { await session.dispose(); } }); it("preserves SDK custom-tool precedence when an extension registers the same name later", async () => { const tempDir = makeTempDir(); const lateCollisionExtension: ExtensionFactory = pi => { pi.on("session_start", async () => { await Promise.resolve(); pi.registerTool({ name: sdkCustomTool.name, label: "Late Extension Collision", description: "Extension tool that must not replace the SDK custom tool.", parameters: type({}), async execute() { return { content: [{ type: "text", text: "late extension" }] }; }, }); }); }; const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [lateCollisionExtension], customTools: [sdkCustomTool], }); try { expect(session.getToolByName(sdkCustomTool.name)?.label).toBe(sdkCustomTool.label); const runner = session.extensionRunner; if (!runner) throw new Error("expected extension runner"); await runner.emit({ type: "session_start" }); expect(session.getToolByName(sdkCustomTool.name)?.label).toBe(sdkCustomTool.label); } finally { await session.dispose(); } }); it("preserves RPC host-tool precedence when an extension registers the same name later", async () => { const tempDir = makeTempDir(); const rpcHostTool = { name: "rpc_host_collision", label: "RPC Host Tool", description: "Host-owned RPC tool.", parameters: type({}), async execute() { return { content: [{ type: "text", text: "rpc host" }] }; }, } satisfies AgentTool; const lateCollisionExtension: ExtensionFactory = pi => { pi.on("session_start", async () => { await Promise.resolve(); pi.registerTool({ name: rpcHostTool.name, label: "Late Extension Collision", description: "Extension tool that must not replace the RPC host tool.", parameters: type({}), async execute() { return { content: [{ type: "text", text: "late extension" }] }; }, }); }); }; const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [lateCollisionExtension], }); try { await session.refreshRpcHostTools([rpcHostTool]); const runner = session.extensionRunner; if (!runner) throw new Error("expected extension runner"); await runner.emit({ type: "session_start" }); expect(session.getToolByName(rpcHostTool.name)?.label).toBe(rpcHostTool.label); } finally { await session.dispose(); } }); it("serializes late extension activation with MCP refreshes", async () => { const tempDir = makeTempDir(); const activationEntered = Promise.withResolvers(); const releaseActivation = Promise.withResolvers(); const lateRegistrationExtension: ExtensionFactory = pi => { pi.on("session_start", async () => { await Promise.resolve(); pi.registerTool({ name: "serialized_lifecycle_tool", label: "Serialized Lifecycle Tool", description: "Lifecycle tool activated before an MCP refresh.", parameters: type({}), async execute() { return { content: [{ type: "text", text: "lifecycle" }] }; }, }); }); }; const mcpTool = { name: "mcp__serialized_refresh_lookup", label: "serialized/refresh lookup", description: "MCP tool refreshed during lifecycle activation.", parameters: type({}), mcpServerName: "serialized", mcpToolName: "refresh_lookup", async execute() { return { content: [{ type: "text", text: "mcp" }] }; }, } satisfies CustomTool; const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [lateRegistrationExtension], }); const originalSetActiveToolPresentation = session.setActiveToolPresentation.bind(session); vi.spyOn(session, "setActiveToolPresentation").mockImplementation(async (...args) => { activationEntered.resolve(); await releaseActivation.promise; return originalSetActiveToolPresentation(...args); }); try { const runner = session.extensionRunner; if (!runner) throw new Error("expected extension runner"); const emission = runner.emit({ type: "session_start" }); await activationEntered.promise; const mcpRefresh = session.refreshMCPTools([mcpTool]); await Promise.resolve(); expect(session.getToolByName(mcpTool.name)).toBeUndefined(); releaseActivation.resolve(); await Promise.all([emission, mcpRefresh]); expect(session.getEnabledToolNames()).toEqual( expect.arrayContaining(["serialized_lifecycle_tool", mcpTool.name]), ); } finally { releaseActivation.resolve(); await session.dispose(); } }); it("serializes complete memory-tool replacement with late extension activation", async () => { const tempDir = makeTempDir(); const activationEntered = Promise.withResolvers(); const releaseActivation = Promise.withResolvers(); const lateRegistrationExtension: ExtensionFactory = pi => { pi.on("session_start", async () => { await Promise.resolve(); pi.registerTool({ name: "memory_race_lifecycle_tool", label: "Memory Race Lifecycle Tool", description: "Lifecycle tool activated before a memory-tool replacement.", parameters: type({}), async execute() { return { content: [{ type: "text", text: "lifecycle" }] }; }, }); }); }; const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [lateRegistrationExtension], }); const originalSetActiveToolPresentation = session.setActiveToolPresentation.bind(session); vi.spyOn(session, "setActiveToolPresentation").mockImplementation(async (...args) => { activationEntered.resolve(); await releaseActivation.promise; return originalSetActiveToolPresentation(...args); }); try { const runner = session.extensionRunner; if (!runner) throw new Error("expected extension runner"); const emission = runner.emit({ type: "session_start" }); await activationEntered.promise; const memoryRefresh = session.applyMemoryBackend(); releaseActivation.resolve(); await Promise.all([emission, memoryRefresh]); expect(session.getEnabledToolNames()).toContain("memory_race_lifecycle_tool"); } finally { releaseActivation.resolve(); await session.dispose(); } }); it("keeps an explicitly disabled tool disabled when its extension re-registers it", async () => { const tempDir = makeTempDir(); const disabledReplacementExtension: ExtensionFactory = pi => { pi.registerTool({ name: "disabled_replacement_tool", label: "Initial Enabled Tool", description: "Initially enabled extension tool.", parameters: type({}), loadMode: "essential", async execute() { return { content: [{ type: "text", text: "initial" }] }; }, }); pi.on("session_start", async () => { await pi.setActiveTools(["read"]); pi.registerTool({ name: "disabled_replacement_tool", label: "Disabled Replacement Tool", description: "Replacement that must retain the disabled state.", parameters: type({}), loadMode: "essential", async execute() { return { content: [{ type: "text", text: "replacement" }] }; }, }); }); }; const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [disabledReplacementExtension], }); try { expect(session.getEnabledToolNames()).toContain("disabled_replacement_tool"); const runner = session.extensionRunner; if (!runner) throw new Error("expected extension runner"); const errors: string[] = []; const unsubscribe = runner.onError(error => { errors.push(error.error); }); await initializeExtensions(session, { reportSendError: vi.fn(), reportRuntimeError: vi.fn(), }); unsubscribe(); expect(errors).toEqual([]); expect(session.getToolByName("disabled_replacement_tool")?.label).toBe("Disabled Replacement Tool"); expect(session.getEnabledToolNames()).not.toContain("disabled_replacement_tool"); } finally { await session.dispose(); } }); it("reclassifies late replacements when their load modes change", async () => { const tempDir = makeTempDir(); const loadModeReplacementExtension: ExtensionFactory = pi => { const registerTransitionTool = (name: string, label: string, loadMode: "essential" | "discoverable"): void => { pi.registerTool({ name, label, description: `${label} extension tool.`, parameters: type({}), loadMode, async execute() { return { content: [{ type: "text", text: label }] }; }, }); }; registerTransitionTool("late_becomes_discoverable", "Initially Essential", "essential"); registerTransitionTool("late_becomes_essential", "Initially Discoverable", "discoverable"); pi.on("session_start", async () => { await Promise.resolve(); registerTransitionTool("late_becomes_discoverable", "Now Discoverable", "discoverable"); registerTransitionTool("late_becomes_essential", "Now Essential", "essential"); }); }; const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [loadModeReplacementExtension], }); try { expect(session.getActiveToolNames()).toContain("late_becomes_discoverable"); expect(session.getMountedXdevToolNames()).toContain("late_becomes_essential"); const runner = session.extensionRunner; if (!runner) throw new Error("expected extension runner"); await runner.emit({ type: "session_start" }); expect(session.getActiveToolNames()).not.toContain("late_becomes_discoverable"); expect(session.getMountedXdevToolNames()).toContain("late_becomes_discoverable"); expect(session.getActiveToolNames()).toContain("late_becomes_essential"); expect(session.getMountedXdevToolNames()).not.toContain("late_becomes_essential"); } finally { await session.dispose(); } }); it("refreshes prompt-visible metadata when a lifecycle registration replaces an enabled tool", async () => { const tempDir = makeTempDir(); const replacementExtension: ExtensionFactory = pi => { const register = (label: string, description: string): void => { pi.registerTool({ name: "prompt_refresh_tool", label, description, parameters: type({}), async execute() { return { content: [{ type: "text", text: label }] }; }, }); }; register("Original Prompt Tool", "Original prompt-visible lifecycle description."); pi.on("session_start", async () => { await Promise.resolve(); register("Replacement Prompt Tool", "Replacement prompt-visible lifecycle description."); }); }; const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [replacementExtension], }); try { expect(session.systemPrompt.join("\n")).toContain("Original prompt-visible lifecycle description."); const runner = session.extensionRunner; if (!runner) throw new Error("expected extension runner"); await runner.emit({ type: "session_start" }); const prompt = session.systemPrompt.join("\n"); expect(session.getToolByName("prompt_refresh_tool")?.label).toBe("Replacement Prompt Tool"); expect(prompt).toContain("Replacement prompt-visible lifecycle description."); expect(prompt).not.toContain("Original prompt-visible lifecycle description."); } finally { await session.dispose(); } }); it("restores a built-in tool and its provenance when a replacement prompt rebuild fails", async () => { let rejectReplacementPrompt = false; const releaseHandler = Promise.withResolvers(); const replacementRefreshAttempted = Promise.withResolvers(); const tempDir = makeTempDir(); const replacementExtension: ExtensionFactory = pi => { pi.on("session_start", async () => { await Promise.resolve(); pi.registerTool({ name: "bash", label: "Rejected Rollback Bash", description: "Rejected rollback lifecycle description.", parameters: type({ changed: type.string }), async execute() { return { content: [{ type: "text", text: "rejected" }] }; }, }); await releaseHandler.promise; }); }; const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [replacementExtension], systemPrompt: defaultPrompt => { if (rejectReplacementPrompt) { replacementRefreshAttempted.resolve(); throw new Error("expected replacement prompt failure"); } return defaultPrompt; }, }); let emission: Promise | undefined; try { const enabledBefore = session.getEnabledToolNames(); const mountedBefore = session.getMountedXdevToolNames(); const promptBefore = session.systemPrompt; const originalTool = session.getToolByName("bash"); expect(originalTool).toBeDefined(); expect(session.hasBuiltInTool("bash")).toBe(true); const runner = session.extensionRunner; if (!runner) throw new Error("expected extension runner"); const errors: string[] = []; const unsubscribe = runner.onError(error => { errors.push(error.error); }); rejectReplacementPrompt = true; emission = runner.emit({ type: "session_start" }); await replacementRefreshAttempted.promise; expect(errors).not.toContain("expected replacement prompt failure"); releaseHandler.resolve(); await emission; unsubscribe(); expect(errors).toContain("expected replacement prompt failure"); expect(session.getToolByName("bash")).toBe(originalTool); expect(session.hasBuiltInTool("bash")).toBe(true); expect(session.getEnabledToolNames()).toEqual(enabledBefore); expect(session.getMountedXdevToolNames()).toEqual(mountedBefore); expect(session.systemPrompt).toEqual(promptBefore); } finally { releaseHandler.resolve(); await emission; await session.dispose(); } }); it("waits for later registrations after an earlier activation fails", async () => { const tempDir = makeTempDir(); const releaseLaterActivation = Promise.withResolvers(); const laterActivationEntered = Promise.withResolvers(); const registrationExtension: ExtensionFactory = pi => { pi.on("session_start", async () => { await Promise.resolve(); for (const name of ["failed_registration_tool", "drained_registration_tool"]) { pi.registerTool({ name, label: name, description: `${name} lifecycle description.`, parameters: type({}), async execute() { return { content: [{ type: "text", text: name }] }; }, }); } }); }; const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [registrationExtension], }); const originalSetPresentation = session.setActiveToolPresentation.bind(session); vi.spyOn(session, "setActiveToolPresentation").mockImplementation( async (toolNames, mountedToolNames, forcePromptRefresh) => { if (toolNames.includes("failed_registration_tool")) throw new Error("expected activation failure"); if (toolNames.includes("drained_registration_tool")) { laterActivationEntered.resolve(); await releaseLaterActivation.promise; } await originalSetPresentation(toolNames, mountedToolNames, forcePromptRefresh); }, ); const runner = session.extensionRunner; if (!runner) throw new Error("expected extension runner"); const errors: string[] = []; runner.onError(error => { errors.push(error.error); }); let emissionCompleted = false; const emission = runner.emit({ type: "session_start" }).finally(() => { emissionCompleted = true; }); try { await laterActivationEntered.promise; await Promise.resolve(); await Promise.resolve(); expect(emissionCompleted).toBe(false); expect(errors).toEqual([]); releaseLaterActivation.resolve(); await emission; expect(errors).toContain("expected activation failure"); expect(session.getToolByName("failed_registration_tool")).toBeUndefined(); expect(session.getToolByName("drained_registration_tool")).toBeDefined(); expect(session.systemPrompt.join("\n")).toContain("drained_registration_tool"); } finally { releaseLaterActivation.resolve(); await emission; await session.dispose(); } }); it("releases a timed-out activation so later lifecycle registrations can proceed", async () => { const tempDir = makeTempDir(); const registrationExtension: ExtensionFactory = pi => { for (const name of ["stalled_registration_tool", "recovered_registration_tool"]) { pi.on("session_start", async () => { await Promise.resolve(); pi.registerTool({ name, label: name, description: `${name} lifecycle tool.`, parameters: type({}), loadMode: "essential", async execute() { return { content: [{ type: "text", text: name }] }; }, }); }); } }; const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [registrationExtension], }); try { const originalSetPresentation = session.setActiveToolPresentation.bind(session); vi.spyOn(session, "setActiveToolPresentation") .mockImplementationOnce((_toolNames, _mountedToolNames, _forcePromptRefresh, signal) => untilAborted(signal, Promise.withResolvers().promise), ) .mockImplementation(originalSetPresentation); const runner = session.extensionRunner; if (!runner) throw new Error("expected extension runner"); const errors: string[] = []; const unsubscribe = runner.onError(error => { errors.push(error.error); }); testSetExtensionHandlerTimeoutMs(250); await runner.emit({ type: "session_start" }); unsubscribe(); expect(errors).toContain("handler timed out after 250ms"); expect(session.getToolByName("stalled_registration_tool")).toBeUndefined(); expect(session.getToolByName("recovered_registration_tool")?.label).toBe("recovered_registration_tool"); expect(session.getEnabledToolNames()).toContain("recovered_registration_tool"); } finally { await session.dispose(); } }); it("applies explicit tool selection after preceding lifecycle registrations", async () => { const tempDir = makeTempDir(); const registrationExtension: ExtensionFactory = pi => { pi.on("session_start", async () => { pi.registerTool({ name: "register_then_select_tool", label: "Register Then Select Tool", description: "Must not overwrite the explicit selection that follows registration.", parameters: type({}), async execute() { return { content: [{ type: "text", text: "registered" }] }; }, }); await pi.setActiveTools(["read"]); }); }; const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [registrationExtension], }); try { await initializeExtensions(session, { reportSendError: vi.fn(), reportRuntimeError: vi.fn(), }); expect(session.getAllToolNames()).toContain("register_then_select_tool"); expect(session.getEnabledToolNames()).toContain("read"); expect(session.getEnabledToolNames()).not.toContain("register_then_select_tool"); expect(session.getMountedXdevToolNames()).not.toContain("register_then_select_tool"); } finally { await session.dispose(); } }); it("attributes detached registration failures without waiting for another lifecycle handler", async () => { const tempDir = makeTempDir(); const releaseDetachedRegistration = Promise.withResolvers(); const registrationFailure = Promise.withResolvers<{ event: string; error: string }>(); let rejectDetachedPrompt = false; const detachedRegistrationExtension: ExtensionFactory = pi => { pi.on("session_start", () => { void releaseDetachedRegistration.promise.then(() => { pi.registerTool({ name: "detached_registration_tool", label: "Detached Registration Tool", description: "Detached tool whose activation intentionally fails.", parameters: type({}), async execute() { return { content: [{ type: "text", text: "detached" }] }; }, }); }); }); }; const { session } = await createAgentSession({ ...baseOptions(tempDir), settings: Settings.isolated({ "bashInterceptor.enabled": true, "bashInterceptor.patterns": [ { pattern: "^\\s*printf\\s+", tool: "detached_registration_tool", message: "Use the detached registration tool.", }, ], }), autoApprove: true, extensions: [detachedRegistrationExtension], systemPrompt: defaultPrompt => { if (rejectDetachedPrompt) throw new Error("expected detached registration failure"); return defaultPrompt; }, }); try { await initializeExtensions(session, { reportSendError: vi.fn(), reportRuntimeError: error => { if (error.error === "expected detached registration failure") { registrationFailure.resolve({ event: error.event, error: error.error }); } }, }); rejectDetachedPrompt = true; releaseDetachedRegistration.resolve(); expect(await registrationFailure.promise).toEqual({ event: "tool_registration", error: "expected detached registration failure", }); expect(session.getToolByName("detached_registration_tool")).toBeUndefined(); rejectDetachedPrompt = false; const toolCallId = "detached-rollback-bash"; const mock = createMockModel({ responses: [ { content: [ { type: "toolCall", id: toolCallId, name: "bash", arguments: { command: "printf rollback-ok" }, }, ], }, { content: [{ type: "text", text: "done" }] }, ], }); vi.spyOn(session.agent, "streamFn").mockImplementation(mock.stream); await withProviderAuth(["openai"], async () => { await session.prompt("verify rollback context"); const bashResult = session.messages.find( (message): message is ToolResultMessage => message.role === "toolResult" && message.toolCallId === toolCallId, ); expect(bashResult?.isError).toBe(false); expect(JSON.stringify(bashResult?.content)).toContain("rollback-ok"); }); } finally { releaseDetachedRegistration.resolve(); await session.dispose(); } }); it("times out detached activations without blocking later registrations", async () => { const tempDir = makeTempDir(); const releaseStalledRegistration = Promise.withResolvers(); const releaseRecoveredRegistration = Promise.withResolvers(); const detachedRegistrationExtension: ExtensionFactory = pi => { pi.on("session_start", () => { void releaseStalledRegistration.promise.then(() => { pi.registerTool({ name: "stalled_detached_tool", label: "Stalled Detached Tool", description: "Detached registration whose activation stalls.", parameters: type({}), loadMode: "essential", async execute() { return { content: [{ type: "text", text: "stalled" }] }; }, }); }); void releaseRecoveredRegistration.promise.then(() => { pi.registerTool({ name: "recovered_detached_tool", label: "Recovered Detached Tool", description: "Detached registration that follows the timeout.", parameters: type({}), loadMode: "essential", async execute() { return { content: [{ type: "text", text: "recovered" }] }; }, }); }); }); }; const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [detachedRegistrationExtension], }); try { await initializeExtensions(session, { reportSendError: vi.fn(), reportRuntimeError: vi.fn(), }); const runner = session.extensionRunner; if (!runner) throw new Error("expected extension runner"); const detachedFailure = Promise.withResolvers<{ event: string; error: string }>(); runner.onError(error => { if (error.event === "tool_registration") { detachedFailure.resolve({ event: error.event, error: error.error }); } }); const recoveredActivation = Promise.withResolvers(); const originalSetPresentation = session.setActiveToolPresentation.bind(session); vi.spyOn(session, "setActiveToolPresentation") .mockImplementationOnce((_toolNames, _mountedToolNames, _forcePromptRefresh, signal) => untilAborted(signal, Promise.withResolvers().promise), ) .mockImplementation(async (toolNames, mountedToolNames, forcePromptRefresh, signal) => { await originalSetPresentation(toolNames, mountedToolNames, forcePromptRefresh, signal); if (toolNames.includes("recovered_detached_tool")) recoveredActivation.resolve(); }); testSetExtensionHandlerTimeoutMs(250); releaseStalledRegistration.resolve(); const failure = await detachedFailure.promise; releaseRecoveredRegistration.resolve(); await recoveredActivation.promise; expect(failure.event).toBe("tool_registration"); expect(failure.error).toContain("timed out"); expect(session.getToolByName("stalled_detached_tool")).toBeUndefined(); expect(session.getToolByName("recovered_detached_tool")?.label).toBe("Recovered Detached Tool"); } finally { releaseStalledRegistration.resolve(); releaseRecoveredRegistration.resolve(); await session.dispose(); } }); it("forwards built-in and external xd:// devices to Cursor provider contexts", async () => { const tempDir = makeTempDir(); const cursorModel = getBundledModel("cursor", "composer-1.5"); if (!cursorModel) throw new Error("expected bundled Cursor model"); const { session } = await createAgentSession({ ...baseOptions(tempDir), model: cursorModel, }); const externalMcpTool: CustomTool = { name: "mcp__fixture_report", label: "fixture/report", description: "Report a fixture result.", parameters: type({}), strict: true, mcpServerName: "fixture", mcpToolName: "report", async execute() { return { content: [{ type: "text", text: "reported" }] }; }, }; try { await session.refreshMCPTools([externalMcpTool]); const deviceNames = session.getXdevToolEntries().map(entry => entry.name); expect(deviceNames).toEqual(expect.arrayContaining(["ast_edit", "mcp__fixture_report"])); expect(session.getActiveToolNames()).not.toContain("mcp__fixture_report"); const context = await session.agent.buildSideRequestContext([]); const providerToolNames = context.tools?.map(tool => tool.name); expect(providerToolNames).toEqual(expect.arrayContaining(["ast_edit", "mcp__fixture_report"])); } finally { await session.dispose(); } }); it("allows explicitly requested defaultInactive extension tools into the initial active set", async () => { const tempDir = makeTempDir(); const { session } = await createAgentSession({ ...baseOptions(tempDir), extensions: [toolActivationExtension], toolNames: ["read", "default_inactive_tool"], }); try { expect(session.getActiveToolNames()).toEqual( expect.arrayContaining(["read", "default_inactive_tool", "default_active_tool"]), ); // No granted write tool → no xd:// transport: extension tools surface // top-level instead of mounting with an auto-granted write. expect(session.getActiveToolNames()).not.toContain("write"); expect(session.getXdevToolEntries()).toEqual([]); expect(session.systemPrompt.join("\n")).toContain("default_inactive_tool"); } finally { await session.dispose(); } }); it("activates the yield tool when requireYieldTool is set and toolNames is explicit", async () => { // Regression for #1408: plan-mode subagents pass an explicit `toolNames` list // (e.g. `["read", "grep", "glob", "lsp", "web_search"]`). Without this // invariant, `yield` ended up registered but not active, and the model // could not satisfy the idle-reminder contract that demands a `yield` call. const tempDir = makeTempDir(); const { session } = await createAgentSession({ ...baseOptions(tempDir), requireYieldTool: true, toolNames: ["read", "grep", "glob", "web_search"], }); try { expect(session.getActiveToolNames()).toContain("yield"); } finally { await session.dispose(); } }); it("normalizes legacy builtin toolNames before selecting the active SDK tools", async () => { const tempDir = makeTempDir(); const { session } = await createAgentSession({ ...baseOptions(tempDir), toolNames: ["read", "search", "find"], }); try { const activeToolNames = session.getActiveToolNames(); expect(activeToolNames).toContain("read"); expect(activeToolNames).toContain("grep"); expect(activeToolNames).toContain("glob"); expect(activeToolNames).not.toContain("search"); expect(activeToolNames).not.toContain("find"); } finally { await session.dispose(); } }); it("keeps the write tool registered for plan mode even when no deferrable tool is requested", async () => { // Regression for #1428 (adapted to the xd://propose device): plan mode // submits its finalized plan by writing the chosen slug/title to // xd://propose, dispatched through the plan-proposal handler // (interactive-mode.ts: `setPlanProposalHandler`). With an explicit // read-only `toolNames` (e.g. `read`, `search`, `find`, `web_search`) // the registry has no `write` and no `deferrable` tool; dropping it would // silently activate plan mode with no way to submit the plan. const tempDir = makeTempDir(); const { session } = await createAgentSession({ ...baseOptions(tempDir), toolNames: ["read", "grep", "glob", "web_search"], }); try { expect(session.getToolByName("write")).toBeDefined(); } finally { await session.dispose(); } }); it("does not force write into the registry when neither a deferrable tool nor plan mode needs it", async () => { const tempDir = makeTempDir(); const settings = Settings.isolated(); settings.set("plan.enabled", false); const { session } = await createAgentSession({ ...baseOptions(tempDir), settings, toolNames: ["read", "grep", "glob", "web_search"], }); try { expect(session.getToolByName("write")).toBeUndefined(); } finally { await session.dispose(); } }); it("does not activate write merely because plan mode is available", async () => { const tempDir = makeTempDir(); const { session } = await createAgentSession({ ...baseOptions(tempDir), toolNames: ["read"], }); try { await session.setActiveToolsByName(["read"]); expect(session.getActiveToolNames()).not.toContain("write"); } finally { await session.dispose(); } }); it("preserves write explicitly selected by a runtime caller", async () => { const tempDir = makeTempDir(); const { session } = await createAgentSession({ ...baseOptions(tempDir), toolNames: ["read"], }); try { await session.setActiveToolsByName(["read", "write"]); await session.refreshMCPTools([]); expect(session.getActiveToolNames()).toContain("write"); } finally { await session.dispose(); } }); it("registers vibe tools only during explicit vibe activation and exposes parent Todo bookkeeping", async () => { const tempDir = makeTempDir(); const { session } = await createAgentSession(baseOptions(tempDir)); const previousActiveToolNames = session.getActiveToolNames(); try { for (const name of VIBE_TOOL_NAMES) { expect(session.getToolByName(name)).toBeUndefined(); } await session.activateVibeTools(["read", "todo"]); const todo = session.getToolByName("todo"); if (!todo) throw new Error("Expected real Todo tool"); expect(session.getActiveToolNames()).toContain("todo"); for (const name of VIBE_TOOL_NAMES) { expect(session.getToolByName(name)).toBeDefined(); expect(session.getActiveToolNames()).toContain(name); } await todo.execute("vibe-todo-init", { op: "init", list: [{ phase: "Work", items: ["Worker change"] }], }); await todo.execute("vibe-todo-done", { op: "done", task: "Worker change" }); expect(session.getTodoPhases()).toMatchObject([ { name: "Work", tasks: [{ content: "Worker change", status: "completed" }], }, ]); await session.deactivateVibeTools(previousActiveToolNames); for (const name of VIBE_TOOL_NAMES) { expect(session.getToolByName(name)).toBeUndefined(); } expect(session.getActiveToolNames()).toEqual(previousActiveToolNames); } finally { await session.dispose(); } }); it("rehydrates completed parent Todo work from persisted session history", async () => { const tempDir = makeTempDir(); const sessionManager = SessionManager.create(tempDir, tempDir); const { session } = await createAgentSession({ ...baseOptions(tempDir), sessionManager, }); try { await session.activateVibeTools(["read", "todo"]); const todo = session.getToolByName("todo"); if (!todo) throw new Error("Expected real Todo tool"); const init = await todo.execute("vibe-todo-init", { op: "init", list: [{ phase: "Worker flow", items: ["Reconcile worker result"] }], }); const done = await todo.execute("vibe-todo-done", { op: "done", task: "Reconcile worker result" }); for (const [toolCallId, result] of [ ["vibe-todo-init", init], ["vibe-todo-done", done], ] as const) { sessionManager.appendMessage({ role: "toolResult", toolCallId, toolName: "todo", content: result.content, details: result.details, isError: result.isError === true, timestamp: Date.now(), }); } await sessionManager.ensureOnDisk(); const sessionFile = session.sessionFile; if (!sessionFile) throw new Error("Expected persisted session file"); session.setTodoPhases([]); expect(session.getTodoPhases()).toEqual([]); expect(await session.switchSession(sessionFile)).toBe(true); expect(session.getTodoPhases()).toMatchObject([ { name: "Worker flow", tasks: [{ content: "Reconcile worker result", status: "completed" }], }, ]); } finally { await session.dispose(); } }); it("does not register the xAI TTS tool unless enabled", async () => { const tempDir = makeTempDir(); const { session } = await createAgentSession({ ...baseOptions(tempDir), }); try { expect(session.getToolByName("tts")).toBeUndefined(); expect(session.getAllToolNames()).not.toContain("tts"); expect(session.getActiveToolNames()).not.toContain("tts"); } finally { await session.dispose(); } }); it("registers the xAI TTS tool when enabled", async () => { const tempDir = makeTempDir(); const { session } = await createAgentSession({ ...baseOptions(tempDir), settings: Settings.isolated({ "speechgen.enabled": true }), }); try { expect(session.getToolByName("tts")).toBeDefined(); // tts is a discoverable custom tool → mounted as an xd:// device, not top-level. expect(session.getXdevToolEntries().map(entry => entry.name)).toContain("tts"); expect(session.getActiveToolNames()).not.toContain("tts"); } finally { await session.dispose(); } }); it("keeps the stable MCP tool-name collision winner during SDK startup and warns", async () => { const tempDir = makeTempDir(); const warn = vi.spyOn(logger, "warn").mockImplementation(() => {}); const createMcpTool = (serverName: string, label: string): CustomTool => ({ name: "mcp__foo_bar_lookup", label, description: `Lookup from ${serverName}`, parameters: type({}), mcpServerName: serverName, mcpToolName: "lookup", async execute() { return { content: [{ type: "text", text: serverName }] }; }, }); const { session } = await createAgentSession({ ...baseOptions(tempDir), customTools: [createMcpTool("foo.bar", "foo.bar/lookup"), createMcpTool("foo_bar", "foo_bar/lookup")], }); try { expect(session.getToolByName("mcp__foo_bar_lookup")?.label).toBe("foo.bar/lookup"); expect(warn).toHaveBeenCalledWith("MCP tool name collision; keeping stable winner", { name: "mcp__foo_bar_lookup", keptServer: "foo.bar", keptTool: "lookup", ignoredServer: "foo_bar", ignoredTool: "lookup", }); } finally { await session.dispose(); } }); it("keeps restricted host tool lists isolated from configured custom capabilities", async () => { const restrictedDir = makeTempDir(); const normalDir = makeTempDir(); const configuredSettings = () => Settings.isolated({ "providers.imageOrder": ["openai"], "generate_image.enabled": true, "speechgen.enabled": true, "memory.backend": "hindsight", "autolearn.enabled": true, }); const inheritedManager = { getServerInstructions: () => new Map([["private-server", "must not reach restricted child"]]), } as unknown as MCPManager; const restrictedLateExtension: ExtensionFactory = pi => { pi.on("session_start", async () => { await Promise.resolve(); pi.registerTool({ name: "restricted_late_extension_tool", label: "Restricted Late Extension Tool", description: "Must not enter a caller-restricted session.", parameters: type({}), async execute() { return { content: [{ type: "text", text: "restricted late" }] }; }, }); }); }; const { session: restricted } = await createAgentSession({ ...baseOptions(restrictedDir), settings: configuredSettings(), extensions: [toolActivationExtension, restrictedLateExtension], customTools: [sdkCustomTool], toolNames: ["read", "lsp", "hub"], requireYieldTool: true, restrictToolNames: true, enableMCP: true, mcpManager: inheritedManager, enableLsp: true, enableIrc: true, }); try { await initializeExtensions(restricted, { reportSendError: vi.fn(), reportRuntimeError: vi.fn(), }); expect(restricted.getAllToolNames()).toEqual(["read", "lsp", "yield"]); expect(restricted.getActiveToolNames()).toEqual(["read", "lsp", "yield"]); for (const name of [ "generate_image", "tts", "recall", "retain", "reflect", "learn", "manage_skill", "default_active_tool", "default_inactive_tool", "sdk_custom_tool", "restricted_late_extension_tool", "hub", ]) { expect(restricted.getToolByName(name)).toBeUndefined(); } expect(restricted.getXdevToolEntries()).toEqual([]); expect(restricted.systemPrompt.join("\n")).not.toContain("private-server"); expect(restricted.systemPrompt.join("\n")).not.toContain("MCP Server Instructions"); } finally { await restricted.dispose(); } const { session: normal } = await createAgentSession({ ...baseOptions(normalDir), settings: configuredSettings(), extensions: [toolActivationExtension], customTools: [sdkCustomTool], toolNames: ["read", "generate_image"], requireYieldTool: true, restrictToolNames: false, }); try { const activeToolNames = normal.getActiveToolNames(); expect(activeToolNames).toEqual( expect.arrayContaining([ "read", "yield", "generate_image", "learn", "manage_skill", "tts", "default_active_tool", "sdk_custom_tool", ]), ); // Without a granted write tool the session allocates no xd:// state; // SDK custom and extension capabilities surface top-level instead. expect(activeToolNames).not.toContain("write"); expect(normal.getXdevToolEntries()).toEqual([]); expect(normal.getAllToolNames()).toEqual( expect.arrayContaining([ "generate_image", "read", "yield", "tts", "default_active_tool", "sdk_custom_tool", "recall", "retain", "reflect", ]), ); } finally { await normal.dispose(); } }); it("permits only explicitly named SDK custom tools when a restricted caller opts in", async () => { const tempDir = makeTempDir(); const { session } = await createAgentSession({ ...baseOptions(tempDir), customTools: [sdkCustomTool], toolNames: ["read", "sdk_custom_tool"], restrictToolNames: true, allowRestrictedCustomTools: true, }); try { expect(session.getAllToolNames()).toEqual(["read", "sdk_custom_tool"]); expect(session.getActiveToolNames()).toEqual(["read", "sdk_custom_tool"]); expect(session.getToolByName("sdk_custom_tool")).toBeDefined(); } finally { await session.dispose(); } }); it("renders report-issue guidance only for unrestricted sessions", async () => { const normalDir = makeTempDir(); const restrictedDir = makeTempDir(); const { session: normal } = await createAgentSession({ ...baseOptions(normalDir), settings: Settings.isolated({ "dev.autoqa": true }), }); const { session: restricted } = await createAgentSession({ ...baseOptions(restrictedDir), settings: Settings.isolated({ "dev.autoqa": true }), toolNames: ["read"], restrictToolNames: true, }); try { expect(normal.systemPrompt.join("\n")).toContain("xd://report_issue"); expect(restricted.systemPrompt.join("\n")).not.toContain("xd://report_issue"); } finally { await Promise.all([normal.dispose(), restricted.dispose()]); } }); it("ignores an inherited MCP manager when MCP is disabled", async () => { const tempDir = makeTempDir(); const inheritedManager = { getServerInstructions: () => new Map([["private-server", "must not reach restricted child"]]), } as unknown as MCPManager; const { session } = await createAgentSession({ ...baseOptions(tempDir), enableMCP: false, mcpManager: inheritedManager, }); try { expect(session.systemPrompt.join("\n")).not.toContain("private-server"); expect(session.systemPrompt.join("\n")).not.toContain("MCP Server Instructions"); } finally { await session.dispose(); } }); // A session created on another provider keeps its configured-mode `edit` in // the registry (only a Cursor-created session moves it out) and the tool // roster is built once, at creation — switching to Cursor later does not // rebuild it. These two cover both directions of that wiring: the granted // session must still reach a replace-mode instance for `pi_edit` (whose // `old_string`/`new_string` args do not validate against the default `hashline` // schema), and the restricted one must still be refused. // // The handlers are internal to the session; `streamFn` is where they are // handed to the provider, which is the externally observable seam. const captureCursorExecHandlers = async (session: AgentSession, cursorModel: Model): Promise => { let handlers: CursorExecHandlers | undefined; const streamFn: StreamFn = (_model, _context, options) => { // The session installs the concrete class; the provider option is // typed as the wire-level interface, whose `piEdit` answers a proto // result rather than the tool result the class returns. handlers = options?.cursorExecHandlers as CursorExecHandlers | undefined; throw new Error("captured"); }; vi.spyOn(session.agent, "streamFn").mockImplementation(streamFn); await session.setModel(cursorModel); // Not wrapped in a catch: `prompt` resolves even when the turn fails (the // loop records the stream error), so a rejection here is a genuine setup // failure and must surface rather than be mistaken for the capture. await session.prompt("hi"); if (!handlers) throw new Error("no exec handlers reached the provider"); return handlers; }; // `setModel` and `prompt` both refuse a provider with no configured auth. // Granted on the suite's isolated storage rather than through the provider's // env var — an env mutation would outlive this file — and removed after, // since the storage is shared by every test here. const withProviderAuth = async (providers: string[], run: () => Promise): Promise => { for (const provider of providers) modelRegistry.authStorage.setRuntimeApiKey(provider, "test-key"); try { await run(); } finally { for (const provider of providers) modelRegistry.authStorage.removeRuntimeApiKey(provider); } }; it("answers a native pi_edit after a session switches onto Cursor", async () => { const tempDir = makeTempDir(); const cursorModel = getBundledModel("cursor", "composer-1.5"); if (!cursorModel) throw new Error("expected bundled Cursor model"); const target = path.join(tempDir, "sample.txt"); fs.writeFileSync(target, "alpha\nbeta\n"); await withProviderAuth(["cursor"], async () => { const { session } = await createAgentSession(baseOptions(tempDir)); try { const handlers = await captureCursorExecHandlers(session, cursorModel); const result = await handlers.piEdit({ toolCallId: "sdk-switch-1", args: { path: target, edits: [{ oldText: "beta", newText: "gamma" }] }, } as never); expect(result.isError).toBeFalsy(); expect(fs.readFileSync(target, "utf8")).toBe("alpha\ngamma\n"); } finally { await session.dispose(); } }); }); it("refuses a native pi_edit after a read-only session switches onto Cursor", async () => { // The bridge instance is constructed, not looked up, so building it for // a roster that was never granted `edit` would hand a read-only session // a mutating tool the native frames reach regardless of the advertised // catalog (issue #5680). Making the construction provider-independent // must not widen it. const tempDir = makeTempDir(); const cursorModel = getBundledModel("cursor", "composer-1.5"); if (!cursorModel) throw new Error("expected bundled Cursor model"); const target = path.join(tempDir, "sample.txt"); fs.writeFileSync(target, "alpha\nbeta\n"); await withProviderAuth(["cursor"], async () => { const { session } = await createAgentSession({ ...baseOptions(tempDir), toolNames: ["read"] }); try { const handlers = await captureCursorExecHandlers(session, cursorModel); const result = await handlers.piEdit({ toolCallId: "sdk-switch-2", args: { path: target, edits: [{ oldText: "beta", newText: "gamma" }] }, } as never); expect(result.isError).toBe(true); expect(fs.readFileSync(target, "utf8")).toBe("alpha\nbeta\n"); } finally { await session.dispose(); } }); }); it("resolves bridge frame paths through the session's live cwd", async () => { // The bridge is built once, at session creation, while the session's cwd // moves under it (`/cd`, resume, branch restore). The path-confining // frames — the native `delete`, and a `download_path` resource read — // resolve a relative path against whichever cwd the bridge was handed, so // a startup snapshot means acting on the workspace the session has left // while reporting success for the path the server named. const tempDir = makeTempDir(); const movedDir = makeTempDir(); const cursorModel = getBundledModel("cursor", "composer-1.5"); if (!cursorModel) throw new Error("expected bundled Cursor model"); const staleTarget = path.join(tempDir, "obsolete.txt"); const liveTarget = path.join(movedDir, "obsolete.txt"); fs.writeFileSync(staleTarget, "preserve me"); fs.writeFileSync(liveTarget, "remove me"); await withProviderAuth(["cursor"], async () => { const sessionManager = SessionManager.inMemory(); const { session } = await createAgentSession({ ...baseOptions(tempDir), sessionManager }); try { const handlers = await captureCursorExecHandlers(session, cursorModel); await sessionManager.moveTo(movedDir); const result = await handlers.delete({ toolCallId: "sdk-cwd-1", path: "obsolete.txt" } as never); expect(result.isError).toBe(false); expect(fs.existsSync(liveTarget)).toBe(false); expect(fs.existsSync(staleTarget)).toBe(true); } finally { await session.dispose(); } }); }); it("does not execute an unadvertised edit call through the fallback resolver", async () => { // One resolver serves two roles: the session's device resolver is passed // to the bridge as `getTool` AND installed as the agent loop's // `resolveFallbackTool`, which runs for ANY call the advertised set does // not contain. It must stay device-only: routing `edit` through it would // execute a replace-mode edit for a call the model was never offered — // a hallucinated one, or a tool the session deselected after startup. // `pi_edit` gets its instance from `getEditReplaceTool` instead. const tempDir = makeTempDir(); const target = path.join(tempDir, "sample.txt"); fs.writeFileSync(target, "alpha\nbeta\n"); await withProviderAuth(["openai"], async () => { // Granted at startup, so an `edit` instance exists to leak, then // deselected — the exact state that makes the fallback dangerous. const { session } = await createAgentSession(baseOptions(tempDir)); try { await session.setActiveToolsByName(session.getActiveToolNames().filter(name => name !== "edit")); expect(session.getActiveToolNames()).not.toContain("edit"); // A real mock provider, not a hand-rolled stream: the loop builds // the assistant message from the full event sequence, and an // incomplete one is dropped before tool dispatch ever runs. const toolCallId = "unadvertised-edit-1"; const mock = createMockModel({ responses: [ { content: [ { type: "toolCall", id: toolCallId, name: "edit", arguments: { path: target, old_string: "beta", new_string: "gamma" }, }, ], }, { content: [{ type: "text", text: "done" }] }, ], }); vi.spyOn(session.agent, "streamFn").mockImplementation(mock.stream); await session.prompt("hi"); // The surfaced result, not just the file: an unchanged file alone // would also pass if the fallback HAD resolved the tool and the // edit then failed validation or approval. Only "not found" // proves the resolver refused to hand one over. const result = session.messages.find( (message): message is ToolResultMessage => message.role === "toolResult" && message.toolCallId === toolCallId, ); expect(result?.isError).toBe(true); expect(JSON.stringify(result?.content)).toContain("Tool edit not found"); expect(fs.readFileSync(target, "utf8")).toBe("alpha\nbeta\n"); } finally { await session.dispose(); } }); }); it("runs advisor tools through the approval gate", async () => { // The advisor's tools are built straight from `BUILTIN_TOOLS`, outside // the registry loop that wraps everything else. Its own loop and its // Cursor exec bridge (`piWrite`/`piBash`) run those instances directly, // so an unwrapped one executes whatever it is handed regardless of the // user's `tools.approval.` policy — the gate lives in // `ExtensionToolWrapper`, not in either caller. const tempDir = makeTempDir(); const target = path.join(tempDir, "advisor-write.txt"); // An advisor only builds once a model resolves for it, and both the // explicit override and the `advisor` role chain resolve against // `modelRegistry.getAvailable()` — the models this machine holds auth // for. Grant the suite's isolated storage a key and name the model // outright, or the roster silently resolves to `no_model` wherever no // provider is configured (CI) while passing on a developer box whose // environment happens to carry provider keys. await withProviderAuth(["openai"], async () => { const { session } = await createAgentSession({ ...baseOptions(tempDir), settings: Settings.isolated({ "advisor.enabled": true, "tools.approval": { write: "deny" } }), }); try { // The default advisor roster is read-only (read/grep/glob); the // reviewed hole needs one actually granted a mutating tool. session.applyAdvisorConfigs([{ name: "writer", tools: ["write"], model: "gpt-4o-mini" }], undefined); const advisor = session.getAdvisorAgent(); if (!advisor) throw new Error("expected an advisor agent"); const writeTool = advisor.state.tools?.find(tool => tool.name === "write"); if (!writeTool) throw new Error("expected the advisor to hold a write tool"); // The gate rejects rather than returning an error result — that throw // IS the refusal, and it only happens when the instance is wrapped. await expect( writeTool.execute("advisor-w1", { path: target, content: "written" }, undefined, undefined, { settings: session.settings, } as never), ).rejects.toThrow(/blocked by user policy/); expect(fs.existsSync(target)).toBe(false); } finally { await session.dispose(); } }); }); });