Files
oh-my-pi/packages/coding-agent/test/tool-discovery/initial-tools.test.ts
T
roboomp a98dc285a3 fix(tests): isolated CI-environment assumptions across the coding-agent suite
The CI gate failed in several non-overlapping ways once the full coding-agent suite ran here: tests wrote into the real $HOME (`/srv/agent-home`) which is read-only, a fixture rotated stored Anthropic API keys but Settings reloaded the user models.yml and shadowed them, an OAuth callback server bound to `hostname:"localhost"` (loopback unreachable in this runtime), a built-in tool metadata assertion only saw `github` when `gh` was installed, and the GithubTool `pr_checkout` worktree assertions assumed `~/.omp/wt` but `XDG_DATA_HOME` redirected `getWorktreesDir()` to `$XDG_DATA_HOME/omp/wt`.

Fixes:

- `packages/ai/src/registry/oauth/callback-server.ts`: drop `hostname: "localhost"` when no caller-supplied hostname overrides it. Bun on Linux refused inbound connections to the listener when bound explicitly to `localhost`; defaulting to Bun.serves default binding restores loopback connectivity.

- `packages/coding-agent/test/status-line-path.test.ts`: route the `~/Projects` fixtures through a writable temp home (spy `os.homedir()`), housed under the repo `.wt/` worktree scratch so the temp home is not classified as a status-line scratch root.

- `packages/coding-agent/test/skills.test.ts`: ditto for the `~/.pi-skills-test-*` mkdtemp in the tilde-expansion test.

- `packages/coding-agent/test/marketplace/project-scope.test.ts`: stop writing `~/.git`; build the entire home-dir guard fixture in a temp dir and spy `os.homedir()`.

- `packages/coding-agent/test/oauth-flow.test.ts`: wrap each callback fetch in a brief retry so the simulated browser redirect tolerates the few-ms gap before the Bun callback server starts accepting connections.

- `packages/coding-agent/test/tools/gh.test.ts`: extend `setupTempHome()` to clear `XDG_DATA_HOME`/`XDG_STATE_HOME`/`XDG_CACHE_HOME` for the duration of the test so the rebuilt dirs resolver routes `getWorktreesDir()` back through the spied home, then restore them on cleanup.

- `packages/coding-agent/test/tool-discovery/initial-tools.test.ts`: instantiate `GithubTool` directly in the metadata fixture so the assertion runs even when `gh` is unavailable (GithubTool.createIf returns null without `gh`).

- `packages/coding-agent/test/agent-session-retry-cap.test.ts`: pass an isolated `models.yml` path to `ModelRegistry` so the two-Anthropic-key fixture is the authoritative credential source instead of any user-level command-backed Anthropic key.

Verification:

- `bun check` → passed

- `bun run test` (full coding-agent suite, 4 buckets, all chunks) → 0 fails

Fixes #3639
2026-06-27 11:15:44 +02:00

188 lines
6.6 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { BuiltinToolLoadMode, ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import {
AskTool,
BUILTIN_TOOLS,
computeEssentialBuiltinNames,
createTools,
DEFAULT_ESSENTIAL_TOOL_NAMES,
filterInitialToolsForDiscoveryAll,
GithubTool,
IrcTool,
JobTool,
SshTool,
} from "@oh-my-pi/pi-coding-agent/tools";
const allToolsSettings = Settings.isolated({
"astGrep.enabled": true,
"astEdit.enabled": true,
"debug.enabled": true,
"glob.enabled": true,
"grep.enabled": true,
"github.enabled": true,
"lsp.enabled": true,
"inspect_image.enabled": true,
"web_search.enabled": true,
"browser.enabled": true,
"checkpoint.enabled": true,
"todo.enabled": true,
"memory.backend": "mnemopi",
"autolearn.enabled": true,
"tools.discoveryMode": "all",
});
const toolSession: ToolSession = {
cwd: "/tmp/test",
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => null,
settings: allToolsSettings,
isToolDiscoveryEnabled: () => true,
getSelectedDiscoveredToolNames: () => [],
activateDiscoveredTools: async names => names,
};
async function getToolMetadata(): Promise<Map<string, { loadMode?: string; summary?: string }>> {
const tools = await createTools(toolSession, Object.keys(BUILTIN_TOOLS));
const metadata = new Map(tools.map(tool => [tool.name, { loadMode: tool.loadMode, summary: tool.summary }]));
for (const tool of [
new AskTool({ ...toolSession, hasUI: true }),
new GithubTool(toolSession),
new SshTool(toolSession, [], new Map(), ""),
new JobTool(toolSession),
new IrcTool(toolSession),
]) {
metadata.set(tool.name, { loadMode: tool.loadMode, summary: tool.summary });
}
return metadata;
}
describe("BUILTIN_TOOLS public factory map", () => {
it("sets loading fields on tool definitions without wrapping factories", async () => {
const metadata = await getToolMetadata();
const missing = Object.keys(BUILTIN_TOOLS).filter(name => metadata.get(name)?.loadMode === undefined);
expect(missing).toEqual([]);
});
});
describe("built-in tool loadMode annotations", () => {
it("provides a summary for every discoverable tool", async () => {
const missing: string[] = [];
const metadata = await getToolMetadata();
for (const [name, meta] of metadata) {
if (meta.loadMode === "discoverable" && !meta.summary) {
missing.push(name);
}
}
expect(missing).toEqual([]);
});
it("marks eval essential so it survives tools.discoveryMode 'all'", async () => {
const metadata = await getToolMetadata();
expect(metadata.get("eval")?.loadMode).toBe("essential");
// Essential loadMode keeps eval active under discovery-all even when it is
// absent from the essential-names set — not relying on the names list.
const kept = filterInitialToolsForDiscoveryAll(["eval"], {
loadModeOf: name => metadata.get(name)?.loadMode as BuiltinToolLoadMode | undefined,
essentialNames: new Set<string>(),
explicitlyRequested: new Set<string>(),
restored: new Set<string>(),
forceActive: new Set<string>(),
});
expect(kept).toEqual(["eval"]);
});
});
describe("computeEssentialBuiltinNames", () => {
it("returns DEFAULT_ESSENTIAL_TOOL_NAMES when override is empty", () => {
const settings = Settings.isolated({});
expect(computeEssentialBuiltinNames(settings).sort()).toEqual([...DEFAULT_ESSENTIAL_TOOL_NAMES].sort());
});
it("respects tools.essentialOverride when provided", () => {
const settings = Settings.isolated({ "tools.essentialOverride": ["read", "glob"] });
expect(computeEssentialBuiltinNames(settings).sort()).toEqual(["glob", "read"]);
});
it("maps legacy essential override tool names", () => {
const settings = Settings.isolated({ "tools.essentialOverride": ["read", "find", "search", "glob"] });
expect(computeEssentialBuiltinNames(settings).sort()).toEqual(["glob", "grep", "read"]);
});
it("filters override entries that are not known built-in tools", () => {
const settings = Settings.isolated({
"tools.essentialOverride": ["read", "not_a_real_tool", "edit"],
});
expect(computeEssentialBuiltinNames(settings).sort()).toEqual(["edit", "read"]);
});
it("trims whitespace and drops empty entries from the override", () => {
const settings = Settings.isolated({
"tools.essentialOverride": [" read ", "", " "],
});
expect(computeEssentialBuiltinNames(settings)).toEqual(["read"]);
});
it("falls back to defaults when override is non-empty but contains only invalid names", () => {
// The filtered list is empty (no valid names), but the override was provided —
// current behavior returns the empty filtered list (caller can decide). Document the behavior.
const settings = Settings.isolated({
"tools.essentialOverride": ["not_a_real_tool"],
});
expect(computeEssentialBuiltinNames(settings)).toEqual([]);
});
});
describe("tools.discoveryMode settings schema", () => {
it("defaults to auto discovery mode", () => {
const settings = Settings.isolated({});
expect(settings.get("tools.discoveryMode")).toBe("auto");
});
it("back-compat: mcp.discoveryMode still accepted", () => {
const settings = Settings.isolated({ "mcp.discoveryMode": true });
expect(settings.get("mcp.discoveryMode")).toBe(true);
});
});
describe("filterInitialToolsForDiscoveryAll", () => {
const loadModes: Record<string, BuiltinToolLoadMode> = {
read: "essential",
edit: "essential",
todo: "discoverable",
grep: "discoverable",
};
const base = {
loadModeOf: (name: string): BuiltinToolLoadMode | undefined => loadModes[name],
essentialNames: new Set(["read", "bash", "edit", "write", "glob"]),
explicitlyRequested: new Set<string>(),
restored: new Set<string>(),
forceActive: new Set<string>(),
};
it("hides non-essential discoverable built-ins", () => {
expect(filterInitialToolsForDiscoveryAll(["read", "edit", "todo", "grep"], base)).toEqual(["read", "edit"]);
});
it("keeps discoverable tools required by a forced tool_choice (eager todo)", () => {
const result = filterInitialToolsForDiscoveryAll(["read", "todo", "grep"], {
...base,
forceActive: new Set(["todo"]),
});
expect(result).toEqual(["read", "todo"]);
});
it("keeps explicitly requested and restored discoverable tools", () => {
const result = filterInitialToolsForDiscoveryAll(["todo", "grep"], {
...base,
explicitlyRequested: new Set(["grep"]),
restored: new Set(["todo"]),
});
expect([...result].sort()).toEqual(["grep", "todo"]);
});
it("never hides tools without a built-in loadMode (MCP/custom/extension)", () => {
expect(filterInitialToolsForDiscoveryAll(["mcp__server__tool", "grep"], base)).toEqual(["mcp__server__tool"]);
});
});