Skip to content

Commit 939c5db

Browse files
committed
Add four more agent tools: HTTP requests, screenshots, symbol search, patches
- http_request: arbitrary REST calls (any method/headers/body), distinct from fetch_url's read-only page scraping. Requires approval, like write_file, since it can have side effects on external systems. - capture_page_screenshot: renders a URL in a hidden offscreen BrowserWindow and saves the PNG into the workspace under .agent-screenshots/ rather than returning raw image bytes as the tool result, which would otherwise bloat every later request in the conversation with the same image data. - find_symbol_references: word-boundary search across the workspace, so "count" doesn't also match "recount"/"counter" the way search_files' substring match would — for navigating code before editing it. - apply_patch: applies a unified diff (git diff / diff -u format) across one or more files in a single call instead of one replace_in_file per file, including file creation/deletion via /dev/null. No fuzzy matching — a hunk whose context doesn't match current file content throws rather than guessing. Fixed a real parsing bug along the way: a trailing newline in patch text was being parsed as a phantom empty context line, breaking any patch whose last hunk ended in an added/removed line.
1 parent 3021919 commit 939c5db

4 files changed

Lines changed: 477 additions & 0 deletions

File tree

app/src/agent-tools.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ import {
3131
stopBackgroundCommand,
3232
listBackgroundCommands,
3333
killAllBackgroundCommands,
34+
httpRequest,
35+
findSymbolReferences,
36+
applyPatch,
3437
} from "./agent-tools";
3538

3639
function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise<void> {
@@ -271,6 +274,113 @@ describe("agent-tools", () => {
271274
});
272275
});
273276

277+
describe("httpRequest", () => {
278+
it("rejects a malformed URL", async () => {
279+
await expect(httpRequest("not a url")).rejects.toThrow(/not a valid URL/);
280+
});
281+
282+
it("rejects a non-http(s) protocol", async () => {
283+
await expect(httpRequest("ftp://example.com")).rejects.toThrow(/http:\/\/ and https:\/\//);
284+
});
285+
});
286+
287+
describe("findSymbolReferences", () => {
288+
it("matches whole identifiers only, not substrings", () => {
289+
fs.writeFileSync(path.join(workspace, "code.ts"), "const count = 1;\nconst recount = 2;\nfunction counter() {}\n");
290+
const results = findSymbolReferences(workspace, "count");
291+
expect(results).toEqual([{ file: "code.ts", line: 1, text: "const count = 1;" }]);
292+
});
293+
294+
it("finds every reference across multiple lines and files", () => {
295+
fs.writeFileSync(path.join(workspace, "a.ts"), "function greet() {}\ngreet();\n");
296+
fs.mkdirSync(path.join(workspace, "sub"));
297+
fs.writeFileSync(path.join(workspace, "sub", "b.ts"), "import { greet } from '../a';\ngreet();\n");
298+
const results = findSymbolReferences(workspace, "greet");
299+
expect(results.length).toBe(4);
300+
});
301+
302+
it("returns nothing for a symbol that isn't used anywhere", () => {
303+
fs.writeFileSync(path.join(workspace, "code.ts"), "const x = 1;\n");
304+
expect(findSymbolReferences(workspace, "doesNotExist")).toEqual([]);
305+
});
306+
});
307+
308+
describe("applyPatch", () => {
309+
it("applies a single-hunk edit to an existing file", () => {
310+
fs.writeFileSync(path.join(workspace, "greet.txt"), "line one\nline two\nline three\n");
311+
const patch = [
312+
"--- a/greet.txt",
313+
"+++ b/greet.txt",
314+
"@@ -1,3 +1,3 @@",
315+
" line one",
316+
"-line two",
317+
"+line TWO",
318+
" line three",
319+
"",
320+
].join("\n");
321+
const result = applyPatch(workspace, patch);
322+
expect(result.filesChanged).toEqual(["greet.txt"]);
323+
expect(fs.readFileSync(path.join(workspace, "greet.txt"), "utf-8")).toBe("line one\nline TWO\nline three\n");
324+
});
325+
326+
it("creates a new file from a /dev/null patch", () => {
327+
const patch = [
328+
"--- /dev/null",
329+
"+++ b/new.txt",
330+
"@@ -0,0 +1,2 @@",
331+
"+hello",
332+
"+world",
333+
"",
334+
].join("\n");
335+
applyPatch(workspace, patch);
336+
expect(fs.readFileSync(path.join(workspace, "new.txt"), "utf-8")).toBe("hello\nworld");
337+
});
338+
339+
it("deletes a file when the new side is /dev/null", () => {
340+
fs.writeFileSync(path.join(workspace, "gone.txt"), "bye\n");
341+
const patch = ["--- a/gone.txt", "+++ /dev/null", "@@ -1,1 +0,0 @@", "-bye", ""].join("\n");
342+
applyPatch(workspace, patch);
343+
expect(fs.existsSync(path.join(workspace, "gone.txt"))).toBe(false);
344+
});
345+
346+
it("applies edits across multiple files in one patch", () => {
347+
fs.writeFileSync(path.join(workspace, "a.txt"), "alpha\n");
348+
fs.writeFileSync(path.join(workspace, "b.txt"), "beta\n");
349+
const patch = [
350+
"--- a/a.txt",
351+
"+++ b/a.txt",
352+
"@@ -1,1 +1,1 @@",
353+
"-alpha",
354+
"+ALPHA",
355+
"--- a/b.txt",
356+
"+++ b/b.txt",
357+
"@@ -1,1 +1,1 @@",
358+
"-beta",
359+
"+BETA",
360+
"",
361+
].join("\n");
362+
const result = applyPatch(workspace, patch);
363+
expect(result.filesChanged.sort()).toEqual(["a.txt", "b.txt"]);
364+
expect(fs.readFileSync(path.join(workspace, "a.txt"), "utf-8")).toBe("ALPHA\n");
365+
expect(fs.readFileSync(path.join(workspace, "b.txt"), "utf-8")).toBe("BETA\n");
366+
});
367+
368+
it("throws when the hunk's context doesn't match the file's actual content", () => {
369+
fs.writeFileSync(path.join(workspace, "drifted.txt"), "actual content\n");
370+
const patch = ["--- a/drifted.txt", "+++ b/drifted.txt", "@@ -1,1 +1,1 @@", "-expected content", "+new content", ""].join("\n");
371+
expect(() => applyPatch(workspace, patch)).toThrow(/Context mismatch/);
372+
});
373+
374+
it("throws on a patch with no valid file headers", () => {
375+
expect(() => applyPatch(workspace, "not a real patch")).toThrow(/No valid file patches/);
376+
});
377+
378+
it("respects the workspace sandbox for patched file paths", () => {
379+
const patch = ["--- /dev/null", "+++ b/../../etc/evil.txt", "@@ -0,0 +1,1 @@", "+pwned", ""].join("\n");
380+
expect(() => applyPatch(workspace, patch)).toThrow(/outside the workspace/);
381+
});
382+
});
383+
274384
describe("executeTool", () => {
275385
it("dispatches to the right tool by name", async () => {
276386
writeFile(workspace, "y.txt", "z");

0 commit comments

Comments
 (0)