From 490cf0529bdd0f778f3d54e6f120bcf44fab8dcd Mon Sep 17 00:00:00 2001 From: Povilas Kanapickas Date: Sun, 28 Jun 2026 22:13:11 +0300 Subject: [PATCH 1/6] feat: register fetch_web_content tool and web group in type system --- packages/types/src/mode.ts | 4 +-- packages/types/src/tool.ts | 3 +- packages/types/src/vscode-extension-host.ts | 2 ++ schemas/roomodes.json | 40 +++++++++++++++++---- src/core/auto-approval/tools.ts | 1 + src/shared/__tests__/modes.spec.ts | 2 +- src/shared/tools.ts | 5 +++ 7 files changed, 47 insertions(+), 10 deletions(-) diff --git a/packages/types/src/mode.ts b/packages/types/src/mode.ts index 49a98a3dc1..f289e0ff27 100644 --- a/packages/types/src/mode.ts +++ b/packages/types/src/mode.ts @@ -192,7 +192,7 @@ export const DEFAULT_MODES: readonly ModeConfig[] = [ whenToUse: "Use this mode when you need to write, modify, or refactor code. Ideal for implementing features, fixing bugs, creating new files, or making code improvements across any programming language or framework.", description: "Write, modify, and refactor code", - groups: ["read", "edit", "command", "mcp"], + groups: ["read", "edit", "command", "mcp", "web"], }, { slug: "ask", @@ -214,7 +214,7 @@ export const DEFAULT_MODES: readonly ModeConfig[] = [ whenToUse: "Use this mode when you're troubleshooting issues, investigating errors, or diagnosing problems. Specialized in systematic debugging, adding logging, analyzing stack traces, and identifying root causes before applying fixes.", description: "Diagnose and fix software issues", - groups: ["read", "edit", "command", "mcp"], + groups: ["read", "edit", "command", "mcp", "web"], customInstructions: "Reflect on 5-7 different possible sources of the problem, distill those down to 1-2 most likely sources, and then add logs to validate your assumptions. Explicitly ask the user to confirm the diagnosis before fixing the problem.", }, diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 4f90b63e9f..a64f6116f4 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -4,7 +4,7 @@ import { z } from "zod" * ToolGroup */ -export const toolGroups = ["read", "edit", "command", "mcp", "modes"] as const +export const toolGroups = ["read", "edit", "command", "mcp", "modes", "web"] as const export const toolGroupsSchema = z.enum(toolGroups) @@ -46,6 +46,7 @@ export const toolNames = [ "skill", "generate_image", "custom_tool", + "fetch_web_content", ] as const export const toolNamesSchema = z.enum(toolNames) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 75a72accde..9020d37f8b 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -814,6 +814,8 @@ export interface ClineSayTool { | "runSlashCommand" | "updateTodoList" | "skill" + | "fetchWebContent" + url?: string path?: string // For readCommandOutput readStart?: number diff --git a/schemas/roomodes.json b/schemas/roomodes.json index cff607526b..84fff90c4b 100644 --- a/schemas/roomodes.json +++ b/schemas/roomodes.json @@ -29,7 +29,10 @@ }, "source": { "type": "string", - "enum": ["global", "project"] + "enum": [ + "global", + "project" + ] }, "allowedMcpServers": { "type": "array", @@ -44,7 +47,15 @@ "anyOf": [ { "type": "string", - "enum": ["read", "edit", "command", "mcp", "modes", "browser"] + "enum": [ + "read", + "edit", + "command", + "mcp", + "modes", + "web", + "browser" + ] }, { "type": "array", @@ -53,7 +64,15 @@ "items": [ { "type": "string", - "enum": ["read", "edit", "command", "mcp", "modes", "browser"] + "enum": [ + "read", + "edit", + "command", + "mcp", + "modes", + "web", + "browser" + ] }, { "type": "object", @@ -84,17 +103,26 @@ "type": "string" } }, - "required": ["relativePath"], + "required": [ + "relativePath" + ], "additionalProperties": false } } }, - "required": ["slug", "name", "roleDefinition", "groups"], + "required": [ + "slug", + "name", + "roleDefinition", + "groups" + ], "additionalProperties": false } } }, - "required": ["customModes"], + "required": [ + "customModes" + ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://github.com/RooCodeInc/Roo-Code/blob/main/schemas/roomodes.json", diff --git a/src/core/auto-approval/tools.ts b/src/core/auto-approval/tools.ts index a43f0cd994..3cdfec302d 100644 --- a/src/core/auto-approval/tools.ts +++ b/src/core/auto-approval/tools.ts @@ -13,5 +13,6 @@ export function isReadOnlyToolAction(tool: ClineSayTool): boolean { "searchFiles", "codebaseSearch", "runSlashCommand", + "fetchWebContent", ].includes(tool.tool) } diff --git a/src/shared/__tests__/modes.spec.ts b/src/shared/__tests__/modes.spec.ts index d56fc7e412..32ca56d706 100644 --- a/src/shared/__tests__/modes.spec.ts +++ b/src/shared/__tests__/modes.spec.ts @@ -613,7 +613,7 @@ describe("FileRestrictionError", () => { name: "🪲 Debug", roleDefinition: "You are Zoo, an expert software debugger specializing in systematic problem diagnosis and resolution.", - groups: ["read", "edit", "command", "mcp"], + groups: ["read", "edit", "command", "mcp", "web"], }) expect(debugMode?.customInstructions).toContain( "Reflect on 5-7 different possible sources of the problem, distill those down to 1-2 most likely sources, and then add logs to validate your assumptions. Explicitly ask the user to confirm the diagnosis before fixing the problem.", diff --git a/src/shared/tools.ts b/src/shared/tools.ts index d2dd9907b1..98d50bbbdf 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -116,6 +116,7 @@ export type NativeToolArgs = { update_todo_list: { todos: string } use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record } write_to_file: { path: string; content: string } + fetch_web_content: { url: string; prompt?: string } // Add more tools as they are migrated to native protocol } @@ -290,6 +291,7 @@ export const TOOL_DISPLAY_NAMES: Record = { skill: "load skill", generate_image: "generate images", custom_tool: "use custom tools", + fetch_web_content: "fetch web content", } as const // Define available tool groups. @@ -311,6 +313,9 @@ export const TOOL_GROUPS: Record = { tools: ["switch_mode", "new_task"], alwaysAvailable: true, }, + web: { + tools: ["fetch_web_content"], + }, } // Tools that are always available to all modes. From f1a4235f279311bc3ef9678c31b36058407ba7ad Mon Sep 17 00:00:00 2001 From: Povilas Kanapickas Date: Sun, 28 Jun 2026 22:13:12 +0300 Subject: [PATCH 2/6] feat: add fetch_web_content native tool definition --- .../tools/native-tools/fetch_web_content.ts | 52 +++++++++++++++++++ src/core/prompts/tools/native-tools/index.ts | 2 + 2 files changed, 54 insertions(+) create mode 100644 src/core/prompts/tools/native-tools/fetch_web_content.ts diff --git a/src/core/prompts/tools/native-tools/fetch_web_content.ts b/src/core/prompts/tools/native-tools/fetch_web_content.ts new file mode 100644 index 0000000000..627726767f --- /dev/null +++ b/src/core/prompts/tools/native-tools/fetch_web_content.ts @@ -0,0 +1,52 @@ +import type OpenAI from "openai" + +const FETCH_WEB_CONTENT_DESCRIPTION = `Request to fetch content from a URL on the web. This tool retrieves the content of a web page or API endpoint and returns it as text. + +Use this tool when you need to: +- Read documentation from a URL +- Fetch API responses +- Get content from web pages +- Download text-based resources + +The tool will automatically: +- Convert HTML to readable text (stripping scripts, styles, and tags) +- Pretty-print JSON responses +- Enforce size limits and timeouts for safety + +Parameters: +- url: (required) The URL to fetch. Must use http:// or https:// protocol. +- prompt: (optional) A description of what information you're looking for. This helps focus the analysis of the fetched content. + +Example: Fetching documentation +{ "url": "https://docs.example.com/api/reference", "prompt": "Find the authentication methods" } + +Example: Fetching an API response +{ "url": "https://api.example.com/status", "prompt": null }` + +const URL_PARAMETER_DESCRIPTION = `The URL to fetch content from. Must use http:// or https:// protocol.` + +const PROMPT_PARAMETER_DESCRIPTION = `Optional description of what information to look for in the fetched content. Helps focus analysis.` + +export default { + type: "function", + function: { + name: "fetch_web_content", + description: FETCH_WEB_CONTENT_DESCRIPTION, + strict: true, + parameters: { + type: "object", + properties: { + url: { + type: "string", + description: URL_PARAMETER_DESCRIPTION, + }, + prompt: { + type: ["string", "null"], + description: PROMPT_PARAMETER_DESCRIPTION, + }, + }, + required: ["url", "prompt"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 758914d2d6..c3a4a2ccce 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -20,6 +20,7 @@ import searchFiles from "./search_files" import switchMode from "./switch_mode" import updateTodoList from "./update_todo_list" import writeToFile from "./write_to_file" +import fetchWebContent from "./fetch_web_content" export { getMcpServerTools } from "./mcp_server" export { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "./converters" @@ -68,6 +69,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch switchMode, updateTodoList, writeToFile, + fetchWebContent, ] satisfies OpenAI.Chat.ChatCompletionTool[] } From d89ef6b17a0057f5e3118aff650cc621896457a5 Mon Sep 17 00:00:00 2001 From: Povilas Kanapickas Date: Sun, 28 Jun 2026 22:13:13 +0300 Subject: [PATCH 3/6] feat: implement FetchWebContentTool with tests --- pnpm-lock.yaml | 45 ++ src/core/tools/FetchWebContentTool.ts | 328 ++++++++ .../__tests__/fetchWebContentTool.spec.ts | 724 ++++++++++++++++++ src/package.json | 1 + 4 files changed, 1098 insertions(+) create mode 100644 src/core/tools/FetchWebContentTool.ts create mode 100644 src/core/tools/__tests__/fetchWebContentTool.spec.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4d46a1685..0ad5f7aca1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -510,6 +510,9 @@ importers: axios: specifier: ^1.12.0 version: 1.18.0 + cheerio: + specifier: ^1.2.0 + version: 1.2.0 chokidar: specifier: ^4.0.1 version: 4.0.3 @@ -4234,6 +4237,10 @@ packages: resolution: {integrity: sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==} engines: {node: '>=18.17'} + cheerio@1.2.0: + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} + engines: {node: '>=20.18.1'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -4865,6 +4872,9 @@ packages: encoding-sniffer@0.2.0: resolution: {integrity: sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==} + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} @@ -4887,6 +4897,10 @@ packages: resolution: {integrity: sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -5577,6 +5591,9 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + htmlparser2@9.1.0: resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} @@ -12754,6 +12771,20 @@ snapshots: undici: 6.27.0 whatwg-mimetype: 4.0.0 + cheerio@1.2.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 10.1.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 6.27.0 + whatwg-mimetype: 4.0.0 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -13362,6 +13393,11 @@ snapshots: iconv-lite: 0.6.3 whatwg-encoding: 3.1.1 + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + end-of-stream@1.4.4: dependencies: once: 1.4.0 @@ -13382,6 +13418,8 @@ snapshots: entities@6.0.0: {} + entities@7.0.1: {} + environment@1.1.0: {} error-stack-parser@2.1.4: @@ -14372,6 +14410,13 @@ snapshots: html-void-elements@3.0.0: {} + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + htmlparser2@9.1.0: dependencies: domelementtype: 2.3.0 diff --git a/src/core/tools/FetchWebContentTool.ts b/src/core/tools/FetchWebContentTool.ts new file mode 100644 index 0000000000..4fa7fa6cdd --- /dev/null +++ b/src/core/tools/FetchWebContentTool.ts @@ -0,0 +1,328 @@ +import { type ClineSayTool } from "@roo-code/types" +import * as cheerio from "cheerio" + +import { Task } from "../task/Task" +import type { ToolUse } from "../../shared/tools" +import { formatResponse } from "../prompts/responses" + +import { BaseTool, ToolCallbacks } from "./BaseTool" + +/** + * Default timeout for fetch requests in milliseconds (30 seconds) + */ +const DEFAULT_TIMEOUT_MS = 30_000 + +/** + * Maximum response size in bytes (5MB) + */ +const MAX_RESPONSE_BYTES = 5_000_000 + +/** + * Maximum content length in characters for the output + */ +const MAX_CONTENT_CHARS = 50_000 + +interface FetchWebContentParams { + url: string + prompt?: string | null +} + +/** + * Tags whose entire subtree should be removed (non-visible or non-content). + */ +const REMOVE_TAGS = new Set(["script", "style", "noscript", "template", "svg", "iframe", "object", "embed", "head"]) + +/** + * Block-level elements that should produce a newline boundary. + */ +const BLOCK_TAGS = new Set([ + "p", + "div", + "section", + "article", + "aside", + "main", + "header", + "footer", + "nav", + "blockquote", + "pre", + "figure", + "figcaption", + "details", + "summary", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "ul", + "ol", + "li", + "dl", + "dt", + "dd", + "table", + "thead", + "tbody", + "tfoot", + "tr", + "td", + "th", + "caption", + "hr", + "br", + "address", + "form", + "fieldset", +]) + +/** + * Extract text content from HTML by parsing it into a DOM tree with cheerio, + * removing non-content elements, and extracting text with proper whitespace + * handling for block vs inline elements. + */ +export function htmlToText(html: string): string { + const $ = cheerio.load(html) + + // Remove non-content elements entirely + for (const tag of REMOVE_TAGS) { + $(tag).remove() + } + + // Walk the DOM tree and extract text with block-level newline boundaries + const parts: string[] = [] + + function walk(nodes: cheerio.Cheerio): void { + nodes.contents().each((_, node) => { + if (node.type === "comment") { + return + } + + if (node.type === "text") { + const text = $(node).text() + if (text.trim()) { + parts.push(text) + } + return + } + + if (node.type === "tag") { + const tagName = node.name.toLowerCase() + + // Add newline before block elements + if (BLOCK_TAGS.has(tagName)) { + parts.push("\n") + } + + // Recurse into children + walk($(node)) + + // Add newline after block elements + if (BLOCK_TAGS.has(tagName)) { + parts.push("\n") + } + } + }) + } + + walk($.root()) + + // Join and normalize whitespace + return ( + parts + .join("") + // Collapse runs of spaces/tabs (but not newlines) into a single space + .replace(/[^\S\n]+/g, " ") + // Remove spaces at the start/end of lines + .replace(/ *\n */g, "\n") + // Collapse 3+ consecutive newlines into 2 + .replace(/\n{3,}/g, "\n\n") + .trim() + ) +} + +export class FetchWebContentTool extends BaseTool<"fetch_web_content"> { + readonly name = "fetch_web_content" as const + + async execute(params: FetchWebContentParams, task: Task, callbacks: ToolCallbacks): Promise { + const { askApproval, handleError, pushToolResult } = callbacks + const url = params.url + const prompt = params.prompt || undefined + + // Validate url parameter is present + if (!url) { + task.consecutiveMistakeCount++ + task.recordToolError("fetch_web_content") + task.didToolFailInCurrentTurn = true + pushToolResult(await task.sayAndCreateMissingParamError("fetch_web_content", "url")) + return + } + + // Validate URL format + let parsedUrl: URL + try { + parsedUrl = new URL(url) + } catch { + task.consecutiveMistakeCount++ + task.recordToolError("fetch_web_content") + task.didToolFailInCurrentTurn = true + pushToolResult(formatResponse.toolError(`Invalid URL: ${url}`)) + return + } + + // Only allow http and https protocols + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + task.consecutiveMistakeCount++ + task.recordToolError("fetch_web_content") + task.didToolFailInCurrentTurn = true + pushToolResult( + formatResponse.toolError(`Invalid protocol: ${parsedUrl.protocol}. Only http and https are supported.`), + ) + return + } + + task.consecutiveMistakeCount = 0 + + // Build the approval message + const sharedMessageProps: ClineSayTool = { + tool: "fetchWebContent", + url: url, + } + + const completeMessage = JSON.stringify(sharedMessageProps satisfies ClineSayTool) + const didApprove = await askApproval("tool", completeMessage) + + if (!didApprove) { + return + } + + // Execute the fetch + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS) + + try { + const response = await fetch(url, { + method: "GET", + headers: { + "User-Agent": "Mozilla/5.0 (compatible; ZooCode/1.0.0)", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7", + "Accept-Language": "en-US,en;q=0.9", + }, + redirect: "follow", + signal: controller.signal, + }) + + clearTimeout(timeout) + + if (!response.ok) { + pushToolResult(formatResponse.toolError(`HTTP ${response.status}: ${response.statusText}`)) + return + } + + const contentType = response.headers.get("content-type") || "" + + // Read response body with size limit + const reader = response.body?.getReader() + if (!reader) { + pushToolResult(formatResponse.toolError("Failed to read response body")) + return + } + + const chunks: Uint8Array[] = [] + let totalSize = 0 + + while (true) { + const { done, value } = await reader.read() + if (done) break + + totalSize += value.length + if (totalSize > MAX_RESPONSE_BYTES) { + reader.cancel() + pushToolResult( + formatResponse.toolError( + `Response too large: exceeded ${MAX_RESPONSE_BYTES} bytes (${Math.round(MAX_RESPONSE_BYTES / 1_000_000)}MB limit)`, + ), + ) + return + } + + chunks.push(value) + } + + // Combine chunks and decode + const buffer = new Uint8Array(totalSize) + let offset = 0 + for (const chunk of chunks) { + buffer.set(chunk, offset) + offset += chunk.length + } + const text = new TextDecoder("utf-8").decode(buffer) + + // Process content based on type + let content: string + if (contentType.includes("text/html") || contentType.includes("application/xhtml")) { + content = htmlToText(text) + } else if (contentType.includes("application/json")) { + try { + const json = JSON.parse(text) + content = JSON.stringify(json, null, 2) + } catch { + content = text + } + } else { + content = text + } + + // Format output with metadata + const outputLines = [ + `URL: ${url}`, + `Content-Type: ${contentType}`, + `Size: ${totalSize} bytes`, + ``, + `--- Content ---`, + content.slice(0, MAX_CONTENT_CHARS), + ] + + if (content.length > MAX_CONTENT_CHARS) { + outputLines.push( + `\n[Content truncated: showing first ${MAX_CONTENT_CHARS} of ${content.length} characters]`, + ) + } + + if (prompt) { + outputLines.push(``, `--- Analysis Request ---`, `Prompt: ${prompt}`) + } + + pushToolResult(outputLines.join("\n")) + } catch (error) { + clearTimeout(timeout) + + if (error instanceof Error && error.name === "AbortError") { + pushToolResult(formatResponse.toolError(`Request timed out after ${DEFAULT_TIMEOUT_MS}ms`)) + return + } + + await handleError("fetching web content", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"fetch_web_content">): Promise { + const url = block.params.url + + if (!this.hasPathStabilized(url)) { + return + } + + const sharedMessageProps: ClineSayTool = { + tool: "fetchWebContent", + url: url ?? "", + } + + const partialMessage = JSON.stringify(sharedMessageProps satisfies ClineSayTool) + await task.ask("tool", partialMessage, block.partial).catch(() => {}) + } +} + +export const fetchWebContentTool = new FetchWebContentTool() diff --git a/src/core/tools/__tests__/fetchWebContentTool.spec.ts b/src/core/tools/__tests__/fetchWebContentTool.spec.ts new file mode 100644 index 0000000000..3938656478 --- /dev/null +++ b/src/core/tools/__tests__/fetchWebContentTool.spec.ts @@ -0,0 +1,724 @@ +// npx vitest run src/core/tools/__tests__/fetchWebContentTool.spec.ts + +import { FetchWebContentTool, htmlToText } from "../FetchWebContentTool" +import type { ToolCallbacks } from "../BaseTool" +import type { Task } from "../../task/Task" + +// Mock formatResponse +vi.mock("../../prompts/responses", () => ({ + formatResponse: { + toolError: (msg: string) => `Error: ${msg}`, + }, +})) + +function createMockTask(overrides: Partial = {}): Task { + return { + consecutiveMistakeCount: 0, + didToolFailInCurrentTurn: false, + cwd: "/test/workspace", + recordToolError: vi.fn(), + sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing parameter error"), + ask: vi.fn().mockResolvedValue(undefined), + say: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as Task +} + +function createMockCallbacks(): ToolCallbacks & { + results: string[] + approvals: string[] + errors: string[] +} { + const results: string[] = [] + const approvals: string[] = [] + const errors: string[] = [] + + return { + results, + approvals, + errors, + askApproval: vi.fn().mockImplementation(async (_type: string, message: string) => { + approvals.push(message) + return true + }), + handleError: vi.fn().mockImplementation(async (context: string, error: Error) => { + errors.push(`${context}: ${error.message}`) + }), + pushToolResult: vi.fn().mockImplementation((result: string) => { + results.push(result) + }), + } +} + +function createMockResponse( + body: string, + options: { + status?: number + statusText?: string + contentType?: string + ok?: boolean + } = {}, +): Response { + const { status = 200, statusText = "OK", contentType = "text/plain", ok = true } = options + + const encoder = new TextEncoder() + const encoded = encoder.encode(body) + + return { + ok, + status, + statusText, + headers: new Headers({ "content-type": contentType }), + body: new ReadableStream({ + start(controller) { + controller.enqueue(encoded) + controller.close() + }, + }), + } as unknown as Response +} + +describe("FetchWebContentTool", () => { + let tool: FetchWebContentTool + let originalFetch: typeof globalThis.fetch + + beforeEach(() => { + tool = new FetchWebContentTool() + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + vi.restoreAllMocks() + }) + + describe("execute", () => { + it("should fetch plain text content successfully", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + + globalThis.fetch = vi + .fn() + .mockResolvedValue(createMockResponse("Hello, world!", { contentType: "text/plain" })) + + await tool.execute({ url: "https://example.com/text" }, task, callbacks) + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://example.com/text", + expect.objectContaining({ method: "GET" }), + ) + expect(callbacks.results).toEqual([ + [ + "URL: https://example.com/text", + "Content-Type: text/plain", + "Size: 13 bytes", + "", + "--- Content ---", + "Hello, world!", + ].join("\n"), + ]) + }) + + it("should convert HTML to text", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + const html = "

Title

Paragraph

" + + globalThis.fetch = vi + .fn() + .mockResolvedValue(createMockResponse(html, { contentType: "text/html; charset=utf-8" })) + + await tool.execute({ url: "https://example.com" }, task, callbacks) + + expect(callbacks.results).toEqual([ + [ + "URL: https://example.com", + "Content-Type: text/html; charset=utf-8", + "Size: 83 bytes", + "", + "--- Content ---", + "Title\n\nParagraph", + ].join("\n"), + ]) + }) + + it("should pretty-print JSON responses", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + const json = '{"key":"value","nested":{"a":1}}' + + globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse(json, { contentType: "application/json" })) + + await tool.execute({ url: "https://api.example.com/data" }, task, callbacks) + + expect(callbacks.results).toEqual([ + [ + "URL: https://api.example.com/data", + "Content-Type: application/json", + "Size: 32 bytes", + "", + "--- Content ---", + JSON.stringify(JSON.parse(json), null, 2), + ].join("\n"), + ]) + }) + + it("should error on missing url parameter", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + + await tool.execute({ url: "" }, task, callbacks) + + expect(task.consecutiveMistakeCount).toBe(1) + expect(task.didToolFailInCurrentTurn).toBe(true) + expect(task.recordToolError).toHaveBeenCalledWith("fetch_web_content") + }) + + it("should error on invalid URL", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + + await tool.execute({ url: "not-a-url" }, task, callbacks) + + expect(task.consecutiveMistakeCount).toBe(1) + expect(callbacks.results).toEqual(["Error: Invalid URL: not-a-url"]) + }) + + it("should reject non-http protocols", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + + await tool.execute({ url: "file:///etc/passwd" }, task, callbacks) + + expect(task.consecutiveMistakeCount).toBe(1) + expect(callbacks.results).toEqual(["Error: Invalid protocol: file:. Only http and https are supported."]) + }) + + it("should reject javascript: protocol", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + + await tool.execute({ url: "javascript:alert(1)" }, task, callbacks) + + expect(task.consecutiveMistakeCount).toBe(1) + expect(callbacks.results).toEqual([ + "Error: Invalid protocol: javascript:. Only http and https are supported.", + ]) + }) + + it("should handle HTTP errors", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + + globalThis.fetch = vi.fn().mockResolvedValue( + createMockResponse("Not Found", { + status: 404, + statusText: "Not Found", + ok: false, + }), + ) + + await tool.execute({ url: "https://example.com/missing" }, task, callbacks) + + expect(callbacks.results).toEqual(["Error: HTTP 404: Not Found"]) + }) + + it("should handle fetch timeout (AbortError)", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + + const abortError = new Error("The operation was aborted") + abortError.name = "AbortError" + globalThis.fetch = vi.fn().mockRejectedValue(abortError) + + await tool.execute({ url: "https://example.com/slow" }, task, callbacks) + + expect(callbacks.results).toEqual(["Error: Request timed out after 30000ms"]) + }) + + it("should not fetch when user rejects approval", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + callbacks.askApproval = vi.fn().mockResolvedValue(false) + + globalThis.fetch = vi.fn() + + await tool.execute({ url: "https://example.com" }, task, callbacks) + + expect(globalThis.fetch).not.toHaveBeenCalled() + expect(callbacks.results).toEqual([]) + }) + + it("should include prompt in output when provided", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + + globalThis.fetch = vi + .fn() + .mockResolvedValue(createMockResponse("Some content", { contentType: "text/plain" })) + + await tool.execute({ url: "https://example.com", prompt: "Find the API key section" }, task, callbacks) + + expect(callbacks.results).toEqual([ + [ + "URL: https://example.com", + "Content-Type: text/plain", + "Size: 12 bytes", + "", + "--- Content ---", + "Some content", + "", + "--- Analysis Request ---", + "Prompt: Find the API key section", + ].join("\n"), + ]) + }) + + it("should handle response with no readable body", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({ "content-type": "text/plain" }), + body: null, + }) + + await tool.execute({ url: "https://example.com/nobody" }, task, callbacks) + + expect(callbacks.results).toEqual(["Error: Failed to read response body"]) + }) + + it("should error when response exceeds size limit", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + + // Create a response that exceeds MAX_RESPONSE_BYTES (5MB) + const largeChunk = new Uint8Array(3_000_000) // 3MB per chunk + let chunkCount = 0 + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({ "content-type": "text/plain" }), + body: { + getReader: () => ({ + read: vi.fn().mockImplementation(async () => { + chunkCount++ + if (chunkCount <= 2) { + return { done: false, value: largeChunk } + } + return { done: true, value: undefined } + }), + cancel: vi.fn(), + }), + }, + }) + + await tool.execute({ url: "https://example.com/large" }, task, callbacks) + + expect(callbacks.results).toEqual(["Error: Response too large: exceeded 5000000 bytes (5MB limit)"]) + }) + + it("should truncate content exceeding MAX_CONTENT_CHARS", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + + // Create content that exceeds 50,000 chars + const longContent = "A".repeat(60_000) + + globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse(longContent, { contentType: "text/plain" })) + + await tool.execute({ url: "https://example.com/long" }, task, callbacks) + + expect(callbacks.results).toEqual([ + [ + "URL: https://example.com/long", + "Content-Type: text/plain", + "Size: 60000 bytes", + "", + "--- Content ---", + "A".repeat(50_000), + "\n[Content truncated: showing first 50000 of 60000 characters]", + ].join("\n"), + ]) + }) + + it("should handle invalid JSON with application/json content type", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + + globalThis.fetch = vi + .fn() + .mockResolvedValue(createMockResponse("not valid json {{{", { contentType: "application/json" })) + + await tool.execute({ url: "https://api.example.com/broken" }, task, callbacks) + + expect(callbacks.results).toEqual([ + [ + "URL: https://api.example.com/broken", + "Content-Type: application/json", + "Size: 18 bytes", + "", + "--- Content ---", + "not valid json {{{", + ].join("\n"), + ]) + }) + + it("should handle XHTML content type as HTML", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + const xhtml = '

XHTML Title

Content here

' + + globalThis.fetch = vi.fn().mockResolvedValue( + createMockResponse(xhtml, { + contentType: "application/xhtml+xml; charset=utf-8", + }), + ) + + await tool.execute({ url: "https://example.com/xhtml" }, task, callbacks) + + expect(callbacks.results).toEqual([ + [ + "URL: https://example.com/xhtml", + "Content-Type: application/xhtml+xml; charset=utf-8", + "Size: 86 bytes", + "", + "--- Content ---", + "XHTML Title\n\nContent here", + ].join("\n"), + ]) + }) + + it("should handle generic fetch errors via handleError", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + + const networkError = new Error("ECONNREFUSED") + globalThis.fetch = vi.fn().mockRejectedValue(networkError) + + await tool.execute({ url: "https://example.com/down" }, task, callbacks) + + expect(callbacks.handleError).toHaveBeenCalledWith("fetching web content", networkError) + // Should not push a tool result for generic errors (handleError does it) + expect(callbacks.results).toEqual([]) + }) + + it("should not include prompt section when prompt is null", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + + globalThis.fetch = vi + .fn() + .mockResolvedValue(createMockResponse("Some content", { contentType: "text/plain" })) + + await tool.execute({ url: "https://example.com", prompt: null }, task, callbacks) + + expect(callbacks.results).toEqual([ + [ + "URL: https://example.com", + "Content-Type: text/plain", + "Size: 12 bytes", + "", + "--- Content ---", + "Some content", + ].join("\n"), + ]) + }) + + it("should not include prompt section when prompt is undefined", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + + globalThis.fetch = vi + .fn() + .mockResolvedValue(createMockResponse("Some content", { contentType: "text/plain" })) + + await tool.execute({ url: "https://example.com" }, task, callbacks) + + expect(callbacks.results).toEqual([ + [ + "URL: https://example.com", + "Content-Type: text/plain", + "Size: 12 bytes", + "", + "--- Content ---", + "Some content", + ].join("\n"), + ]) + }) + + it("should include size in output metadata", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + + globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse("Hello!", { contentType: "text/plain" })) + + await tool.execute({ url: "https://example.com/size" }, task, callbacks) + + expect(callbacks.results).toEqual([ + [ + "URL: https://example.com/size", + "Content-Type: text/plain", + "Size: 6 bytes", + "", + "--- Content ---", + "Hello!", + ].join("\n"), + ]) + }) + + it("should reset consecutiveMistakeCount on valid URL", async () => { + const task = createMockTask({ consecutiveMistakeCount: 3 }) + const callbacks = createMockCallbacks() + + globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse("content", { contentType: "text/plain" })) + + await tool.execute({ url: "https://example.com" }, task, callbacks) + + expect(task.consecutiveMistakeCount).toBe(0) + }) + + it("should send correct approval message with fetchWebContent tool type", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + + globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse("content", { contentType: "text/plain" })) + + await tool.execute({ url: "https://example.com/approve" }, task, callbacks) + + expect(callbacks.askApproval).toHaveBeenCalledWith("tool", expect.any(String)) + const approvalMessage = JSON.parse(callbacks.approvals[0]) + expect(approvalMessage.tool).toBe("fetchWebContent") + expect(approvalMessage.url).toBe("https://example.com/approve") + }) + + it("should handle empty content-type header", async () => { + const task = createMockTask() + const callbacks = createMockCallbacks() + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({}), + body: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("raw content")) + controller.close() + }, + }), + }) + + await tool.execute({ url: "https://example.com/noct" }, task, callbacks) + + expect(callbacks.results).toEqual([ + [ + "URL: https://example.com/noct", + "Content-Type: ", + "Size: 11 bytes", + "", + "--- Content ---", + "raw content", + ].join("\n"), + ]) + }) + }) + + describe("handlePartial", () => { + it("should not call task.ask until url has stabilized", async () => { + const task = createMockTask() + + // First call with a url - not stabilized yet (first time seen) + await tool.handlePartial(task, { + type: "tool_use", + name: "fetch_web_content", + params: { url: "https://example.com" }, + partial: true, + } as any) + + expect(task.ask).not.toHaveBeenCalled() + + // Second call with same url - now stabilized + await tool.handlePartial(task, { + type: "tool_use", + name: "fetch_web_content", + params: { url: "https://example.com" }, + partial: true, + } as any) + + expect(task.ask).toHaveBeenCalledWith("tool", expect.any(String), true) + }) + + it("should not call task.ask when url is still changing", async () => { + const task = createMockTask() + + await tool.handlePartial(task, { + type: "tool_use", + name: "fetch_web_content", + params: { url: "https://ex" }, + partial: true, + } as any) + + await tool.handlePartial(task, { + type: "tool_use", + name: "fetch_web_content", + params: { url: "https://example.com" }, + partial: true, + } as any) + + expect(task.ask).not.toHaveBeenCalled() + }) + + it("should not call task.ask when url is undefined", async () => { + const task = createMockTask() + + await tool.handlePartial(task, { + type: "tool_use", + name: "fetch_web_content", + params: {}, + partial: true, + } as any) + + await tool.handlePartial(task, { + type: "tool_use", + name: "fetch_web_content", + params: {}, + partial: true, + } as any) + + expect(task.ask).not.toHaveBeenCalled() + }) + + it("should include url in the partial message JSON", async () => { + const task = createMockTask() + + // Stabilize the url + await tool.handlePartial(task, { + type: "tool_use", + name: "fetch_web_content", + params: { url: "https://docs.example.com/api" }, + partial: true, + } as any) + + await tool.handlePartial(task, { + type: "tool_use", + name: "fetch_web_content", + params: { url: "https://docs.example.com/api" }, + partial: true, + } as any) + + expect(task.ask).toHaveBeenCalledTimes(1) + const callArg = (task.ask as ReturnType).mock.calls[0][1] + const parsed = JSON.parse(callArg) + expect(parsed.tool).toBe("fetchWebContent") + expect(parsed.url).toBe("https://docs.example.com/api") + }) + + it("should swallow errors from task.ask", async () => { + const task = createMockTask({ + ask: vi.fn().mockRejectedValue(new Error("ask failed")), + }) + + // Stabilize the url + await tool.handlePartial(task, { + type: "tool_use", + name: "fetch_web_content", + params: { url: "https://example.com" }, + partial: true, + } as any) + + // Should not throw + await tool.handlePartial(task, { + type: "tool_use", + name: "fetch_web_content", + params: { url: "https://example.com" }, + partial: true, + } as any) + }) + }) + + describe("htmlToText", () => { + it("should strip script tags and content", () => { + expect(htmlToText('

Hello

World

')).toBe("Hello\n\nWorld") + }) + + it("should strip style tags and content", () => { + expect(htmlToText("

Hello

")).toBe("Hello") + }) + + it("should strip noscript, template, svg, and iframe elements", () => { + const html = [ + "

Visible

", + "", + "", + '', + '', + ].join("") + expect(htmlToText(html)).toBe("Visible") + }) + + it("should strip content (meta, title, link tags)", () => { + const html = [ + "", + "Page Title", + '', + '', + "

Body content

", + ].join("") + expect(htmlToText(html)).toBe("Body content") + }) + + it("should decode HTML entities", () => { + expect(htmlToText("& < > "  ")).toBe('& < > "') + }) + + it("should decode numeric HTML entities (decimal and hex)", () => { + expect(htmlToText("ABC DEF")).toBe("ABC DEF") + }) + + it("should decode named entities like — and ’", () => { + expect(htmlToText("Hello—World it’s fine")).toBe("Hello\u2014World it\u2019s fine") + }) + + it("should normalize whitespace", () => { + expect(htmlToText("

Hello World

")).toBe("Hello World") + }) + + it("should add newlines between block-level elements", () => { + expect(htmlToText("
First
Second

Third

")).toBe("First\n\nSecond\n\nThird") + }) + + it("should keep inline elements on the same line", () => { + expect(htmlToText("

Hello bold and italic text

")).toBe( + "Hello bold and italic text", + ) + }) + + it("should handle nested structures correctly", () => { + expect( + htmlToText( + '

Title

Paragraph with a link inside.

  • Item 1
  • Item 2
', + ), + ).toBe("Title\n\nParagraph with a link inside.\n\nItem 1\n\nItem 2") + }) + + it("should collapse excessive newlines to at most two", () => { + expect(htmlToText("

A





B

")).toBe("A\n\nB") + }) + + it("should handle malformed HTML gracefully", () => { + expect(htmlToText("

Unclosed paragraph

Nested bold
")).toBe( + "Unclosed paragraph\n\nNested bold", + ) + }) + + it("should remove HTML comments", () => { + expect(htmlToText("

Before

After

")).toBe("Before\n\nAfter") + }) + }) +}) diff --git a/src/package.json b/src/package.json index f36fca6a4f..cc292c77fe 100644 --- a/src/package.json +++ b/src/package.json @@ -483,6 +483,7 @@ "ai-sdk-provider-poe": "2.0.18", "async-mutex": "^0.5.0", "axios": "^1.12.0", + "cheerio": "^1.2.0", "chokidar": "^4.0.1", "clone-deep": "^4.0.1", "delay": "^6.0.0", From 7f2248f299061902d63b5bdb467ce547dac86858 Mon Sep 17 00:00:00 2001 From: Povilas Kanapickas Date: Sun, 28 Jun 2026 22:13:14 +0300 Subject: [PATCH 4/6] feat: wire fetch_web_content into execution dispatcher --- .../assistant-message/NativeToolCallParser.ts | 18 +++ .../__tests__/NativeToolCallParser.spec.ts | 125 ++++++++++++++++++ .../presentAssistantMessage.ts | 10 ++ 3 files changed, 153 insertions(+) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 9639ae1baa..382a33655c 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -637,6 +637,15 @@ export class NativeToolCallParser { } break + case "fetch_web_content": + if (partialArgs.url !== undefined) { + nativeArgs = { + url: partialArgs.url, + prompt: partialArgs.prompt, + } + } + break + default: break } @@ -992,6 +1001,15 @@ export class NativeToolCallParser { } break + case "fetch_web_content": + if (args.url !== undefined) { + nativeArgs = { + url: args.url, + prompt: args.prompt, + } as NativeArgsFor + } + break + default: if (customToolRegistry.has(resolvedName)) { nativeArgs = args as NativeArgsFor diff --git a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts index 2c15e12069..f06e24eb7c 100644 --- a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts +++ b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts @@ -291,6 +291,67 @@ describe("NativeToolCallParser", () => { }) }) }) + + describe("fetch_web_content tool", () => { + it("should parse fetch_web_content with url and prompt", () => { + const toolCall = { + id: "toolu_fetch_1", + name: "fetch_web_content" as const, + arguments: JSON.stringify({ + url: "https://example.com", + prompt: "Find the main heading", + }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + + expect(result).not.toBeNull() + expect(result?.type).toBe("tool_use") + if (result?.type === "tool_use") { + expect(result.nativeArgs).toBeDefined() + const nativeArgs = result.nativeArgs as { url: string; prompt?: string } + expect(nativeArgs.url).toBe("https://example.com") + expect(nativeArgs.prompt).toBe("Find the main heading") + } + }) + + it("should parse fetch_web_content with url only (no prompt)", () => { + const toolCall = { + id: "toolu_fetch_2", + name: "fetch_web_content" as const, + arguments: JSON.stringify({ + url: "https://api.example.com/status", + prompt: null, + }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + + expect(result).not.toBeNull() + expect(result?.type).toBe("tool_use") + if (result?.type === "tool_use") { + expect(result.nativeArgs).toBeDefined() + const nativeArgs = result.nativeArgs as { url: string; prompt?: string | null } + expect(nativeArgs.url).toBe("https://api.example.com/status") + expect(nativeArgs.prompt).toBeNull() + } + }) + + it("should return null when url is missing", () => { + const toolCall = { + id: "toolu_fetch_3", + name: "fetch_web_content" as const, + arguments: JSON.stringify({ + prompt: "some prompt", + }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + + // Should return null because nativeArgs can't be constructed without url + expect(result).toBeNull() + }) + }) }) describe("processStreamingChunk", () => { @@ -311,6 +372,22 @@ describe("NativeToolCallParser", () => { expect(nativeArgs.path).toBe("src/test.ts") }) }) + + describe("fetch_web_content tool", () => { + it("should emit a partial ToolUse with nativeArgs.url during streaming", () => { + const id = "toolu_streaming_fetch_1" + NativeToolCallParser.startStreamingToolCall(id, "fetch_web_content") + + const fullArgs = JSON.stringify({ url: "https://example.com", prompt: "Find info" }) + const result = NativeToolCallParser.processStreamingChunk(id, fullArgs) + + expect(result).not.toBeNull() + expect(result?.nativeArgs).toBeDefined() + const nativeArgs = result?.nativeArgs as { url: string; prompt?: string } + expect(nativeArgs.url).toBe("https://example.com") + expect(nativeArgs.prompt).toBe("Find info") + }) + }) }) describe("finalizeStreamingToolCall", () => { @@ -342,5 +419,53 @@ describe("NativeToolCallParser", () => { } }) }) + + describe("fetch_web_content tool", () => { + it("should parse fetch_web_content args on finalize", () => { + const id = "toolu_finalize_fetch_1" + NativeToolCallParser.startStreamingToolCall(id, "fetch_web_content") + + NativeToolCallParser.processStreamingChunk( + id, + JSON.stringify({ + url: "https://docs.example.com/api", + prompt: "Find authentication methods", + }), + ) + + const result = NativeToolCallParser.finalizeStreamingToolCall(id) + + expect(result).not.toBeNull() + expect(result?.type).toBe("tool_use") + if (result?.type === "tool_use") { + const nativeArgs = result.nativeArgs as { url: string; prompt?: string } + expect(nativeArgs.url).toBe("https://docs.example.com/api") + expect(nativeArgs.prompt).toBe("Find authentication methods") + } + }) + + it("should parse fetch_web_content with null prompt on finalize", () => { + const id = "toolu_finalize_fetch_2" + NativeToolCallParser.startStreamingToolCall(id, "fetch_web_content") + + NativeToolCallParser.processStreamingChunk( + id, + JSON.stringify({ + url: "https://api.example.com/status", + prompt: null, + }), + ) + + const result = NativeToolCallParser.finalizeStreamingToolCall(id) + + expect(result).not.toBeNull() + expect(result?.type).toBe("tool_use") + if (result?.type === "tool_use") { + const nativeArgs = result.nativeArgs as { url: string; prompt?: string | null } + expect(nativeArgs.url).toBe("https://api.example.com/status") + expect(nativeArgs.prompt).toBeNull() + } + }) + }) }) }) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index cc675dd948..e2e35d81c7 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -37,6 +37,7 @@ import { generateImageTool } from "../tools/GenerateImageTool" import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool" import { isValidToolName, validateToolUse } from "../tools/validateToolUse" import { codebaseSearchTool } from "../tools/CodebaseSearchTool" +import { fetchWebContentTool } from "../tools/FetchWebContentTool" import { formatResponse } from "../prompts/responses" import { sanitizeToolUseId } from "../../utils/tool-id" @@ -383,6 +384,8 @@ export async function presentAssistantMessage(cline: Task) { return `[${block.name} for '${block.params.skill}'${block.params.args ? ` with args: ${block.params.args}` : ""}]` case "generate_image": return `[${block.name} for '${block.params.path}']` + case "fetch_web_content": + return `[${block.name} for '${block.params.url}']` default: return `[${block.name}]` } @@ -849,6 +852,13 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult, }) break + case "fetch_web_content": + await fetchWebContentTool.handle(cline, block as ToolUse<"fetch_web_content">, { + askApproval, + handleError, + pushToolResult, + }) + break default: { // Handle unknown/invalid tool names OR custom tools // This is critical for native tool calling where every tool_use MUST have a tool_result From ec011497123c28f32f1c483992025e94880cb6c8 Mon Sep 17 00:00:00 2001 From: Povilas Kanapickas Date: Sun, 28 Jun 2026 22:13:15 +0300 Subject: [PATCH 5/6] feat: add fetch_web_content timeline rendering in ChatRow --- webview-ui/src/components/chat/ChatRow.tsx | 23 +++ .../ChatRow.fetch-web-content.spec.tsx | 136 ++++++++++++++++++ webview-ui/src/i18n/locales/en/chat.json | 4 + 3 files changed, 163 insertions(+) create mode 100644 webview-ui/src/components/chat/__tests__/ChatRow.fetch-web-content.spec.tsx diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index af40af4ecf..623dfd313b 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -57,6 +57,7 @@ import { useSelectedModel } from "../ui/hooks/useSelectedModel" import { Eye, FileDiff, + Globe, ListTree, User, Edit, @@ -1015,6 +1016,28 @@ export const ChatRowContent = ({ )} ) + case "fetchWebContent": + return ( + <> +
+ + + {message.type === "ask" + ? t("chat:webFetch.wantsToFetch") + : t("chat:webFetch.didFetch")} + +
+
+ + + + {tool.url} + + + +
+ + ) default: return null } diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.fetch-web-content.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.fetch-web-content.spec.tsx new file mode 100644 index 0000000000..b58d757a17 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatRow.fetch-web-content.spec.tsx @@ -0,0 +1,136 @@ +import React from "react" +import { render, screen } from "@/utils/test-utils" +import { describe, it, expect, beforeEach, vi } from "vitest" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" +import { ChatRowContent } from "../ChatRow" + +// Mock i18n +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "chat:webFetch.wantsToFetch": "Zoo wants to fetch web content", + "chat:webFetch.didFetch": "Zoo fetched web content", + } + return translations[key] || key + }, + }), + Trans: ({ i18nKey, children }: { i18nKey: string; children?: React.ReactNode }) => { + return <>{children || i18nKey} + }, + initReactI18next: { + type: "3rdParty", + init: () => {}, + }, +})) + +// Mock VSCodeBadge +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeBadge: ({ children, ...props }: { children: React.ReactNode }) => {children}, +})) + +const queryClient = new QueryClient() + +const mockOnToggleExpand = vi.fn() +const mockOnSuggestionClick = vi.fn() +const mockOnBatchFileResponse = vi.fn() +const mockOnFollowUpUnmount = vi.fn() + +const renderChatRowWithProviders = (message: any) => { + return render( + + + + + , + ) +} + +describe("ChatRow - fetchWebContent tool", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should display fetchWebContent ask message with URL", () => { + const message: any = { + type: "ask", + ask: "tool", + ts: Date.now(), + text: JSON.stringify({ + tool: "fetchWebContent", + url: "https://example.com", + }), + partial: false, + } + + renderChatRowWithProviders(message) + + expect(screen.getByText("Zoo wants to fetch web content")).toBeInTheDocument() + expect(screen.getByText("https://example.com")).toBeInTheDocument() + }) + + it("should display the Globe icon for fetchWebContent", () => { + const message: any = { + type: "ask", + ask: "tool", + ts: Date.now(), + text: JSON.stringify({ + tool: "fetchWebContent", + url: "https://docs.example.com/api", + }), + partial: false, + } + + renderChatRowWithProviders(message) + + expect(screen.getByLabelText("Web fetch icon")).toBeInTheDocument() + }) + + it("should display the URL in the tool use block", () => { + const message: any = { + type: "ask", + ask: "tool", + ts: Date.now(), + text: JSON.stringify({ + tool: "fetchWebContent", + url: "https://api.github.com/repos/owner/repo", + }), + partial: false, + } + + renderChatRowWithProviders(message) + + expect(screen.getByText("https://api.github.com/repos/owner/repo")).toBeInTheDocument() + }) + + it("should not return null for fetchWebContent tool (regression test)", () => { + const message: any = { + type: "ask", + ask: "tool", + ts: Date.now(), + text: JSON.stringify({ + tool: "fetchWebContent", + url: "https://www.delfi.lt", + }), + partial: false, + } + + const { container } = renderChatRowWithProviders(message) + + // The container should have rendered content (not null) + expect(container.innerHTML).not.toBe("") + expect(screen.getByText("Zoo wants to fetch web content")).toBeInTheDocument() + expect(screen.getByText("https://www.delfi.lt")).toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index a830abd5cd..1edfe1e234 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -266,6 +266,10 @@ "didSearch_other": "Found {{count}} results", "resultTooltip": "Similarity score: {{score}} (click to open file)" }, + "webFetch": { + "wantsToFetch": "Zoo wants to fetch web content", + "didFetch": "Zoo fetched web content" + }, "commandOutput": "Command Output", "commandExecution": { "abort": "Abort", From ba0ca5926e5c6be6a72a004792fd851fbf457a09 Mon Sep 17 00:00:00 2001 From: Povilas Kanapickas Date: Sun, 28 Jun 2026 22:13:16 +0300 Subject: [PATCH 6/6] Update translations --- webview-ui/src/i18n/locales/ca/chat.json | 4 ++++ webview-ui/src/i18n/locales/ca/prompts.json | 3 ++- webview-ui/src/i18n/locales/de/chat.json | 4 ++++ webview-ui/src/i18n/locales/de/prompts.json | 3 ++- webview-ui/src/i18n/locales/en/prompts.json | 3 ++- webview-ui/src/i18n/locales/es/chat.json | 4 ++++ webview-ui/src/i18n/locales/es/prompts.json | 3 ++- webview-ui/src/i18n/locales/fr/chat.json | 4 ++++ webview-ui/src/i18n/locales/fr/prompts.json | 3 ++- webview-ui/src/i18n/locales/hi/chat.json | 4 ++++ webview-ui/src/i18n/locales/hi/prompts.json | 3 ++- webview-ui/src/i18n/locales/id/chat.json | 4 ++++ webview-ui/src/i18n/locales/id/prompts.json | 3 ++- webview-ui/src/i18n/locales/it/chat.json | 4 ++++ webview-ui/src/i18n/locales/it/prompts.json | 3 ++- webview-ui/src/i18n/locales/ja/chat.json | 4 ++++ webview-ui/src/i18n/locales/ja/prompts.json | 3 ++- webview-ui/src/i18n/locales/ko/chat.json | 4 ++++ webview-ui/src/i18n/locales/ko/prompts.json | 3 ++- webview-ui/src/i18n/locales/nl/chat.json | 4 ++++ webview-ui/src/i18n/locales/nl/prompts.json | 3 ++- webview-ui/src/i18n/locales/pl/chat.json | 4 ++++ webview-ui/src/i18n/locales/pl/prompts.json | 3 ++- webview-ui/src/i18n/locales/pt-BR/chat.json | 4 ++++ webview-ui/src/i18n/locales/pt-BR/prompts.json | 3 ++- webview-ui/src/i18n/locales/ru/chat.json | 4 ++++ webview-ui/src/i18n/locales/ru/prompts.json | 3 ++- webview-ui/src/i18n/locales/tr/chat.json | 4 ++++ webview-ui/src/i18n/locales/tr/prompts.json | 3 ++- webview-ui/src/i18n/locales/vi/chat.json | 4 ++++ webview-ui/src/i18n/locales/vi/prompts.json | 3 ++- webview-ui/src/i18n/locales/zh-CN/chat.json | 4 ++++ webview-ui/src/i18n/locales/zh-CN/prompts.json | 3 ++- webview-ui/src/i18n/locales/zh-TW/chat.json | 4 ++++ webview-ui/src/i18n/locales/zh-TW/prompts.json | 3 ++- 35 files changed, 104 insertions(+), 18 deletions(-) diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 97f7873352..1f59e97975 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -492,5 +492,9 @@ "title": "Proveïdor ja no compatible", "message": "Aquest proveïdor ja no està disponible. Selecciona un proveïdor compatible per continuar.", "openSettings": "Obrir configuració" + }, + "webFetch": { + "wantsToFetch": "Zoo vol obtenir contingut web", + "didFetch": "Zoo ha obtingut contingut web" } } diff --git a/webview-ui/src/i18n/locales/ca/prompts.json b/webview-ui/src/i18n/locales/ca/prompts.json index 8df3376f83..5e3e130e07 100644 --- a/webview-ui/src/i18n/locales/ca/prompts.json +++ b/webview-ui/src/i18n/locales/ca/prompts.json @@ -26,7 +26,8 @@ "read": "Llegir fitxers", "edit": "Editar fitxers", "command": "Executar comandes", - "mcp": "Utilitzar MCP" + "mcp": "Utilitzar MCP", + "web": "Obtenir contingut web" }, "noTools": "Cap" }, diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 938e850852..742c77e0d8 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -492,5 +492,9 @@ "title": "Anbieter wird nicht mehr unterstützt", "message": "Dieser Anbieter ist nicht mehr verfügbar. Wähle einen unterstützten Anbieter aus, um fortzufahren.", "openSettings": "Einstellungen öffnen" + }, + "webFetch": { + "wantsToFetch": "Zoo möchte Webinhalte abrufen", + "didFetch": "Zoo hat Webinhalte abgerufen" } } diff --git a/webview-ui/src/i18n/locales/de/prompts.json b/webview-ui/src/i18n/locales/de/prompts.json index 551c217125..514a025655 100644 --- a/webview-ui/src/i18n/locales/de/prompts.json +++ b/webview-ui/src/i18n/locales/de/prompts.json @@ -26,7 +26,8 @@ "read": "Dateien lesen", "edit": "Dateien bearbeiten", "command": "Befehle ausführen", - "mcp": "MCP verwenden" + "mcp": "MCP verwenden", + "web": "Webinhalte abrufen" }, "noTools": "Keine" }, diff --git a/webview-ui/src/i18n/locales/en/prompts.json b/webview-ui/src/i18n/locales/en/prompts.json index 1494d31ba8..bd4f61d246 100644 --- a/webview-ui/src/i18n/locales/en/prompts.json +++ b/webview-ui/src/i18n/locales/en/prompts.json @@ -26,7 +26,8 @@ "read": "Read Files", "edit": "Edit Files", "command": "Run Commands", - "mcp": "Use MCP" + "mcp": "Use MCP", + "web": "Fetch Web Content" }, "noTools": "None" }, diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 964b53f914..c5c185c3c0 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -492,5 +492,9 @@ "title": "Proveedor ya no compatible", "message": "Este proveedor ya no está disponible. Selecciona un proveedor compatible para continuar.", "openSettings": "Abrir configuración" + }, + "webFetch": { + "wantsToFetch": "Zoo quiere obtener contenido web", + "didFetch": "Zoo obtuvo contenido web" } } diff --git a/webview-ui/src/i18n/locales/es/prompts.json b/webview-ui/src/i18n/locales/es/prompts.json index 626fb3284e..5e1bba4ed5 100644 --- a/webview-ui/src/i18n/locales/es/prompts.json +++ b/webview-ui/src/i18n/locales/es/prompts.json @@ -26,7 +26,8 @@ "read": "Leer archivos", "edit": "Editar archivos", "command": "Ejecutar comandos", - "mcp": "Usar MCP" + "mcp": "Usar MCP", + "web": "Obtener contenido web" }, "noTools": "Ninguna" }, diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 62cf59f8d1..5e25cd54aa 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -492,5 +492,9 @@ "title": "Fournisseur plus pris en charge", "message": "Ce fournisseur n'est plus disponible. Choisis un fournisseur pris en charge pour continuer.", "openSettings": "Ouvrir les paramètres" + }, + "webFetch": { + "wantsToFetch": "Zoo veut récupérer du contenu web", + "didFetch": "Zoo a récupéré du contenu web" } } diff --git a/webview-ui/src/i18n/locales/fr/prompts.json b/webview-ui/src/i18n/locales/fr/prompts.json index bd5967f7f0..470bd8a255 100644 --- a/webview-ui/src/i18n/locales/fr/prompts.json +++ b/webview-ui/src/i18n/locales/fr/prompts.json @@ -26,7 +26,8 @@ "read": "Lire les fichiers", "edit": "Modifier les fichiers", "command": "Exécuter des commandes", - "mcp": "Utiliser MCP" + "mcp": "Utiliser MCP", + "web": "Récupérer du contenu web" }, "noTools": "Aucun" }, diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index ef73399f97..a3df70af9a 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -492,5 +492,9 @@ "title": "प्रदाता अब समर्थित नहीं है", "message": "यह प्रदाता अब उपलब्ध नहीं है। जारी रखने के लिए कोई समर्थित प्रदाता चुनें।", "openSettings": "सेटिंग्स खोलें" + }, + "webFetch": { + "wantsToFetch": "Zoo वेब सामग्री प्राप्त करना चाहता है", + "didFetch": "Zoo ने वेब सामग्री प्राप्त की" } } diff --git a/webview-ui/src/i18n/locales/hi/prompts.json b/webview-ui/src/i18n/locales/hi/prompts.json index 6d3cb85d05..0e48d2611e 100644 --- a/webview-ui/src/i18n/locales/hi/prompts.json +++ b/webview-ui/src/i18n/locales/hi/prompts.json @@ -26,7 +26,8 @@ "read": "फाइलें पढ़ें", "edit": "फाइलें संपादित करें", "command": "कमांड्स चलाएँ", - "mcp": "MCP का उपयोग करें" + "mcp": "MCP का उपयोग करें", + "web": "वेब सामग्री प्राप्त करें" }, "noTools": "कोई नहीं" }, diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index fb35085551..3916ff757b 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -498,5 +498,9 @@ "title": "Penyedia tidak lagi didukung", "message": "Penyedia ini sudah tidak tersedia. Pilih penyedia yang didukung untuk melanjutkan.", "openSettings": "Buka Pengaturan" + }, + "webFetch": { + "wantsToFetch": "Zoo ingin mengambil konten web", + "didFetch": "Zoo telah mengambil konten web" } } diff --git a/webview-ui/src/i18n/locales/id/prompts.json b/webview-ui/src/i18n/locales/id/prompts.json index 395ca69cb4..bf0b945798 100644 --- a/webview-ui/src/i18n/locales/id/prompts.json +++ b/webview-ui/src/i18n/locales/id/prompts.json @@ -26,7 +26,8 @@ "read": "Baca File", "edit": "Edit File", "command": "Jalankan Perintah", - "mcp": "Gunakan MCP" + "mcp": "Gunakan MCP", + "web": "Ambil Konten Web" }, "noTools": "Tidak Ada" }, diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index cf79413bc5..27f8895d4d 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -492,5 +492,9 @@ "title": "Provider non più supportato", "message": "Questo provider non è più disponibile. Seleziona un provider supportato per continuare.", "openSettings": "Apri impostazioni" + }, + "webFetch": { + "wantsToFetch": "Zoo vuole recuperare contenuto web", + "didFetch": "Zoo ha recuperato contenuto web" } } diff --git a/webview-ui/src/i18n/locales/it/prompts.json b/webview-ui/src/i18n/locales/it/prompts.json index fd5c9518e8..a2c9efce2d 100644 --- a/webview-ui/src/i18n/locales/it/prompts.json +++ b/webview-ui/src/i18n/locales/it/prompts.json @@ -26,7 +26,8 @@ "read": "Leggi file", "edit": "Modifica file", "command": "Esegui comandi", - "mcp": "Usa MCP" + "mcp": "Usa MCP", + "web": "Recupera contenuto web" }, "noTools": "Nessuno" }, diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 5b853fcfc6..13f2946a02 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -492,5 +492,9 @@ "title": "プロバイダーのサポート終了", "message": "このプロバイダーは現在利用できません。続行するには、サポートされているプロバイダーを選択してください。", "openSettings": "設定を開く" + }, + "webFetch": { + "wantsToFetch": "Zooがウェブコンテンツを取得しようとしています", + "didFetch": "Zooがウェブコンテンツを取得しました" } } diff --git a/webview-ui/src/i18n/locales/ja/prompts.json b/webview-ui/src/i18n/locales/ja/prompts.json index 4d7c77e688..55ff607136 100644 --- a/webview-ui/src/i18n/locales/ja/prompts.json +++ b/webview-ui/src/i18n/locales/ja/prompts.json @@ -26,7 +26,8 @@ "read": "ファイルを読み込む", "edit": "ファイルを編集", "command": "コマンドを実行", - "mcp": "MCP を使用" + "mcp": "MCP を使用", + "web": "ウェブコンテンツを取得" }, "noTools": "なし" }, diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 8d1a4d24d9..006254e05d 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -492,5 +492,9 @@ "title": "공급자 지원 종료", "message": "이 공급자는 더 이상 사용할 수 없습니다. 계속하려면 지원되는 공급자를 선택하세요.", "openSettings": "설정 열기" + }, + "webFetch": { + "wantsToFetch": "Zoo가 웹 콘텐츠를 가져오려고 합니다", + "didFetch": "Zoo가 웹 콘텐츠를 가져왔습니다" } } diff --git a/webview-ui/src/i18n/locales/ko/prompts.json b/webview-ui/src/i18n/locales/ko/prompts.json index 204547e4c3..6b46a60c79 100644 --- a/webview-ui/src/i18n/locales/ko/prompts.json +++ b/webview-ui/src/i18n/locales/ko/prompts.json @@ -26,7 +26,8 @@ "read": "파일 읽기", "edit": "파일 편집", "command": "명령 실행", - "mcp": "MCP 사용" + "mcp": "MCP 사용", + "web": "웹 콘텐츠 가져오기" }, "noTools": "없음" }, diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 572a7d3b33..99e01cb339 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -492,5 +492,9 @@ "title": "Provider wordt niet meer ondersteund", "message": "Deze provider is niet meer beschikbaar. Selecteer een ondersteunde provider om door te gaan.", "openSettings": "Instellingen openen" + }, + "webFetch": { + "wantsToFetch": "Zoo wil webinhoud ophalen", + "didFetch": "Zoo heeft webinhoud opgehaald" } } diff --git a/webview-ui/src/i18n/locales/nl/prompts.json b/webview-ui/src/i18n/locales/nl/prompts.json index 3a0a7d5445..df4bbf524c 100644 --- a/webview-ui/src/i18n/locales/nl/prompts.json +++ b/webview-ui/src/i18n/locales/nl/prompts.json @@ -26,7 +26,8 @@ "read": "Bestanden lezen", "edit": "Bestanden bewerken", "command": "Commando's uitvoeren", - "mcp": "MCP gebruiken" + "mcp": "MCP gebruiken", + "web": "Webinhoud ophalen" }, "noTools": "Geen" }, diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 5ff7a919c4..a34a5f5f81 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -492,5 +492,9 @@ "title": "Dostawca nie jest już obsługiwany", "message": "Ten dostawca nie jest już dostępny. Wybierz obsługiwanego dostawcę, aby kontynuować.", "openSettings": "Otwórz ustawienia" + }, + "webFetch": { + "wantsToFetch": "Zoo chce pobrać treść z sieci", + "didFetch": "Zoo pobrał treść z sieci" } } diff --git a/webview-ui/src/i18n/locales/pl/prompts.json b/webview-ui/src/i18n/locales/pl/prompts.json index 02d72ff510..2b3935c1a5 100644 --- a/webview-ui/src/i18n/locales/pl/prompts.json +++ b/webview-ui/src/i18n/locales/pl/prompts.json @@ -26,7 +26,8 @@ "read": "Czytaj pliki", "edit": "Edytuj pliki", "command": "Uruchamiaj polecenia", - "mcp": "Używaj MCP" + "mcp": "Używaj MCP", + "web": "Pobierz treść z sieci" }, "noTools": "Brak" }, diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 96dff96e81..52ea71a8e6 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -492,5 +492,9 @@ "title": "Provedor não é mais suportado", "message": "Este provedor não está mais disponível. Selecione um provedor compatível para continuar.", "openSettings": "Abrir configurações" + }, + "webFetch": { + "wantsToFetch": "Zoo quer buscar conteúdo web", + "didFetch": "Zoo buscou conteúdo web" } } diff --git a/webview-ui/src/i18n/locales/pt-BR/prompts.json b/webview-ui/src/i18n/locales/pt-BR/prompts.json index 3ccc978bd8..6de5069aa1 100644 --- a/webview-ui/src/i18n/locales/pt-BR/prompts.json +++ b/webview-ui/src/i18n/locales/pt-BR/prompts.json @@ -26,7 +26,8 @@ "read": "Ler arquivos", "edit": "Editar arquivos", "command": "Executar comandos", - "mcp": "Usar MCP" + "mcp": "Usar MCP", + "web": "Buscar conteúdo web" }, "noTools": "Nenhuma" }, diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 22df1da2d9..41fe8c9ec1 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -493,5 +493,9 @@ "title": "Провайдер больше не поддерживается", "message": "Этот провайдер больше недоступен. Выберите поддерживаемого провайдера, чтобы продолжить.", "openSettings": "Открыть настройки" + }, + "webFetch": { + "wantsToFetch": "Zoo хочет получить веб-контент", + "didFetch": "Zoo получил веб-контент" } } diff --git a/webview-ui/src/i18n/locales/ru/prompts.json b/webview-ui/src/i18n/locales/ru/prompts.json index 1863bebf9d..2674114518 100644 --- a/webview-ui/src/i18n/locales/ru/prompts.json +++ b/webview-ui/src/i18n/locales/ru/prompts.json @@ -26,7 +26,8 @@ "read": "Чтение файлов", "edit": "Редактирование файлов", "command": "Выполнять команды", - "mcp": "Использовать MCP" + "mcp": "Использовать MCP", + "web": "Получить веб-контент" }, "noTools": "Отсутствуют" }, diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 10c1ffdb6a..3807715fd4 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -493,5 +493,9 @@ "title": "Sağlayıcı artık desteklenmiyor", "message": "Bu sağlayıcı artık kullanılamıyor. Devam etmek için desteklenen bir sağlayıcı seçin.", "openSettings": "Ayarları aç" + }, + "webFetch": { + "wantsToFetch": "Zoo web içeriği getirmek istiyor", + "didFetch": "Zoo web içeriği getirdi" } } diff --git a/webview-ui/src/i18n/locales/tr/prompts.json b/webview-ui/src/i18n/locales/tr/prompts.json index e0288355c2..afbd0e40cf 100644 --- a/webview-ui/src/i18n/locales/tr/prompts.json +++ b/webview-ui/src/i18n/locales/tr/prompts.json @@ -26,7 +26,8 @@ "read": "Dosyaları Oku", "edit": "Dosyaları Düzenle", "command": "Komutları Çalıştır", - "mcp": "MCP Kullan" + "mcp": "MCP Kullan", + "web": "Web İçeriği Getir" }, "noTools": "Yok" }, diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index e386c6ab80..cb7a9ce74d 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -493,5 +493,9 @@ "title": "Nhà cung cấp không còn được hỗ trợ", "message": "Nhà cung cấp này không còn khả dụng. Hãy chọn một nhà cung cấp được hỗ trợ để tiếp tục.", "openSettings": "Mở cài đặt" + }, + "webFetch": { + "wantsToFetch": "Zoo muốn lấy nội dung web", + "didFetch": "Zoo đã lấy nội dung web" } } diff --git a/webview-ui/src/i18n/locales/vi/prompts.json b/webview-ui/src/i18n/locales/vi/prompts.json index ab5dbb899c..b3048694a7 100644 --- a/webview-ui/src/i18n/locales/vi/prompts.json +++ b/webview-ui/src/i18n/locales/vi/prompts.json @@ -26,7 +26,8 @@ "read": "Đọc tệp", "edit": "Chỉnh sửa tệp", "command": "Chạy lệnh", - "mcp": "Sử dụng MCP" + "mcp": "Sử dụng MCP", + "web": "Lấy nội dung web" }, "noTools": "Không có" }, diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 28a35692be..cb4392e902 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -493,5 +493,9 @@ "title": "供应商不再受支持", "message": "此提供商已不可用。请选择受支持的提供商以继续。", "openSettings": "打开设置" + }, + "webFetch": { + "wantsToFetch": "Zoo 想要获取网页内容", + "didFetch": "Zoo 已获取网页内容" } } diff --git a/webview-ui/src/i18n/locales/zh-CN/prompts.json b/webview-ui/src/i18n/locales/zh-CN/prompts.json index 84255d8868..aa42fa501e 100644 --- a/webview-ui/src/i18n/locales/zh-CN/prompts.json +++ b/webview-ui/src/i18n/locales/zh-CN/prompts.json @@ -26,7 +26,8 @@ "read": "读取文件", "edit": "编辑文件", "command": "运行命令", - "mcp": "MCP服务" + "mcp": "MCP服务", + "web": "获取网页内容" }, "noTools": "无" }, diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 090224b971..11ebf40bd6 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -483,5 +483,9 @@ "title": "供應商不再受支援", "message": "此供應商已不可用。請選擇受支援的供應商以繼續。", "openSettings": "開啟設定" + }, + "webFetch": { + "wantsToFetch": "Zoo 想要擷取網頁內容", + "didFetch": "Zoo 已擷取網頁內容" } } diff --git a/webview-ui/src/i18n/locales/zh-TW/prompts.json b/webview-ui/src/i18n/locales/zh-TW/prompts.json index 962a4bf42e..69dd33f257 100644 --- a/webview-ui/src/i18n/locales/zh-TW/prompts.json +++ b/webview-ui/src/i18n/locales/zh-TW/prompts.json @@ -26,7 +26,8 @@ "read": "讀取檔案", "edit": "編輯檔案", "command": "執行命令", - "mcp": "使用 MCP" + "mcp": "使用 MCP", + "web": "擷取網頁內容" }, "noTools": "無" },