import { afterEach, 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 { getBundledModel } from "@oh-my-pi/pi-ai"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { createAgentSession, type ExtensionFactory } from "@oh-my-pi/pi-coding-agent/sdk"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { Snowflake } from "@oh-my-pi/pi-utils"; import * as z from "zod/v4"; 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: z.object({}), 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: z.object({}), async execute() { return { content: [{ type: "text", text: "active" }] }; }, }); }; describe("createAgentSession defaultInactive tool activation", () => { const tempDirs: string[] = []; afterEach(() => { for (const tempDir of tempDirs.splice(0)) { fs.rmSync(tempDir, { recursive: true, force: true }); } vi.restoreAllMocks(); }); it("excludes defaultInactive extension tools from the initial active set unless explicitly requested", async () => { const tempDir = path.join(os.tmpdir(), `pi-sdk-tool-activation-${Snowflake.next()}`); tempDirs.push(tempDir); fs.mkdirSync(tempDir, { recursive: true }); const { session } = await createAgentSession({ cwd: tempDir, agentDir: tempDir, sessionManager: SessionManager.inMemory(), settings: Settings.isolated(), model: getBundledModel("openai", "gpt-4o-mini"), disableExtensionDiscovery: true, extensions: [toolActivationExtension], skills: [], contextFiles: [], promptTemplates: [], slashCommands: [], enableMCP: false, enableLsp: false, }); try { expect(session.getAllToolNames()).toEqual( expect.arrayContaining(["default_active_tool", "default_inactive_tool"]), ); expect(session.getActiveToolNames()).toContain("default_active_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("allows explicitly requested defaultInactive extension tools into the initial active set", async () => { const tempDir = path.join(os.tmpdir(), `pi-sdk-tool-activation-${Snowflake.next()}`); tempDirs.push(tempDir); fs.mkdirSync(tempDir, { recursive: true }); const { session } = await createAgentSession({ cwd: tempDir, agentDir: tempDir, sessionManager: SessionManager.inMemory(), settings: Settings.isolated(), model: getBundledModel("openai", "gpt-4o-mini"), disableExtensionDiscovery: true, extensions: [toolActivationExtension], skills: [], contextFiles: [], promptTemplates: [], slashCommands: [], enableMCP: false, enableLsp: false, toolNames: ["read", "default_inactive_tool"], }); try { expect(session.getActiveToolNames()).toEqual( expect.arrayContaining(["read", "default_active_tool", "default_inactive_tool"]), ); 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", "search", "find", "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 = path.join(os.tmpdir(), `pi-sdk-tool-activation-${Snowflake.next()}`); tempDirs.push(tempDir); fs.mkdirSync(tempDir, { recursive: true }); const { session } = await createAgentSession({ cwd: tempDir, agentDir: tempDir, sessionManager: SessionManager.inMemory(), settings: Settings.isolated(), model: getBundledModel("openai", "gpt-4o-mini"), disableExtensionDiscovery: true, skills: [], contextFiles: [], promptTemplates: [], slashCommands: [], enableMCP: false, enableLsp: false, requireYieldTool: true, toolNames: ["read", "search", "find", "web_search"], }); try { expect(session.getActiveToolNames()).toContain("yield"); } finally { await session.dispose(); } }); it("keeps the hidden resolve tool registered for plan mode even when no deferrable tool is requested", async () => { // Regression for #1428: plan mode submits its finalized plan via // `resolve { action: "apply" }` dispatched through a standing handler // (interactive-mode.ts: `setStandingResolveHandler`). With an explicit // read-only `toolNames` (e.g. `read`, `search`, `find`, `web_search`) // the registry has no `deferrable` tool, so the previous gate dropped // `resolve` from the registry and plan mode silently activated without // it — leaving the agent stuck after drafting the plan. const tempDir = path.join(os.tmpdir(), `pi-sdk-tool-activation-${Snowflake.next()}`); tempDirs.push(tempDir); fs.mkdirSync(tempDir, { recursive: true }); const { session } = await createAgentSession({ cwd: tempDir, agentDir: tempDir, sessionManager: SessionManager.inMemory(), settings: Settings.isolated(), model: getBundledModel("openai", "gpt-4o-mini"), disableExtensionDiscovery: true, skills: [], contextFiles: [], promptTemplates: [], slashCommands: [], enableMCP: false, enableLsp: false, toolNames: ["read", "search", "find", "web_search"], }); try { expect(session.getToolByName("resolve")).toBeDefined(); } finally { await session.dispose(); } }); it("drops the hidden resolve tool when neither a deferrable tool nor plan mode can use it", async () => { const tempDir = path.join(os.tmpdir(), `pi-sdk-tool-activation-${Snowflake.next()}`); tempDirs.push(tempDir); fs.mkdirSync(tempDir, { recursive: true }); const settings = Settings.isolated(); settings.set("plan.enabled", false); const { session } = await createAgentSession({ cwd: tempDir, agentDir: tempDir, sessionManager: SessionManager.inMemory(), settings, model: getBundledModel("openai", "gpt-4o-mini"), disableExtensionDiscovery: true, skills: [], contextFiles: [], promptTemplates: [], slashCommands: [], enableMCP: false, enableLsp: false, toolNames: ["read", "search", "find", "web_search"], }); try { expect(session.getToolByName("resolve")).toBeUndefined(); } finally { await session.dispose(); } }); it("does not register the xAI TTS tool unless enabled", async () => { const tempDir = path.join(os.tmpdir(), `pi-sdk-tool-activation-${Snowflake.next()}`); tempDirs.push(tempDir); fs.mkdirSync(tempDir, { recursive: true }); const { session } = await createAgentSession({ cwd: tempDir, agentDir: tempDir, sessionManager: SessionManager.inMemory(), settings: Settings.isolated(), model: getBundledModel("openai", "gpt-4o-mini"), disableExtensionDiscovery: true, skills: [], contextFiles: [], promptTemplates: [], slashCommands: [], enableMCP: false, enableLsp: false, }); 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 = path.join(os.tmpdir(), `pi-sdk-tool-activation-${Snowflake.next()}`); tempDirs.push(tempDir); fs.mkdirSync(tempDir, { recursive: true }); const { session } = await createAgentSession({ cwd: tempDir, agentDir: tempDir, sessionManager: SessionManager.inMemory(), settings: Settings.isolated({ "tts.enabled": true }), model: getBundledModel("openai", "gpt-4o-mini"), disableExtensionDiscovery: true, skills: [], contextFiles: [], promptTemplates: [], slashCommands: [], enableMCP: false, enableLsp: false, }); try { expect(session.getToolByName("tts")).toBeDefined(); expect(session.getActiveToolNames()).toContain("tts"); } finally { await session.dispose(); } }); });