Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
119 changes: 110 additions & 9 deletions bun.lock

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ gh pr view <number> --json mergeable,mergeStateStatus | jq '.'

## Testing Doctrine

Two types of tests are preferred:

1. **True integration tests** — use real runtimes, real filesystems, real network calls. No mocks, stubs, or fakes. These prove the system works end-to-end.
2. **Unit tests on pure/isolated logic** — test pure functions or well-isolated modules where inputs and outputs are clear. No mocks needed because the code has no external dependencies.

Avoid mock-heavy tests that verify implementation details rather than behavior. If you need mocks to test something, consider whether the code should be restructured to be more testable.

### Storybook

- Prefer full-app stories (`App.stories.tsx`) to isolated components.
Expand Down
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ module.exports = {
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
"^chalk$": "<rootDir>/tests/__mocks__/chalk.js",
"^jsdom$": "<rootDir>/tests/__mocks__/jsdom.js",
},
transform: {
"^.+\\.tsx?$": [
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"@ai-sdk/openai": "^2.0.72",
"@ai-sdk/xai": "^2.0.36",
"@lydell/node-pty": "1.1.0",
"@mozilla/readability": "^0.6.0",
"@openrouter/ai-sdk-provider": "^1.2.5",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
Expand All @@ -72,6 +73,7 @@
"electron-updater": "^6.6.2",
"express": "^5.1.0",
"ghostty-web": "0.2.1",
"jsdom": "^27.2.0",
"jsonc-parser": "^3.3.1",
"lru-cache": "^11.2.2",
"lucide-react": "^0.553.0",
Expand All @@ -83,6 +85,7 @@
"shescape": "^2.1.6",
"source-map-support": "^0.5.21",
"streamdown": "^1.4.0",
"turndown": "^7.2.2",
"undici": "^7.16.0",
"write-file-atomic": "^6.0.0",
"ws": "^8.18.3",
Expand All @@ -106,11 +109,13 @@
"@types/escape-html": "^1.0.4",
"@types/express": "^5.0.3",
"@types/jest": "^30.0.0",
"@types/jsdom": "^27.0.0",
"@types/katex": "^0.16.7",
"@types/markdown-it": "^14.1.2",
"@types/minimist": "^1.2.5",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/turndown": "^5.0.6",
"@types/write-file-atomic": "^4.0.3",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.44.1",
Expand Down
20 changes: 20 additions & 0 deletions src/browser/components/Messages/ToolMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { FileReadToolCall } from "../tools/FileReadToolCall";
import { ProposePlanToolCall } from "../tools/ProposePlanToolCall";
import { TodoToolCall } from "../tools/TodoToolCall";
import { StatusSetToolCall } from "../tools/StatusSetToolCall";
import { WebFetchToolCall } from "../tools/WebFetchToolCall";
import type {
BashToolArgs,
BashToolResult,
Expand All @@ -25,6 +26,8 @@ import type {
TodoWriteToolResult,
StatusSetToolArgs,
StatusSetToolResult,
WebFetchToolArgs,
WebFetchToolResult,
} from "@/common/types/tools";

interface ToolMessageProps {
Expand Down Expand Up @@ -81,6 +84,11 @@ function isStatusSetTool(toolName: string, args: unknown): args is StatusSetTool
return TOOL_DEFINITIONS.status_set.schema.safeParse(args).success;
}

function isWebFetchTool(toolName: string, args: unknown): args is WebFetchToolArgs {
if (toolName !== "web_fetch") return false;
return TOOL_DEFINITIONS.web_fetch.schema.safeParse(args).success;
}

export const ToolMessage: React.FC<ToolMessageProps> = ({ message, className, workspaceId }) => {
// Route to specialized components based on tool name
if (isBashTool(message.toolName, message.args)) {
Expand Down Expand Up @@ -184,6 +192,18 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({ message, className, wo
);
}

if (isWebFetchTool(message.toolName, message.args)) {
return (
<div className={className}>
<WebFetchToolCall
args={message.args}
result={message.result as WebFetchToolResult | undefined}
status={message.status}
/>
</div>
);
}

// Fallback to generic tool call
return (
<div className={className}>
Expand Down
128 changes: 128 additions & 0 deletions src/browser/components/tools/WebFetchToolCall.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import React from "react";
import type { WebFetchToolArgs, WebFetchToolResult } from "@/common/types/tools";
import {
ToolContainer,
ToolHeader,
ExpandIcon,
StatusIndicator,
ToolDetails,
DetailSection,
DetailLabel,
LoadingDots,
} from "./shared/ToolPrimitives";
import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils";
import { TooltipWrapper, Tooltip } from "../Tooltip";
import { MarkdownRenderer } from "../Messages/MarkdownRenderer";

interface WebFetchToolCallProps {
args: WebFetchToolArgs;
result?: WebFetchToolResult;
status?: ToolStatus;
}

function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}

/**
* Extract domain from URL for compact display
*/
function getDomain(url: string): string {
try {
const parsed = new URL(url);
return parsed.hostname;
} catch {
return url;
}
}

export const WebFetchToolCall: React.FC<WebFetchToolCallProps> = ({
args,
result,
status = "pending",
}) => {
const { expanded, toggleExpanded } = useToolExpansion();

const domain = getDomain(args.url);

return (
<ToolContainer expanded={expanded} className="@container">
<ToolHeader onClick={toggleExpanded}>
<ExpandIcon expanded={expanded}>▶</ExpandIcon>
<TooltipWrapper inline>
<span>🌐</span>
<Tooltip>web_fetch</Tooltip>
</TooltipWrapper>
<div className="text-text flex max-w-96 min-w-0 items-center gap-1.5">
<span className="font-monospace truncate">{domain}</span>
</div>
{result && result.success && (
<span className="text-secondary ml-2 text-[10px] whitespace-nowrap">
<span className="hidden @sm:inline">fetched </span>
{formatBytes(result.length)}
</span>
)}
<StatusIndicator status={status}>{getStatusDisplay(status)}</StatusIndicator>
</ToolHeader>

{expanded && (
<ToolDetails>
<DetailSection>
<div className="bg-code-bg flex flex-wrap gap-4 rounded px-2 py-1.5 text-[11px] leading-[1.4]">
<div className="flex min-w-0 gap-1.5">
<span className="text-secondary font-medium">URL:</span>
<a
href={args.url}
target="_blank"
rel="noopener noreferrer"
className="text-link font-monospace truncate hover:underline"
>
{args.url}
</a>
</div>
{result && result.success && result.title && (
<div className="flex min-w-0 gap-1.5">
<span className="text-secondary font-medium">Title:</span>
<span className="text-text truncate">{result.title}</span>
</div>
)}
</div>
</DetailSection>

{result && (
<>
{result.success === false && result.error && (
<DetailSection>
<DetailLabel>Error</DetailLabel>
<div className="text-danger bg-danger-overlay border-danger rounded border-l-2 px-2 py-1.5 text-[11px]">
{result.error}
</div>
</DetailSection>
)}

{result.success && result.content && (
<DetailSection>
<DetailLabel>Content</DetailLabel>
<div className="bg-code-bg max-h-[300px] overflow-y-auto rounded px-3 py-2 text-[12px]">
<MarkdownRenderer content={result.content} />
</div>
</DetailSection>
)}
</>
)}

{status === "executing" && !result && (
<DetailSection>
<div className="text-secondary text-[11px]">
Fetching page
<LoadingDots />
</div>
</DetailSection>
)}
</ToolDetails>
)}
</ToolContainer>
);
};
5 changes: 5 additions & 0 deletions src/common/constants/toolLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,8 @@ export const BASH_MAX_LINE_BYTES = 1024; // 1KB per line for AI agent
export const MAX_TODOS = 7; // Maximum number of TODO items in a list

export const STATUS_MESSAGE_MAX_LENGTH = 60; // Maximum length for status messages (auto-truncated)

// Web fetch tool limits
export const WEB_FETCH_TIMEOUT_SECS = 15; // curl timeout
export const WEB_FETCH_MAX_OUTPUT_BYTES = 64 * 1024; // 64KB markdown output
export const WEB_FETCH_MAX_HTML_BYTES = 5 * 1024 * 1024; // 5MB HTML input (curl --max-filesize)
19 changes: 19 additions & 0 deletions src/common/types/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,22 @@ export type StatusSetToolResult =
success: false;
error: string;
};

// Web Fetch Tool Types
export interface WebFetchToolArgs {
url: string;
}

export type WebFetchToolResult =
| {
success: true;
title: string;
content: string;
url: string;
byline?: string;
length: number;
}
| {
success: false;
error: string;
};
12 changes: 12 additions & 0 deletions src/common/utils/tools/toolDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
BASH_MAX_LINE_BYTES,
BASH_MAX_TOTAL_BYTES,
STATUS_MESSAGE_MAX_LENGTH,
WEB_FETCH_MAX_OUTPUT_BYTES,
} from "@/common/constants/toolLimits";
import { TOOL_EDIT_WARNING } from "@/common/types/tools";

Expand Down Expand Up @@ -228,6 +229,16 @@ export const TOOL_DEFINITIONS = {
})
.strict(),
},
web_fetch: {
description:
`Fetch a web page and extract its main content as clean markdown. ` +
`Uses the workspace's network context (requests originate from the workspace, not Mux host). ` +
`Requires curl to be installed in the workspace. ` +
`Output is truncated to ${Math.floor(WEB_FETCH_MAX_OUTPUT_BYTES / 1024)}KB.`,
schema: z.object({
url: z.string().url().describe("The URL to fetch (http or https)"),
}),
},
} as const;

/**
Expand Down Expand Up @@ -268,6 +279,7 @@ export function getAvailableTools(modelString: string): string[] {
"todo_write",
"todo_read",
"status_set",
"web_fetch",
];

// Add provider-specific tools
Expand Down
5 changes: 5 additions & 0 deletions src/common/utils/tools/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ export async function getToolsForModel(
const wrap = <TParameters, TResult>(tool: Tool<TParameters, TResult>) =>
wrapWithInitWait(tool, workspaceId, initStateManager);

// Lazy-load web_fetch to avoid loading jsdom (ESM-only) at Jest setup time
// This allows integration tests to run without transforming jsdom's dependencies
const { createWebFetchTool } = await import("@/node/services/tools/web_fetch");

// Runtime-dependent tools need to wait for workspace initialization
// Wrap them to handle init waiting centrally instead of in each tool
const runtimeTools: Record<string, Tool> = {
Expand All @@ -95,6 +99,7 @@ export async function getToolsForModel(
// and line number miscalculations. Use file_edit_replace_string instead.
// file_edit_replace_lines: wrap(createFileEditReplaceLinesTool(config)),
bash: wrap(createBashTool(config)),
web_fetch: wrap(createWebFetchTool(config)),
};

// Non-runtime tools execute immediately (no init wait needed)
Expand Down
Loading