5ff277349c
- 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.
205 lines
7.1 KiB
TypeScript
205 lines
7.1 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
|
import * as scrapers from "@oh-my-pi/pi-coding-agent/web/scrapers/types";
|
|
import { removeWithRetries } from "@oh-my-pi/pi-utils";
|
|
|
|
function createSession(testDir: string): ToolSession {
|
|
const sessionFile = path.join(testDir, "session.jsonl");
|
|
const artifactsDir = sessionFile.slice(0, -6);
|
|
let nextArtifactId = 0;
|
|
return {
|
|
cwd: testDir,
|
|
hasUI: false,
|
|
getSessionFile: () => sessionFile,
|
|
getArtifactsDir: () => artifactsDir,
|
|
getSessionSpawns: () => "*",
|
|
allocateOutputArtifact: async toolType => {
|
|
const id = String(nextArtifactId++);
|
|
return { id, path: path.join(artifactsDir, `${id}.${toolType}.log`) };
|
|
},
|
|
settings: Settings.isolated({
|
|
"fetch.enabled": true,
|
|
"grep.contextBefore": 0,
|
|
"grep.contextAfter": 0,
|
|
"astGrep.enabled": true,
|
|
"astEdit.enabled": true,
|
|
"tools.xdev": false,
|
|
}),
|
|
};
|
|
}
|
|
|
|
function resultText(result: { content: Array<{ type: string; text?: string }> }): string {
|
|
return result.content
|
|
.filter(entry => entry.type === "text")
|
|
.map(entry => entry.text ?? "")
|
|
.join("\n");
|
|
}
|
|
|
|
function stubLoadPage(body: string, contentType: string) {
|
|
return vi.spyOn(scrapers, "loadPage").mockImplementation(async requestedUrl => ({
|
|
ok: true,
|
|
status: 200,
|
|
finalUrl: requestedUrl,
|
|
contentType,
|
|
content: body,
|
|
}));
|
|
}
|
|
|
|
describe("search tools with external URL paths", () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(async () => {
|
|
testDir = await fs.mkdtemp(path.join(os.tmpdir(), "search-url-paths-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
await removeWithRetries(testDir);
|
|
});
|
|
|
|
it("search fetches a URL through the read cache and greps the rendered text", async () => {
|
|
stubLoadPage("alpha\nremote needle\nomega\n", "text/plain");
|
|
const tools = await createTools(createSession(testDir));
|
|
const tool = tools.find(entry => entry.name === "grep");
|
|
expect(tool).toBeDefined();
|
|
|
|
const result = await tool!.execute("search-url", {
|
|
pattern: "remote needle",
|
|
path: "https://example.com/notes.txt",
|
|
});
|
|
|
|
const text = resultText(result);
|
|
expect(text).toContain("remote needle");
|
|
expect(text).not.toContain("Cannot search external URL");
|
|
});
|
|
|
|
it("search applies URL line-range selectors after materialization", async () => {
|
|
stubLoadPage("outside before\nremote needle\noutside after\n", "text/plain");
|
|
const tools = await createTools(createSession(testDir));
|
|
const tool = tools.find(entry => entry.name === "grep");
|
|
expect(tool).toBeDefined();
|
|
|
|
const result = await tool!.execute("search-url-range", {
|
|
pattern: "outside|remote needle",
|
|
path: "https://example.com/notes.txt:2-2",
|
|
});
|
|
|
|
const text = resultText(result);
|
|
expect(text).toContain("remote needle");
|
|
expect(text).not.toContain("outside before");
|
|
expect(text).not.toContain("outside after");
|
|
});
|
|
|
|
it("ast_edit rejects external URLs instead of staging read-cache files", async () => {
|
|
stubLoadPage("legacyWrap(x, value)\n", "text/plain");
|
|
const tools = await createTools(createSession(testDir));
|
|
const tool = tools.find(entry => entry.name === "ast_edit");
|
|
expect(tool).toBeDefined();
|
|
|
|
await expect(
|
|
tool!.execute("ast-edit-url", {
|
|
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
|
|
paths: ["https://example.com/snippet.ts"],
|
|
}),
|
|
).rejects.toThrow("Cannot rewrite external URL");
|
|
});
|
|
|
|
it("ast_grep materializes URL content with the source extension", async () => {
|
|
stubLoadPage("export function remoteNeedle() {\n\treturn 1;\n}\n", "text/plain");
|
|
const tools = await createTools(createSession(testDir));
|
|
const tool = tools.find(entry => entry.name === "ast_grep");
|
|
expect(tool).toBeDefined();
|
|
|
|
const result = await tool!.execute("ast-grep-url", {
|
|
pat: "remoteNeedle",
|
|
path: "https://example.com/snippet.ts",
|
|
});
|
|
|
|
const text = resultText(result);
|
|
expect(text).toContain("remoteNeedle");
|
|
expect(text).not.toContain("Parse issues");
|
|
});
|
|
|
|
it("search materializes a scheme-less www. scope like its canonical spelling", async () => {
|
|
const loadPage = stubLoadPage("alpha\nremote needle\nomega\n", "text/plain");
|
|
const tools = await createTools(createSession(testDir));
|
|
const tool = tools.find(entry => entry.name === "grep");
|
|
expect(tool).toBeDefined();
|
|
|
|
const result = await tool!.execute("search-url-www", {
|
|
pattern: "remote needle",
|
|
path: "www.example.com/notes.txt",
|
|
});
|
|
|
|
expect(resultText(result)).toContain("remote needle");
|
|
expect(loadPage).toHaveBeenCalledWith("https://www.example.com/notes.txt", expect.anything());
|
|
});
|
|
|
|
it("search repairs a collapsed https:/ scheme before materializing", async () => {
|
|
const loadPage = stubLoadPage("alpha\nremote needle\nomega\n", "text/plain");
|
|
const tools = await createTools(createSession(testDir));
|
|
const tool = tools.find(entry => entry.name === "grep");
|
|
expect(tool).toBeDefined();
|
|
|
|
const result = await tool!.execute("search-url-collapsed", {
|
|
pattern: "remote needle",
|
|
path: "https:/example.com/notes.txt",
|
|
});
|
|
|
|
expect(resultText(result)).toContain("remote needle");
|
|
expect(loadPage).toHaveBeenCalledWith("https://example.com/notes.txt", expect.anything());
|
|
});
|
|
|
|
it("search prefers an existing local directory named like a www. host", async () => {
|
|
const loadPage = stubLoadPage("remote body\n", "text/plain");
|
|
await fs.mkdir(path.join(testDir, "www.example.com"), { recursive: true });
|
|
await fs.writeFile(path.join(testDir, "www.example.com", "notes.txt"), "local needle\n");
|
|
const tools = await createTools(createSession(testDir));
|
|
const tool = tools.find(entry => entry.name === "grep");
|
|
expect(tool).toBeDefined();
|
|
|
|
const result = await tool!.execute("search-local-dir", {
|
|
pattern: "local needle",
|
|
path: "www.example.com",
|
|
});
|
|
|
|
expect(resultText(result)).toContain("local needle");
|
|
expect(loadPage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("search leaves plain relative paths untouched by URL materialization", async () => {
|
|
const loadPage = stubLoadPage("remote body\n", "text/plain");
|
|
await fs.mkdir(path.join(testDir, "src"), { recursive: true });
|
|
await fs.writeFile(path.join(testDir, "src", "notes.txt"), "local needle\n");
|
|
const tools = await createTools(createSession(testDir));
|
|
const tool = tools.find(entry => entry.name === "grep");
|
|
expect(tool).toBeDefined();
|
|
|
|
const result = await tool!.execute("search-local-rel", {
|
|
pattern: "local needle",
|
|
path: "src/notes.txt",
|
|
});
|
|
|
|
expect(resultText(result)).toContain("local needle");
|
|
expect(loadPage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("search rejects unsupported URL schemes explicitly", async () => {
|
|
stubLoadPage("remote body\n", "text/plain");
|
|
const tools = await createTools(createSession(testDir));
|
|
const tool = tools.find(entry => entry.name === "grep");
|
|
expect(tool).toBeDefined();
|
|
|
|
await expect(
|
|
tool!.execute("search-url-ftp", {
|
|
pattern: "needle",
|
|
path: "ftp://example.com/notes.txt",
|
|
}),
|
|
).rejects.toThrow("Cannot search external URL");
|
|
});
|
|
});
|