Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions rules/local-agent-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Agent tool definitions live in `src/pro/main/ipc/handlers/local_agent/tools/`. E

- **`modifiesState: true`** must be set on any tool that writes to disk or modifies external state (files, database, etc.). This flag controls whether the tool is available in read-only (ask) mode and plan-only mode — see `buildAgentToolSet` in `tool_definitions.ts`.
- Similarly, code in the `handleLocalAgentStream` handler that writes to the workspace (e.g., `ensureDyadGitignored`, injecting synthetic todo reminders) should be guarded with `if (!readOnly && !planModeOnly)` checks. Injecting instructions that reference state-changing tools into non-writable runs will confuse the model since those tools are filtered out.
- `ensureDyadGitignored` is defined in `src/ipc/handlers/gitignoreUtils.ts` (not `planUtils.ts`). If `npm run ts` reports `TS2305` for this symbol in local-agent handlers, update the import path to `@/ipc/handlers/gitignoreUtils`.
Comment thread
github-actions[bot] marked this conversation as resolved.
Outdated

## Async I/O

Expand Down
4 changes: 2 additions & 2 deletions src/pro/main/ipc/handlers/local_agent/local_agent_handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ import {
type InjectedMessage,
} from "./prepare_step_utils";
import { loadTodos } from "./todo_persistence";
import { ensureDyadGitignored } from "@/ipc/handlers/planUtils";
import { ensureDyadGitignored } from "@/ipc/handlers/gitignoreUtils";
import { TOOL_DEFINITIONS } from "./tool_definitions";
import {
parseAiMessagesJson,
Expand Down Expand Up @@ -429,7 +429,7 @@ export async function handleLocalAgentStream(
// Ensure .dyad/ is gitignored (idempotent; also done by compaction/plans)
// Skip in read-only/plan-only mode to avoid modifying the workspace
if (!readOnly && !planModeOnly) {
await ensureDyadGitignored(appPath).catch((err) =>
await ensureDyadGitignored(appPath).catch((err: unknown) =>
logger.warn("Failed to ensure .dyad gitignored:", err),
);
}
Expand Down
2 changes: 2 additions & 0 deletions src/pro/main/ipc/handlers/local_agent/tool_definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { editFileTool } from "./tools/edit_file";
import { searchReplaceTool } from "./tools/search_replace";
import { webSearchTool } from "./tools/web_search";
import { webCrawlTool } from "./tools/web_crawl";
import { webFetchTool } from "./tools/web_fetch";
import { updateTodosTool } from "./tools/update_todos";
import { runTypeChecksTool } from "./tools/run_type_checks";
import { grepTool } from "./tools/grep";
Expand Down Expand Up @@ -64,6 +65,7 @@ export const TOOL_DEFINITIONS: readonly ToolDefinition[] = [
readLogsTool,
webSearchTool,
webCrawlTool,
webFetchTool,
updateTodosTool,
runTypeChecksTool,
// Plan mode tools
Expand Down
157 changes: 157 additions & 0 deletions src/pro/main/ipc/handlers/local_agent/tools/web_fetch.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { webFetchTool } from "./web_fetch";
import type { AgentContext } from "./types";

vi.mock("electron-log", () => ({
default: {
scope: () => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
},
}));

describe("webFetchTool", () => {
const mockContext: AgentContext = {
event: {} as any,
appId: 1,
appPath: "/test/app",
chatId: 1,
supabaseProjectId: null,
supabaseOrganizationSlug: null,
messageId: 1,
isSharedModulesChanged: false,
isDyadPro: false,
todos: [],
dyadRequestId: "test-request",
fileEditTracker: {},
onXmlStream: vi.fn(),
onXmlComplete: vi.fn(),
requireConsent: vi.fn().mockResolvedValue(true),
appendUserMessage: vi.fn(),
onUpdateTodos: vi.fn(),
};

beforeEach(() => {
vi.restoreAllMocks();
vi.stubGlobal("fetch", vi.fn());
});

it("has the correct name and default consent", () => {
expect(webFetchTool.name).toBe("web_fetch");
expect(webFetchTool.defaultConsent).toBe("ask");
});

it("applies markdown as the default format", () => {
const parsed = webFetchTool.inputSchema.parse({
url: "https://example.com",
});
expect(parsed.format).toBe("markdown");
});

it("rejects non-http(s) URLs", async () => {
await expect(
webFetchTool.execute(
{ url: "file:///tmp/test.txt", format: "text" },
mockContext,
),
).rejects.toThrow("URL must start with http:// or https://");
});

it("returns text extracted from html for text format", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(
"<html><body><h1>Title</h1><p>Hello <strong>world</strong>.</p></body></html>",
{
status: 200,
headers: {
"content-type": "text/html; charset=utf-8",
},
},
),
);

const result = await webFetchTool.execute(
{ url: "https://example.com", format: "text" },
mockContext,
);

expect(result).toContain("Title");
expect(result).toContain("Hello world.");
expect(result).not.toContain("<h1>");
});

it("returns html unchanged for html format", async () => {
const html = "<html><body><h1>Title</h1></body></html>";
vi.mocked(fetch).mockResolvedValue(
new Response(html, {
status: 200,
headers: {
"content-type": "text/html",
},
}),
);

const result = await webFetchTool.execute(
{ url: "https://example.com", format: "html" },
mockContext,
);

expect(result).toBe(html);
});

it("rejects responses above the 5MB content-length limit", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response("small", {
status: 200,
headers: {
"content-type": "text/plain",
"content-length": String(6 * 1024 * 1024),
},
}),
);

await expect(
webFetchTool.execute(
{ url: "https://example.com", format: "text" },
mockContext,
),
).rejects.toThrow("Response too large (exceeds 5MB limit)");
});

it("returns a binary content summary for images", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(new Uint8Array([1, 2, 3, 4]), {
status: 200,
headers: {
"content-type": "image/png",
},
}),
);

const result = await webFetchTool.execute(
{ url: "https://example.com/image.png", format: "markdown" },
mockContext,
);

expect(result).toContain("Fetched binary image content");
expect(result).toContain("image/png");
});

it("throws on non-2xx responses", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response("Not found", {
status: 404,
}),
);

await expect(
webFetchTool.execute(
Comment thread
github-actions[bot] marked this conversation as resolved.
{ url: "https://example.com/missing", format: "text" },
mockContext,
),
).rejects.toThrow("Request failed with status code: 404");
});
});
Comment thread
github-actions[bot] marked this conversation as resolved.
Loading
Loading