Files
oh-my-pi/packages/coding-agent/test/sdk-tool-activation.test.ts
T
can1357 5ff277349c refactor(coding-agent): consolidated tool surface onto xd:// devices and hub
- Added the `xd://` virtual device protocol (`internal-urls/xd-protocol.ts`, `tools/xdev.ts`): tools declaring `loadMode: "discoverable"` are unmounted from the request tools array and driven via `read xd://` (list/docs+schema) and `write xd://<tool>` (execute), gated by the `tools.xdev` setting (default on) and inlined into the system prompt.
- Merged the `irc`, `job`, and `launch` tools into a single `hub` tool (`tools/hub/`, `async/job-manager.ts`): messaging keeps `send`/`inbox`/`list`, job control maps to `wait`/`cancel`/`jobs`, process supervision keeps `start`/`logs`/`stop`/`restart`/`describe` with `ps`, and the unified `wait` races background jobs against peer messages; SDK `IrcTool`/`JobTool`/`LaunchTool` are replaced by `HubTool`.
- Removed the hidden `resolve` tool in favor of the `xd://resolve`/`xd://reject`/`xd://propose` resolution devices, auto-including `write` whenever a deferrable tool or plan mode is present.
- Removed the BM25 tool-discovery system: the `search_tool_bm25` tool, the `tool-discovery` module, the `tools.discoveryMode`/`mcp.discoveryMode`/`mcp.discoveryDefaultServers`/`tools.essentialOverride` settings, per-tool MCP selection, and the `mcp_tool_selection` message type.
- Unified tool presentation on `ToolLoadMode` (`essential`|`discoverable`), replacing the custom-tool `xdev?: boolean` opt-out; custom, extension, MCP, RPC host, image-generation, and TTS tools now default to `discoverable`, and added a `satisfies` predicate to `SoftToolRequirement`.
- Removed the standalone `ssh` command tool and `ssh/ssh-executor` (the `ssh://` read/write/search protocol stays), and made `--tools` address hidden built-ins.
- Updated collab-web to render `xd://` dispatches and `hub` op families, dropped the `search_tool_bm25`/`ssh`/`report-finding` renderers, refreshed tool docs and prompts, and migrated the affected tests and changelogs.
2026-07-15 15:16:29 +02:00

288 lines
9.8 KiB
TypeScript

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 { 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 CreateAgentSessionOptions,
createAgentSession,
discoverAuthStorage,
type ExtensionFactory,
} from "@oh-my-pi/pi-coding-agent/sdk";
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 { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils";
import { type } from "arktype";
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" }] };
},
});
};
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();
});
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("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_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", "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("registers vibe tools only during explicit vibe activation", 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"]);
for (const name of VIBE_TOOL_NAMES) {
expect(session.getToolByName(name)).toBeDefined();
expect(session.getActiveToolNames()).toContain(name);
}
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("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();
}
});
});