Files
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

127 lines
5.5 KiB
TypeScript

import { beforeAll, describe, expect, it } from "bun:test";
import {
GALLERY_STATES,
parseGalleryStates,
renderGalleryState,
resolveFixture,
} from "@oh-my-pi/pi-coding-agent/cli/gallery-cli";
import type { GalleryFixture } from "@oh-my-pi/pi-coding-agent/cli/gallery-fixtures";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { initTheme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { toolRenderers } from "@oh-my-pi/pi-coding-agent/tools/renderers";
beforeAll(async () => {
resetSettingsForTest();
await Settings.init({ inMemory: true });
await initTheme(false, undefined, undefined, "dark", "light");
});
describe("gallery harness", () => {
it("accepts displayed gallery state labels and legacy tokens", () => {
expect(parseGalleryStates(["streaming args", "in progress", "done", "failed"])).toEqual([
"streaming",
"progress",
"success",
"error",
]);
expect(parseGalleryStates(["streaming", "progress", "success", "error", "failed"])).toEqual([...GALLERY_STATES]);
});
it("rejects unknown gallery state tokens before rendering", () => {
expect(() => parseGalleryStates(["bogus"])).toThrow(
/Invalid --state 'bogus'.*streaming args.*in progress.*done.*failed/,
);
});
it("renders every registered tool in every lifecycle state without throwing", async () => {
for (const name in toolRenderers) {
const fixture = resolveFixture(name);
for (const state of GALLERY_STATES) {
const lines = await renderGalleryState(name, fixture, state, 100);
// A renderer that produces no lines for a state is a regression: the
// component should always emit at least the call header or result.
expect(lines.length, `${name}/${state} rendered nothing`).toBeGreaterThan(0);
}
}
});
it("routes each state to the matching args/result (streaming args vs result, success vs error)", async () => {
const fixture: GalleryFixture = {
label: "Bash",
streamingArgs: { command: "echo STREAM_MARK" },
args: { command: "echo PROGRESS_MARK" },
result: { content: [{ type: "text", text: "SUCCESS_OUT" }], details: { exitCode: 0 } },
errorResult: { content: [{ type: "text", text: "ERROR_OUT" }], isError: true, details: { exitCode: 1 } },
};
const render = async (state: (typeof GALLERY_STATES)[number]) =>
Bun.stripANSI((await renderGalleryState("bash", fixture, state, 100)).join("\n"));
const streaming = await render("streaming");
expect(streaming).toContain("STREAM_MARK");
expect(streaming).not.toContain("PROGRESS_MARK");
expect(streaming).not.toContain("SUCCESS_OUT");
const progress = await render("progress");
expect(progress).toContain("PROGRESS_MARK");
expect(progress).not.toContain("SUCCESS_OUT");
const success = await render("success");
expect(success).toContain("SUCCESS_OUT");
expect(success).not.toContain("ERROR_OUT");
const error = await render("error");
expect(error).toContain("ERROR_OUT");
expect(error).not.toContain("SUCCESS_OUT");
});
it("routes customRendered tools (task) through the custom-tool branch", async () => {
// `task` attaches its renderer on the real AgentTool, so the gallery must
// reproduce that path. With a result present and mergeCallAndResult, the
// custom branch must NOT emit a redundant tool-name line above the result box
// (regression guard for tool-execution's custom-branch fallback label).
const task = resolveFixture("task");
expect(task.customRendered).toBe(true);
const lines = await renderGalleryState("task", task, "error", 100);
const stripped = lines.map(line => Bun.stripANSI(line).trim());
// The framed result header carries the label inside the box border...
expect(stripped.some(line => line.startsWith(theme.boxRound.topLeft) && line.includes("Task"))).toBe(true);
// ...but no standalone "Task" label line precedes it.
expect(stripped).not.toContain("Task");
});
it("renders curated failed states as failures", async () => {
const cases = [
["hub_inbox", "IRC inbox failed: message store unavailable.", "IRC inbox empty"],
["hub_list", "IRC list failed: agent hub is unavailable.", "no other agents"],
["hub_jobs", "Subagent exited 1: Redis connection string is missing.", "cancelled"],
] as const;
for (const [name, expected, forbidden] of cases) {
const output = Bun.stripANSI((await renderGalleryState(name, resolveFixture(name), "error", 100)).join("\n"));
expect(output).toContain(expected);
expect(output).not.toContain(forbidden);
}
});
it("renders gallery-only read group fixtures", async () => {
const fixture = resolveFixture("read_group");
const success = Bun.stripANSI((await renderGalleryState("read_group", fixture, "success", 140)).join("\n"));
const renderPathMatches = success.match(/packages\/coding-agent\/src\/task\/render\.ts/g) ?? [];
expect(success).toContain("Read (4)");
expect(renderPathMatches).toHaveLength(1);
expect(success).toContain("packages/coding-agent/src/task/render.ts:507-605,1070-1194,…,1270-1274");
expect(success).not.toContain("1210-1240");
expect(success).not.toContain("full file");
});
it("falls back to a generic fixture for registry tools without curated sample data", () => {
// resolveFixture never returns undefined for a registry tool, even one
// missing from the curated fixtures, so the gallery cannot crash on a newly
// added renderer.
const fixture = resolveFixture("a-tool-that-has-no-fixture");
expect(fixture.args).toBeDefined();
expect(fixture.result.content.length).toBeGreaterThan(0);
});
});