refactor: remove browser use functionality entirely (#11392)

This commit is contained in:
Hannes Rudolph 2026-02-11 18:11:21 -07:00 committed by GitHub
parent f54f224a26
commit fa9dff4a06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
165 changed files with 500 additions and 8714 deletions

View file

@ -93,13 +93,6 @@ describe("detectAgentState", () => {
expect(state.requiredAction).toBe("answer")
})
it("should detect waiting for browser_action_launch approval", () => {
const messages = [createMessage({ type: "ask", ask: "browser_action_launch", partial: false })]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
expect(state.requiredAction).toBe("approve")
})
it("should detect waiting for use_mcp_server approval", () => {
const messages = [createMessage({ type: "ask", ask: "use_mcp_server", partial: false })]
const state = detectAgentState(messages)
@ -202,7 +195,6 @@ describe("Type Guards", () => {
expect(isInteractiveAsk("tool")).toBe(true)
expect(isInteractiveAsk("command")).toBe(true)
expect(isInteractiveAsk("followup")).toBe(true)
expect(isInteractiveAsk("browser_action_launch")).toBe(true)
expect(isInteractiveAsk("use_mcp_server")).toBe(true)
})

View file

@ -116,7 +116,7 @@ export enum AgentLoopState {
*/
export type RequiredAction =
| "none" // No action needed (running/streaming)
| "approve" // Can approve/reject (tool, command, browser, mcp)
| "approve" // Can approve/reject (tool, command, mcp)
| "answer" // Need to answer a question (followup)
| "retry_or_new_task" // Can retry or start new task (api_req_failed)
| "proceed_or_new_task" // Can proceed or start new task (mistake_limit)
@ -221,7 +221,6 @@ function getRequiredAction(ask: ClineAsk): RequiredAction {
return "answer"
case "command":
case "tool":
case "browser_action_launch":
case "use_mcp_server":
return "approve"
case "command_output":
@ -264,8 +263,6 @@ function getStateDescription(state: AgentLoopState, ask?: ClineAsk): string {
return "Agent wants to execute a command. Approve or reject."
case "tool":
return "Agent wants to perform a file operation. Approve or reject."
case "browser_action_launch":
return "Agent wants to use the browser. Approve or reject."
case "use_mcp_server":
return "Agent wants to use an MCP server. Approve or reject."
default:

View file

@ -244,7 +244,7 @@ export class AskDispatcher {
}
/**
* Handle interactive asks (followup, command, tool, browser_action_launch, use_mcp_server).
* Handle interactive asks (followup, command, tool, use_mcp_server).
* These require user approval or input.
*/
private async handleInteractiveAsk(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
@ -258,9 +258,6 @@ export class AskDispatcher {
case "tool":
return await this.handleToolApproval(ts, text)
case "browser_action_launch":
return await this.handleBrowserApproval(ts, text)
case "use_mcp_server":
return await this.handleMcpApproval(ts, text)
@ -444,32 +441,6 @@ export class AskDispatcher {
}
}
/**
* Handle browser action approval.
*/
private async handleBrowserApproval(ts: number, text: string): Promise<AskHandleResult> {
this.outputManager.output("\n[browser action request]")
if (text) {
this.outputManager.output(` Action: ${text}`)
}
this.outputManager.markDisplayed(ts, text || "", false)
if (this.nonInteractive) {
// Auto-approved by extension settings
return { handled: true }
}
try {
const approved = await this.promptManager.promptForYesNo("Allow browser action? (y/n): ")
this.sendApprovalResponse(approved)
return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
} catch {
this.outputManager.output("[Defaulting to: no]")
this.sendApprovalResponse(false)
return { handled: true, response: "noButtonClicked" }
}
}
/**
* Handle MCP server access approval.
*/

View file

@ -214,7 +214,6 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
const baseSettings: RooCodeSettings = {
mode: this.options.mode,
commandExecutionTimeout: 30,
browserToolEnabled: false,
enableCheckpoints: false,
...getProviderSettings(this.options.provider, this.options.apiKey, this.options.model),
}
@ -227,7 +226,6 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
alwaysAllowWrite: true,
alwaysAllowWriteOutsideWorkspace: true,
alwaysAllowWriteProtected: true,
alwaysAllowBrowser: true,
alwaysAllowMcp: true,
alwaysAllowModeSwitch: true,
alwaysAllowSubtasks: true,

View file

@ -258,15 +258,6 @@ export class JsonEventEmitter {
break
}
case "browser_action":
case "browser_action_result":
this.emitEvent({
type: "tool_result",
subtype: "browser",
tool_result: { name: "browser_action", output: msg.text },
})
break
case "mcp_server_response":
this.emitEvent({
type: "tool_result",
@ -336,15 +327,6 @@ export class JsonEventEmitter {
})
break
case "browser_action_launch":
this.emitEvent({
type: "tool_use",
id: msg.ts,
subtype: "browser",
tool_use: { name: "browser_action", input: { raw: msg.text } },
})
break
case "use_mcp_server":
this.emitEvent({
type: "tool_use",

View file

@ -10,14 +10,13 @@ import { getToolRenderer } from "./tools/index.js"
/**
* Tool categories for styling
*/
type ToolCategory = "file" | "directory" | "search" | "command" | "browser" | "mode" | "completion" | "other"
type ToolCategory = "file" | "directory" | "search" | "command" | "mode" | "completion" | "other"
function getToolCategory(toolName: string): ToolCategory {
const fileTools = ["readFile", "read_file", "writeToFile", "write_to_file", "applyDiff", "apply_diff"]
const dirTools = ["listFiles", "list_files", "listFilesRecursive", "listFilesTopLevel"]
const searchTools = ["searchFiles", "search_files"]
const commandTools = ["executeCommand", "execute_command"]
const browserTools = ["browserAction", "browser_action"]
const modeTools = ["switchMode", "switch_mode", "newTask", "new_task"]
const completionTools = ["attemptCompletion", "attempt_completion", "askFollowupQuestion", "ask_followup_question"]
@ -25,7 +24,6 @@ function getToolCategory(toolName: string): ToolCategory {
if (dirTools.includes(toolName)) return "directory"
if (searchTools.includes(toolName)) return "search"
if (commandTools.includes(toolName)) return "command"
if (browserTools.includes(toolName)) return "browser"
if (modeTools.includes(toolName)) return "mode"
if (completionTools.includes(toolName)) return "completion"
return "other"
@ -39,7 +37,6 @@ const CATEGORY_COLORS: Record<ToolCategory, string> = {
directory: theme.toolHeader,
search: theme.warningColor,
command: theme.successColor,
browser: theme.focusColor,
mode: theme.userHeader,
completion: theme.successColor,
other: theme.toolHeader,

View file

@ -1,87 +0,0 @@
import { Box, Text } from "ink"
import * as theme from "../../theme.js"
import { Icon } from "../Icon.js"
import type { ToolRendererProps } from "./types.js"
import { getToolDisplayName, getToolIconName } from "./utils.js"
const ACTION_LABELS: Record<string, string> = {
launch: "Launch Browser",
click: "Click",
hover: "Hover",
type: "Type Text",
press: "Press Key",
scroll_down: "Scroll Down",
scroll_up: "Scroll Up",
resize: "Resize Window",
close: "Close Browser",
screenshot: "Take Screenshot",
}
export function BrowserTool({ toolData }: ToolRendererProps) {
const iconName = getToolIconName(toolData.tool)
const displayName = getToolDisplayName(toolData.tool)
const action = toolData.action || ""
const url = toolData.url || ""
const coordinate = toolData.coordinate || ""
const content = toolData.content || "" // May contain text for type action.
const actionLabel = ACTION_LABELS[action] || action
return (
<Box flexDirection="column" paddingX={1}>
{/* Header */}
<Box>
<Icon name={iconName} color={theme.toolHeader} />
<Text bold color={theme.toolHeader}>
{" "}
{displayName}
</Text>
{action && (
<Text color={theme.focusColor} bold>
{" "}
{actionLabel}
</Text>
)}
</Box>
{/* Action details */}
<Box flexDirection="column" marginLeft={2}>
{/* URL for launch action */}
{url && (
<Box>
<Text color={theme.dimText}>url: </Text>
<Text color={theme.text} underline>
{url}
</Text>
</Box>
)}
{/* Coordinates for click/hover actions */}
{coordinate && (
<Box>
<Text color={theme.dimText}>at: </Text>
<Text color={theme.warningColor}>{coordinate}</Text>
</Box>
)}
{/* Text content for type action */}
{content && action === "type" && (
<Box>
<Text color={theme.dimText}>text: </Text>
<Text color={theme.text}>"{content}"</Text>
</Box>
)}
{/* Key for press action */}
{content && action === "press" && (
<Box>
<Text color={theme.dimText}>key: </Text>
<Text color={theme.successColor}>{content}</Text>
</Box>
)}
</Box>
</Box>
)
}

View file

@ -15,7 +15,6 @@ import { FileReadTool } from "./FileReadTool.js"
import { FileWriteTool } from "./FileWriteTool.js"
import { SearchTool } from "./SearchTool.js"
import { CommandTool } from "./CommandTool.js"
import { BrowserTool } from "./BrowserTool.js"
import { ModeTool } from "./ModeTool.js"
import { CompletionTool } from "./CompletionTool.js"
import { GenericTool } from "./GenericTool.js"
@ -32,7 +31,6 @@ export { FileReadTool } from "./FileReadTool.js"
export { FileWriteTool } from "./FileWriteTool.js"
export { SearchTool } from "./SearchTool.js"
export { CommandTool } from "./CommandTool.js"
export { BrowserTool } from "./BrowserTool.js"
export { ModeTool } from "./ModeTool.js"
export { CompletionTool } from "./CompletionTool.js"
export { GenericTool } from "./GenericTool.js"
@ -45,7 +43,6 @@ const CATEGORY_RENDERERS: Record<string, React.FC<ToolRendererProps>> = {
"file-write": FileWriteTool,
search: SearchTool,
command: CommandTool,
browser: BrowserTool,
mode: ModeTool,
completion: CompletionTool,
other: GenericTool,

View file

@ -5,15 +5,7 @@ export interface ToolRendererProps {
rawContent?: string
}
export type ToolCategory =
| "file-read"
| "file-write"
| "search"
| "command"
| "browser"
| "mode"
| "completion"
| "other"
export type ToolCategory = "file-read" | "file-write" | "search" | "command" | "mode" | "completion" | "other"
export function getToolCategory(toolName: string): ToolCategory {
const fileReadTools = ["readFile", "read_file", "skill", "listFilesTopLevel", "listFilesRecursive", "list_files"]
@ -29,7 +21,6 @@ export function getToolCategory(toolName: string): ToolCategory {
const searchTools = ["searchFiles", "search_files", "codebaseSearch", "codebase_search"]
const commandTools = ["execute_command", "executeCommand"]
const browserTools = ["browser_action", "browserAction"]
const modeTools = ["switchMode", "switch_mode", "newTask", "new_task", "finishTask"]
const completionTools = ["attempt_completion", "attemptCompletion", "ask_followup_question", "askFollowupQuestion"]
@ -37,7 +28,6 @@ export function getToolCategory(toolName: string): ToolCategory {
if (fileWriteTools.includes(toolName)) return "file-write"
if (searchTools.includes(toolName)) return "search"
if (commandTools.includes(toolName)) return "command"
if (browserTools.includes(toolName)) return "browser"
if (modeTools.includes(toolName)) return "mode"
if (completionTools.includes(toolName)) return "completion"
return "other"

View file

@ -73,10 +73,6 @@ export function getToolDisplayName(toolName: string): string {
execute_command: "Execute Command",
executeCommand: "Execute Command",
// Browser operations
browser_action: "Browser Action",
browserAction: "Browser Action",
// Mode operations
switchMode: "Switch Mode",
switch_mode: "Switch Mode",
@ -129,10 +125,6 @@ export function getToolIconName(toolName: string): IconName {
execute_command: "terminal",
executeCommand: "terminal",
// Browser operations
browser_action: "browser",
browserAction: "browser",
// Mode operations
switchMode: "switch",
switch_mode: "switch",

View file

@ -40,14 +40,6 @@ export interface ToolData {
/** Command output */
output?: string
// Browser operation fields
/** Browser action type */
action?: string
/** Browser URL */
url?: string
/** Click/hover coordinates */
coordinate?: string
// Batch operation fields
/** Batch file reads */
batchFiles?: Array<{

View file

@ -57,17 +57,6 @@ export function extractToolData(toolInfo: Record<string, unknown>): ToolData {
toolData.output = toolInfo.output as string
}
// Extract browser-related fields
if (toolInfo.action !== undefined) {
toolData.action = toolInfo.action as string
}
if (toolInfo.url !== undefined) {
toolData.url = toolInfo.url as string
}
if (toolInfo.coordinate !== undefined) {
toolData.coordinate = toolInfo.coordinate as string
}
// Extract batch file operations
if (Array.isArray(toolInfo.files)) {
toolData.batchFiles = (toolInfo.files as Array<Record<string, unknown>>).map((f) => ({
@ -165,12 +154,6 @@ export function formatToolOutput(toolInfo: Record<string, unknown>): string {
return `📁 ${listPath || "."}${recursive ? " (recursive)" : ""}`
}
case "browser_action": {
const action = toolInfo.action as string
const url = toolInfo.url as string
return `🌐 ${action || "action"}${url ? `: ${url}` : ""}`
}
case "attempt_completion": {
const result = toolInfo.result as string
if (result) {
@ -248,12 +231,6 @@ export function formatToolAskMessage(toolInfo: Record<string, unknown>): string
return `Apply changes to: ${diffPath || "(no path)"}`
}
case "browser_action": {
const action = toolInfo.action as string
const url = toolInfo.url as string
return `Browser: ${action || "action"}${url ? ` - ${url}` : ""}`
}
default: {
const params = Object.entries(toolInfo)
.filter(([key]) => key !== "tool")

View file

@ -138,8 +138,8 @@ describe("copyRun", () => {
const toolError3 = await createToolError({
runId: sourceRunId,
taskId: null,
toolName: "browser_action",
error: "Browser connection timeout",
toolName: "write_to_file",
error: "Write timeout",
})
sourceToolErrorIds.push(toolError3.id)
@ -234,8 +234,8 @@ describe("copyRun", () => {
expect(taskToolErrors).toHaveLength(2)
expect(runToolErrors).toHaveLength(1)
const browserError = runToolErrors.find((te) => te.toolName === "browser_action")!
expect(browserError.error).toBe("Browser connection timeout")
const writeError = runToolErrors.find((te) => te.toolName === "write_to_file")!
expect(writeError.error).toBe("Write timeout")
await db.delete(schema.toolErrors).where(eq(schema.toolErrors.runId, newRunId))
await db.delete(schema.tasks).where(eq(schema.tasks.runId, newRunId))

View file

@ -487,11 +487,11 @@ describe("userSettingsConfigSchema with llmEnhancedFeaturesEnabled", () => {
describe("organizationDefaultSettingsSchema with disabledTools", () => {
it("should accept disabledTools as an array of valid tool names", () => {
const input: OrganizationDefaultSettings = {
disabledTools: ["execute_command", "browser_action"],
disabledTools: ["execute_command", "write_to_file"],
}
const result = organizationDefaultSettingsSchema.safeParse(input)
expect(result.success).toBe(true)
expect(result.data?.disabledTools).toEqual(["execute_command", "browser_action"])
expect(result.data?.disabledTools).toEqual(["execute_command", "write_to_file"])
})
it("should accept empty disabledTools array", () => {

View file

@ -102,7 +102,6 @@ export const globalSettingsSchema = z.object({
alwaysAllowWriteOutsideWorkspace: z.boolean().optional(),
alwaysAllowWriteProtected: z.boolean().optional(),
writeDelayMs: z.number().min(0).optional(),
alwaysAllowBrowser: z.boolean().optional(),
requestDelaySeconds: z.number().optional(),
alwaysAllowMcp: z.boolean().optional(),
alwaysAllowModeSwitch: z.boolean().optional(),
@ -148,13 +147,6 @@ export const globalSettingsSchema = z.object({
*/
maxDiagnosticMessages: z.number().optional(),
browserToolEnabled: z.boolean().optional(),
browserViewportSize: z.string().optional(),
screenshotQuality: z.number().optional(),
remoteBrowserEnabled: z.boolean().optional(),
remoteBrowserHost: z.string().optional(),
cachedChromeHostUrl: z.string().optional(),
enableCheckpoints: z.boolean().optional(),
checkpointTimeout: z
.number()
@ -338,7 +330,6 @@ export const EVALS_SETTINGS: RooCodeSettings = {
alwaysAllowWriteOutsideWorkspace: false,
alwaysAllowWriteProtected: false,
writeDelayMs: 1000,
alwaysAllowBrowser: true,
requestDelaySeconds: 10,
alwaysAllowMcp: true,
alwaysAllowModeSwitch: true,
@ -351,11 +342,6 @@ export const EVALS_SETTINGS: RooCodeSettings = {
commandTimeoutAllowlist: [],
preventCompletionWithOpenTodos: false,
browserToolEnabled: false,
browserViewportSize: "900x600",
screenshotQuality: 75,
remoteBrowserEnabled: false,
ttsEnabled: false,
ttsSpeed: 1,
soundEnabled: false,

View file

@ -21,7 +21,6 @@ import { z } from "zod"
* - `resume_task`: Confirmation needed to resume a previously paused task
* - `resume_completed_task`: Confirmation needed to resume a task that was already marked as completed
* - `mistake_limit_reached`: Too many errors encountered, needs user guidance on how to proceed
* - `browser_action_launch`: Permission to open or interact with a browser
* - `use_mcp_server`: Permission to use Model Context Protocol (MCP) server functionality
* - `auto_approval_max_req_reached`: Auto-approval limit has been reached, manual approval required
*/
@ -35,7 +34,6 @@ export const clineAsks = [
"resume_task",
"resume_completed_task",
"mistake_limit_reached",
"browser_action_launch",
"use_mcp_server",
"auto_approval_max_req_reached",
] as const
@ -83,13 +81,7 @@ export function isResumableAsk(ask: ClineAsk): ask is ResumableAsk {
* Asks that put the task into an "user interaction required" state.
*/
export const interactiveAsks = [
"followup",
"command",
"tool",
"browser_action_launch",
"use_mcp_server",
] as const satisfies readonly ClineAsk[]
export const interactiveAsks = ["followup", "command", "tool", "use_mcp_server"] as const satisfies readonly ClineAsk[]
export type InteractiveAsk = (typeof interactiveAsks)[number]
@ -138,8 +130,6 @@ export function isNonBlockingAsk(ask: ClineAsk): ask is NonBlockingAsk {
* - `user_feedback_diff`: Diff-formatted feedback from user showing requested changes
* - `command_output`: Output from an executed command
* - `shell_integration_warning`: Warning about shell integration issues or limitations
* - `browser_action`: Action performed in the browser
* - `browser_action_result`: Result of a browser action
* - `mcp_server_request_started`: MCP server request has been initiated
* - `mcp_server_response`: Response received from MCP server
* - `subtask_result`: Result of a completed subtask
@ -167,9 +157,6 @@ export const clineSays = [
"user_feedback_diff",
"command_output",
"shell_integration_warning",
"browser_action",
"browser_action_result",
"browser_session_status",
"mcp_server_request_started",
"mcp_server_response",
"subtask_result",

View file

@ -1,6 +1,6 @@
import { z } from "zod"
import { toolGroupsSchema } from "./tool.js"
import { deprecatedToolGroups, toolGroupsSchema } from "./tool.js"
/**
* GroupOptions
@ -42,7 +42,24 @@ export type GroupEntry = z.infer<typeof groupEntrySchema>
* ModeConfig
*/
const groupEntryArraySchema = z.array(groupEntrySchema).refine(
/**
* Checks if a group entry references a deprecated tool group.
* Handles both string entries ("browser") and tuple entries (["browser", { ... }]).
*/
function isDeprecatedGroupEntry(entry: unknown): boolean {
if (typeof entry === "string") {
return deprecatedToolGroups.includes(entry)
}
if (Array.isArray(entry) && entry.length >= 1 && typeof entry[0] === "string") {
return deprecatedToolGroups.includes(entry[0])
}
return false
}
/**
* Raw schema for validating group entries after deprecated groups are stripped.
*/
const rawGroupEntryArraySchema = z.array(groupEntrySchema).refine(
(groups) => {
const seen = new Set()
@ -61,6 +78,21 @@ const groupEntryArraySchema = z.array(groupEntrySchema).refine(
{ message: "Duplicate groups are not allowed" },
)
/**
* Schema for mode group entries. Preprocesses the input to strip deprecated
* tool groups (e.g., "browser") before validation, ensuring backward compatibility
* with older user configs.
*
* The type assertion to `z.ZodType<GroupEntry[], z.ZodTypeDef, GroupEntry[]>` is
* required because `z.preprocess` erases the input type to `unknown`, which
* propagates through `modeConfigSchema → rooCodeSettingsSchema → createRunSchema`
* and breaks `zodResolver` generic inference in downstream consumers (e.g., web-evals).
*/
export const groupEntryArraySchema = z.preprocess((val) => {
if (!Array.isArray(val)) return val
return val.filter((entry) => !isDeprecatedGroupEntry(entry))
}, rawGroupEntryArraySchema) as z.ZodType<GroupEntry[], z.ZodTypeDef, GroupEntry[]>
export const modeConfigSchema = z.object({
slug: z.string().regex(/^[a-zA-Z0-9-]+$/, "Slug must contain only letters numbers and dashes"),
name: z.string().min(1, "Name is required"),
@ -142,7 +174,7 @@ export const DEFAULT_MODES: readonly ModeConfig[] = [
whenToUse:
"Use this mode when you need to plan, design, or strategize before implementation. Perfect for breaking down complex problems, creating technical specifications, designing system architecture, or brainstorming solutions before coding.",
description: "Plan and design before implementation",
groups: ["read", ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }], "browser", "mcp"],
groups: ["read", ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }], "mcp"],
customInstructions:
"1. Do some information gathering (using provided tools) to get more context about the task.\n\n2. You should also ask the user clarifying questions to get a better understanding of the task.\n\n3. Once you've gained more context about the user's request, break down the task into clear, actionable steps and create a todo list using the `update_todo_list` tool. Each todo item should be:\n - Specific and actionable\n - Listed in logical execution order\n - Focused on a single, well-defined outcome\n - Clear enough that another mode could execute it independently\n\n **Note:** If the `update_todo_list` tool is not available, write the plan to a markdown file (e.g., `plan.md` or `todo.md`) instead.\n\n4. As you gather more information or discover new requirements, update the todo list to reflect the current understanding of what needs to be accomplished.\n\n5. Ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and refine the todo list.\n\n6. Include Mermaid diagrams if they help clarify complex workflows or system architecture. Please avoid using double quotes (\"\") and parentheses () inside square brackets ([]) in Mermaid diagrams, as this can cause parsing errors.\n\n7. Use the switch_mode tool to request that the user switch to another mode to implement the solution.\n\n**IMPORTANT: Focus on creating clear, actionable todo lists rather than lengthy markdown documents. Use the todo list as your primary planning tool to track and organize the work that needs to be done.**\n\n**CRITICAL: Never provide level of effort time estimates (e.g., hours, days, weeks) for tasks. Focus solely on breaking down the work into clear, actionable steps without estimating how long they will take.**\n\nUnless told otherwise, if you want to save a plan file, put it in the /plans directory",
},
@ -154,7 +186,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", "browser", "command", "mcp"],
groups: ["read", "edit", "command", "mcp"],
},
{
slug: "ask",
@ -164,7 +196,7 @@ export const DEFAULT_MODES: readonly ModeConfig[] = [
whenToUse:
"Use this mode when you need explanations, documentation, or answers to technical questions. Best for understanding concepts, analyzing existing code, getting recommendations, or learning about technologies without making changes.",
description: "Get answers and explanations",
groups: ["read", "browser", "mcp"],
groups: ["read", "mcp"],
customInstructions:
"You can analyze code, explain concepts, and access external resources. Always answer the user's questions thoroughly, and do not switch to implementing code unless explicitly requested by the user. Include Mermaid diagrams when they clarify your response.",
},
@ -176,7 +208,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", "browser", "command", "mcp"],
groups: ["read", "edit", "command", "mcp"],
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.",
},

View file

@ -102,15 +102,6 @@ export interface Size {
height: number
}
export interface BrowserActionParams {
action: "launch" | "click" | "hover" | "type" | "scroll_down" | "scroll_up" | "resize" | "close" | "screenshot"
url?: string
coordinate?: Coordinate
size?: Size
text?: string
path?: string
}
export interface GenerateImageParams {
prompt: string
path: string

View file

@ -4,10 +4,17 @@ import { z } from "zod"
* ToolGroup
*/
export const toolGroups = ["read", "edit", "browser", "command", "mcp", "modes"] as const
export const toolGroups = ["read", "edit", "command", "mcp", "modes"] as const
export const toolGroupsSchema = z.enum(toolGroups)
/**
* Tool groups that have been removed but may still exist in user config files.
* Used by schema preprocessing to silently strip these before validation,
* preventing errors for users with older configs.
*/
export const deprecatedToolGroups: readonly string[] = ["browser"]
export type ToolGroup = z.infer<typeof toolGroupsSchema>
/**
@ -27,7 +34,6 @@ export const toolNames = [
"apply_patch",
"search_files",
"list_files",
"browser_action",
"use_mcp_tool",
"access_mcp_resource",
"ask_followup_question",

View file

@ -59,9 +59,6 @@ export interface ExtensionMessage {
| "deleteCustomModeCheck"
| "currentCheckpointUpdated"
| "checkpointInitWarning"
| "browserToolEnabled"
| "browserConnectionResult"
| "remoteBrowserEnabled"
| "ttsStart"
| "ttsStop"
| "fileSearchResults"
@ -92,8 +89,6 @@ export interface ExtensionMessage {
| "dismissedUpsells"
| "organizationSwitchResult"
| "interactionRequired"
| "browserSessionUpdate"
| "browserSessionNavigate"
| "customToolsResult"
| "modes"
| "taskWithAggregatedCosts"
@ -180,9 +175,6 @@ export interface ExtensionMessage {
queuedMessages?: QueuedMessage[]
list?: string[] // For dismissedUpsells
organizationId?: string | null // For organizationSwitchResult
browserSessionMessages?: ClineMessage[] // For browser session panel updates
isBrowserSessionActive?: boolean // For browser session panel updates
stepIndex?: number // For browserSessionNavigate: the target step index to display
tools?: SerializedCustomToolDefinition[] // For customToolsResult
modes?: { slug: string; name: string }[] // For modes response
skills?: SkillMetadata[] // For skills response
@ -264,7 +256,6 @@ export type ExtensionState = Pick<
| "alwaysAllowWrite"
| "alwaysAllowWriteOutsideWorkspace"
| "alwaysAllowWriteProtected"
| "alwaysAllowBrowser"
| "alwaysAllowMcp"
| "alwaysAllowModeSwitch"
| "alwaysAllowSubtasks"
@ -275,12 +266,6 @@ export type ExtensionState = Pick<
| "deniedCommands"
| "allowedMaxRequests"
| "allowedMaxCost"
| "browserToolEnabled"
| "browserViewportSize"
| "screenshotQuality"
| "remoteBrowserEnabled"
| "cachedChromeHostUrl"
| "remoteBrowserHost"
| "ttsEnabled"
| "ttsSpeed"
| "soundEnabled"
@ -367,8 +352,6 @@ export type ExtensionState = Pick<
organizationAllowList: OrganizationAllowList
organizationSettingsVersion?: number
isBrowserSessionActive: boolean // Actual browser session state
autoCondenseContext: boolean
autoCondenseContextPercent: number
marketplaceItems?: MarketplaceItem[]
@ -508,8 +491,6 @@ export interface WebviewMessage {
| "deleteMcpServer"
| "codebaseIndexEnabled"
| "telemetrySetting"
| "testBrowserConnection"
| "browserConnectionResult"
| "searchFiles"
| "toggleApiConfigPin"
| "hasOpenedModeSelector"
@ -566,11 +547,6 @@ export interface WebviewMessage {
| "allowedCommands"
| "getTaskWithAggregatedCosts"
| "deniedCommands"
| "killBrowserSession"
| "openBrowserSessionPanel"
| "showBrowserSessionPanelAtStep"
| "refreshBrowserSessionPanel"
| "browserPanelDidLaunch"
| "openDebugApiHistory"
| "openDebugUiHistory"
| "downloadErrorDiagnostics"
@ -852,39 +828,6 @@ export interface ClineSayTool {
skill?: string
}
// Must keep in sync with system prompt.
export const browserActions = [
"launch",
"click",
"hover",
"type",
"press",
"scroll_down",
"scroll_up",
"resize",
"close",
"screenshot",
] as const
export type BrowserAction = (typeof browserActions)[number]
export interface ClineSayBrowserAction {
action: BrowserAction
coordinate?: string
size?: string
text?: string
executedCoordinate?: string
}
export type BrowserActionResult = {
screenshot?: string
logs?: string
currentUrl?: string
currentMousePosition?: string
viewportWidth?: number
viewportHeight?: number
}
export interface ClineAskUseMcpServer {
serverName: string
type: "use_mcp_tool" | "access_mcp_resource"

424
pnpm-lock.yaml generated
View file

@ -805,7 +805,7 @@ importers:
version: 1.14.0(typescript@5.8.3)
'@requesty/ai-sdk':
specifier: ^3.0.0
version: 3.0.0(vite@6.3.5(@types/node@20.17.50)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0))(zod@3.25.76)
version: 3.0.0(vite@6.3.6(@types/node@20.17.50)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0))(zod@3.25.76)
'@roo-code/cloud':
specifier: workspace:^
version: link:../packages/cloud
@ -938,12 +938,6 @@ importers:
ps-tree:
specifier: ^1.2.0
version: 1.2.0
puppeteer-chromium-resolver:
specifier: ^24.0.0
version: 24.0.1
puppeteer-core:
specifier: ^23.4.0
version: 23.11.1
reconnecting-eventsource:
specifier: ^1.6.4
version: 1.6.4
@ -3039,16 +3033,6 @@ packages:
'@protobufjs/utf8@1.1.0':
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
'@puppeteer/browsers@2.10.5':
resolution: {integrity: sha512-eifa0o+i8dERnngJwKrfp3dEq7ia5XFyoqB17S4gK8GhsQE4/P8nxOfQSE0zQHxzzLo/cmF+7+ywEQ7wK7Fb+w==}
engines: {node: '>=18'}
hasBin: true
'@puppeteer/browsers@2.6.1':
resolution: {integrity: sha512-aBSREisdsGH890S2rQqK82qmQYU3uFpSH8wcZWHgHzl3LfzsxAKbLNiAG9mO8v1Y0UICBeClICxPJvyr0rcuxg==}
engines: {node: '>=18'}
hasBin: true
'@qdrant/js-client-rest@1.14.0':
resolution: {integrity: sha512-2sM2g17FSkN2sNCSeAfqxHRr+SPEVnUQLXBjVv/whm4YQ4JjZ53Jiy1iShk95G+xBf3hKBhJdj8itRnor03IYw==}
engines: {node: '>=18.0.0', pnpm: '>=8'}
@ -4362,9 +4346,6 @@ packages:
peerDependencies:
'@testing-library/dom': '>=7.21.4'
'@tootallnate/quickjs-emscripten@0.23.0':
resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
'@trpc/client@11.8.1':
resolution: {integrity: sha512-L/SJFGanr9xGABmuDoeXR4xAdHJmsXsiF9OuH+apecJ+8sUITzVT1EPeqp0ebqA6lBhEl5pPfg3rngVhi/h60Q==}
peerDependencies:
@ -4684,9 +4665,6 @@ packages:
'@types/yargs@17.0.33':
resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==}
'@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
'@typescript-eslint/eslint-plugin@8.32.1':
resolution: {integrity: sha512-6u6Plg9nP/J1GRpe/vcjjabo6Uc5YQPAMxsgQyGC/I0RuukiG1wIe3+Vtg3IrSCVJDmqK3j8adrtzXSENRtFgg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -4961,9 +4939,6 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
aproba@2.0.0:
resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==}
archiver-utils@2.1.0:
resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==}
engines: {node: '>= 6'}
@ -5040,10 +5015,6 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-types@0.13.4:
resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==}
engines: {node: '>=4'}
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@ -5137,10 +5108,6 @@ packages:
resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
hasBin: true
basic-ftp@5.0.5:
resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==}
engines: {node: '>=10.0.0'}
better-path-resolve@1.0.0:
resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==}
engines: {node: '>=4'}
@ -5374,16 +5341,6 @@ packages:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
chromium-bidi@0.11.0:
resolution: {integrity: sha512-6CJWHkNRoyZyjV9Rwv2lYONZf1Xm0IuDyNq97nwSsxxP3wf5Bwy15K5rOvVKMtJ127jJBmxFUanSAOjgFRxgrA==}
peerDependencies:
devtools-protocol: '*'
chromium-bidi@5.1.0:
resolution: {integrity: sha512-9MSRhWRVoRPDG0TgzkHrshFSJJNZzfY5UFqUMuksg7zL1yoZIZ3jLB0YAgHclbiAxPI86pBnwDX1tbzoiV8aFw==}
peerDependencies:
devtools-protocol: '*'
ci-info@2.0.0:
resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==}
@ -5481,10 +5438,6 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-support@1.1.3:
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
hasBin: true
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
@ -5544,9 +5497,6 @@ packages:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
console-control-strings@1.1.0:
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
content-disposition@1.0.0:
resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==}
engines: {node: '>= 0.6'}
@ -5823,10 +5773,6 @@ packages:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
data-uri-to-buffer@6.0.2:
resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
engines: {node: '>= 14'}
data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
@ -5948,10 +5894,6 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
degenerator@5.0.1:
resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==}
engines: {node: '>= 14'}
delaunator@5.0.1:
resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==}
@ -6000,12 +5942,6 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
devtools-protocol@0.0.1367902:
resolution: {integrity: sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==}
devtools-protocol@0.0.1452169:
resolution: {integrity: sha512-FOFDVMGrAUNp0dDKsAU1TorWJUx2JOU1k9xdgBKKJF3IBh/Uhl2yswG5r3TEAOrCiGY2QRp1e6LVDQrCsTKO4g==}
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
@ -6203,9 +6139,6 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
eight-colors@1.3.1:
resolution: {integrity: sha512-7nXPYDeKh6DgJDR/mpt2G7N/hCNSGwwoPVmoI3+4TEwOb07VFN1WMPG0DFf6nMEjrkgdj8Og7l7IaEEk3VE6Zg==}
electron-to-chromium@1.5.152:
resolution: {integrity: sha512-xBOfg/EBaIlVsHipHl2VdTPJRSvErNUaqW8ejTq5OlOlIYx1wOllCHsAvAIrr55jD1IYEfdR86miUEt8H5IeJg==}
@ -6371,11 +6304,6 @@ packages:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
escodegen@2.1.0:
resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==}
engines: {node: '>=6.0'}
hasBin: true
eslint-config-prettier@10.1.8:
resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
hasBin: true
@ -6570,11 +6498,6 @@ packages:
extendable-error@0.1.7:
resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==}
extract-zip@2.0.1:
resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
engines: {node: '>= 10.17.0'}
hasBin: true
fast-csv@4.3.6:
resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==}
engines: {node: '>=10.0.0'}
@ -6811,11 +6734,6 @@ packages:
fzf@0.5.2:
resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==}
gauge@5.0.2:
resolution: {integrity: sha512-pMaFftXPtiGIHCJHdcUUx9Rby/rFT/Kkt3fIIGCs+9PMDIljSyRiqraTlxNtBReJRDfUefpa263RQ3vnp5G/LQ==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
deprecated: This package is no longer supported.
gaxios@7.1.3:
resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==}
engines: {node: '>=18'}
@ -6858,10 +6776,6 @@ packages:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
get-stream@5.2.0:
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
engines: {node: '>=8'}
get-stream@6.0.1:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'}
@ -6884,10 +6798,6 @@ packages:
get-tsconfig@4.10.1:
resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==}
get-uri@6.0.4:
resolution: {integrity: sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==}
engines: {node: '>= 14'}
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
@ -6995,9 +6905,6 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
has-unicode@2.0.1:
resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@ -7230,10 +7137,6 @@ packages:
resolution: {integrity: sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==}
engines: {node: '>=12.22.0'}
ip-address@9.0.5:
resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==}
engines: {node: '>= 12'}
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
@ -7574,9 +7477,6 @@ packages:
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
hasBin: true
jsbn@1.1.0:
resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==}
jsdom@26.1.0:
resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
engines: {node: '>=18'}
@ -8016,10 +7916,6 @@ packages:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
lru-cache@7.18.3:
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
engines: {node: '>=12'}
lucide-react@0.518.0:
resolution: {integrity: sha512-kFg34uQqnVl/7HwAiigxPSpj//43VIVHQbMygQPtS1yT4btMXHCWUipHcgcXHD2pm1Z2nUBA/M+Vnh/YmWXQUw==}
peerDependencies:
@ -8329,9 +8225,6 @@ packages:
resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==}
engines: {node: '>= 18'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
@ -8411,10 +8304,6 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
netmask@2.0.2:
resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==}
engines: {node: '>= 0.4.0'}
next-sitemap@4.2.3:
resolution: {integrity: sha512-vjdCxeDuWDzldhCnyFCQipw5bfpl4HmZA7uoo3GAaYGjGgfL4Cxb1CiztPuWGmS+auYs7/8OekRS8C2cjdAsjQ==}
engines: {node: '>=14.18'}
@ -8701,14 +8590,6 @@ packages:
resolution: {integrity: sha512-lwx6u1CotQYPVju77R+D0vFomni/AqRfqLmqQ8hekklqZ6gAY9rONh7lBQ0uxWMkC2AuX9b2DVAl8To0NyP1JA==}
engines: {node: '>=12'}
pac-proxy-agent@7.2.0:
resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==}
engines: {node: '>= 14'}
pac-resolver@7.0.1:
resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==}
engines: {node: '>= 14'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@ -9012,10 +8893,6 @@ packages:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
promise-limit@2.7.0:
resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==}
@ -9046,10 +8923,6 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
proxy-agent@6.5.0:
resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==}
engines: {node: '>= 14'}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
@ -9069,17 +8942,6 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
puppeteer-chromium-resolver@24.0.1:
resolution: {integrity: sha512-whu9e5qmnZekCP5hvlYMe7rWe4cU9seCISRlfT0vXGlCsy7psbeXHdGW6QdXrwyadvCTiD1Ft62jPaqia8ZQaA==}
puppeteer-core@23.11.1:
resolution: {integrity: sha512-3HZ2/7hdDKZvZQ7dhhITOUg4/wOrDRjyK2ZBllRB0ZCOi9u0cwq1ACHDjBB+nX+7+kltHjQvBRdeY7+W0T+7Gg==}
engines: {node: '>=18'}
puppeteer-core@24.10.2:
resolution: {integrity: sha512-CnzhOgrZj8DvkDqI+Yx+9or33i3Y9uUYbKyYpP4C13jWwXx/keQ38RMTMmxuLCWQlxjZrOH0Foq7P2fGP7adDQ==}
engines: {node: '>=18'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
@ -9699,10 +9561,6 @@ packages:
resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==}
engines: {node: '>=18'}
smart-buffer@4.2.0:
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
smol-toml@1.3.4:
resolution: {integrity: sha512-UOPtVuYkzYGee0Bd2Szz8d2G3RfMfJ2t3qVdZUAozZyAk+a0Sxa+QKix0YCwjL/A1RR0ar44nCxaoN9FxdJGwA==}
engines: {node: '>= 18'}
@ -9715,14 +9573,6 @@ packages:
resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==}
engines: {node: '>=10.0.0'}
socks-proxy-agent@8.0.5:
resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==}
engines: {node: '>= 14'}
socks@2.8.4:
resolution: {integrity: sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==}
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
sonner@2.0.5:
resolution: {integrity: sha512-YwbHQO6cSso3HBXlbCkgrgzDNIhws14r4MO87Ofy+cV2X7ES4pOoAK3+veSmVTvqNx1BWUxlhPmZzP00Crk2aQ==}
peerDependencies:
@ -10314,9 +10164,6 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
typed-query-selector@2.12.0:
resolution: {integrity: sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==}
typed-rest-client@1.8.11:
resolution: {integrity: sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==}
@ -10342,9 +10189,6 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
unbzip2-stream@1.4.3:
resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==}
underscore@1.13.7:
resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==}
@ -10805,9 +10649,6 @@ packages:
engines: {node: '>=8'}
hasBin: true
wide-align@1.1.5:
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
widest-line@5.0.0:
resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==}
engines: {node: '>=18'}
@ -12582,7 +12423,7 @@ snapshots:
'@kwsites/file-exists@1.1.1':
dependencies:
debug: 4.4.1
debug: 4.4.1(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@ -13043,33 +12884,6 @@ snapshots:
'@protobufjs/utf8@1.1.0': {}
'@puppeteer/browsers@2.10.5':
dependencies:
debug: 4.4.1
extract-zip: 2.0.1
progress: 2.0.3
proxy-agent: 6.5.0
semver: 7.7.3
tar-fs: 3.1.1
yargs: 17.7.2
transitivePeerDependencies:
- bare-buffer
- supports-color
'@puppeteer/browsers@2.6.1':
dependencies:
debug: 4.4.1
extract-zip: 2.0.1
progress: 2.0.3
proxy-agent: 6.5.0
semver: 7.7.3
tar-fs: 3.1.1
unbzip2-stream: 1.4.3
yargs: 17.7.2
transitivePeerDependencies:
- bare-buffer
- supports-color
'@qdrant/js-client-rest@1.14.0(typescript@5.8.3)':
dependencies:
'@qdrant/openapi-typescript-fetch': 1.2.6
@ -13819,11 +13633,11 @@ snapshots:
dependencies:
'@redis/client': 5.5.5
'@requesty/ai-sdk@3.0.0(vite@6.3.5(@types/node@20.17.50)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0))(zod@3.25.76)':
'@requesty/ai-sdk@3.0.0(vite@6.3.6(@types/node@20.17.50)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0))(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 3.0.20(zod@3.25.76)
vite: 6.3.5(@types/node@20.17.50)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
vite: 6.3.6(@types/node@20.17.50)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
zod: 3.25.76
'@resvg/resvg-wasm@2.4.0': {}
@ -14449,8 +14263,6 @@ snapshots:
dependencies:
'@testing-library/dom': 10.4.0
'@tootallnate/quickjs-emscripten@0.23.0': {}
'@trpc/client@11.8.1(@trpc/server@11.8.1(typescript@5.8.3))(typescript@5.8.3)':
dependencies:
'@trpc/server': 11.8.1(typescript@5.8.3)
@ -14800,11 +14612,6 @@ snapshots:
dependencies:
'@types/yargs-parser': 21.0.3
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 24.2.1
optional: true
'@typescript-eslint/eslint-plugin@8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)':
dependencies:
'@eslint-community/regexpp': 4.12.1
@ -14971,7 +14778,7 @@ snapshots:
sirv: 3.0.1
tinyglobby: 0.2.14
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
'@vitest/utils@3.2.4':
dependencies:
@ -15173,8 +14980,6 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.1
aproba@2.0.0: {}
archiver-utils@2.1.0:
dependencies:
glob: 11.1.0
@ -15308,10 +15113,6 @@ snapshots:
assertion-error@2.0.1: {}
ast-types@0.13.4:
dependencies:
tslib: 2.8.1
async-function@1.0.0: {}
async-mutex@0.5.0:
@ -15395,8 +15196,6 @@ snapshots:
baseline-browser-mapping@2.9.19: {}
basic-ftp@5.0.5: {}
better-path-resolve@1.0.0:
dependencies:
is-windows: 1.0.2
@ -15437,7 +15236,7 @@ snapshots:
dependencies:
bytes: 3.1.2
content-type: 1.0.5
debug: 4.4.1
debug: 4.4.1(supports-color@8.1.1)
http-errors: 2.0.0
iconv-lite: 0.6.3
on-finished: 2.4.1
@ -15663,18 +15462,6 @@ snapshots:
chownr@3.0.0: {}
chromium-bidi@0.11.0(devtools-protocol@0.0.1367902):
dependencies:
devtools-protocol: 0.0.1367902
mitt: 3.0.1
zod: 3.25.76
chromium-bidi@5.1.0(devtools-protocol@0.0.1452169):
dependencies:
devtools-protocol: 0.0.1452169
mitt: 3.0.1
zod: 3.25.76
ci-info@2.0.0: {}
ci-info@3.9.0: {}
@ -15778,8 +15565,6 @@ snapshots:
color-name@1.1.4: {}
color-support@1.1.3: {}
colorette@2.0.20: {}
combined-stream@1.0.8:
@ -15825,8 +15610,6 @@ snapshots:
consola@3.4.2: {}
console-control-strings@1.1.0: {}
content-disposition@1.0.0:
dependencies:
safe-buffer: 5.2.1
@ -16130,8 +15913,6 @@ snapshots:
data-uri-to-buffer@4.0.1: {}
data-uri-to-buffer@6.0.2: {}
data-urls@5.0.0:
dependencies:
whatwg-mimetype: 4.0.0
@ -16169,10 +15950,6 @@ snapshots:
dependencies:
ms: 2.1.3
debug@4.4.1:
dependencies:
ms: 2.1.3
debug@4.4.1(supports-color@8.1.1):
dependencies:
ms: 2.1.3
@ -16232,12 +16009,6 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
degenerator@5.0.1:
dependencies:
ast-types: 0.13.4
escodegen: 2.1.0
esprima: 4.0.1
delaunator@5.0.1:
dependencies:
robust-predicates: 3.0.2
@ -16269,10 +16040,6 @@ snapshots:
dependencies:
dequal: 2.0.3
devtools-protocol@0.0.1367902: {}
devtools-protocol@0.0.1452169: {}
didyoumean@1.2.2: {}
diff-match-patch@1.0.5: {}
@ -16386,8 +16153,6 @@ snapshots:
ee-first@1.1.1: {}
eight-colors@1.3.1: {}
electron-to-chromium@1.5.152: {}
electron-to-chromium@1.5.283: {}
@ -16622,14 +16387,6 @@ snapshots:
escape-string-regexp@5.0.0: {}
escodegen@2.1.0:
dependencies:
esprima: 4.0.1
estraverse: 5.3.0
esutils: 2.0.3
optionalDependencies:
source-map: 0.6.1
eslint-config-prettier@10.1.8(eslint@9.27.0(jiti@2.4.2)):
dependencies:
eslint: 9.27.0(jiti@2.4.2)
@ -16924,7 +16681,7 @@ snapshots:
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.2.2
debug: 4.4.1
debug: 4.4.1(supports-color@8.1.1)
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
@ -16958,16 +16715,6 @@ snapshots:
extendable-error@0.1.7: {}
extract-zip@2.0.1:
dependencies:
debug: 4.4.1
get-stream: 5.2.0
yauzl: 2.10.0
optionalDependencies:
'@types/yauzl': 2.10.3
transitivePeerDependencies:
- supports-color
fast-csv@4.3.6:
dependencies:
'@fast-csv/format': 4.3.5
@ -17061,7 +16808,7 @@ snapshots:
finalhandler@2.1.0:
dependencies:
debug: 4.4.1
debug: 4.4.1(supports-color@8.1.1)
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
@ -17190,17 +16937,6 @@ snapshots:
fzf@0.5.2: {}
gauge@5.0.2:
dependencies:
aproba: 2.0.0
color-support: 1.1.3
console-control-strings: 1.1.0
has-unicode: 2.0.1
signal-exit: 4.1.0
string-width: 4.2.3
strip-ansi: 6.0.1
wide-align: 1.1.5
gaxios@7.1.3:
dependencies:
extend: 3.0.2
@ -17258,10 +16994,6 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
get-stream@5.2.0:
dependencies:
pump: 3.0.2
get-stream@6.0.1: {}
get-stream@8.0.1: {}
@ -17285,14 +17017,6 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
get-uri@6.0.4:
dependencies:
basic-ftp: 5.0.5
data-uri-to-buffer: 6.0.2
debug: 4.4.1
transitivePeerDependencies:
- supports-color
github-from-package@0.0.0:
optional: true
@ -17404,8 +17128,6 @@ snapshots:
dependencies:
has-symbols: 1.1.0
has-unicode@2.0.1: {}
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@ -17584,14 +17306,14 @@ snapshots:
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.3
debug: 4.4.1
debug: 4.4.1(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.3
debug: 4.4.1
debug: 4.4.1(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@ -17729,11 +17451,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
ip-address@9.0.5:
dependencies:
jsbn: 1.1.0
sprintf-js: 1.1.3
ipaddr.js@1.9.1: {}
is-alphabetical@1.0.4: {}
@ -18043,8 +17760,6 @@ snapshots:
dependencies:
argparse: 2.0.1
jsbn@1.1.0: {}
jsdom@26.1.0:
dependencies:
cssstyle: 4.4.0
@ -18491,8 +18206,6 @@ snapshots:
dependencies:
yallist: 4.0.0
lru-cache@7.18.3: {}
lucide-react@0.518.0(react@18.3.1):
dependencies:
react: 18.3.1
@ -19046,8 +18759,6 @@ snapshots:
dependencies:
minipass: 7.1.2
mitt@3.0.1: {}
mkdirp-classic@0.5.3:
optional: true
@ -19139,8 +18850,6 @@ snapshots:
negotiator@1.0.0: {}
netmask@2.0.2: {}
next-sitemap@4.2.3(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)):
dependencies:
'@corex/deepmerge': 4.0.43
@ -19465,24 +19174,6 @@ snapshots:
dependencies:
p-timeout: 6.1.4
pac-proxy-agent@7.2.0:
dependencies:
'@tootallnate/quickjs-emscripten': 0.23.0
agent-base: 7.1.3
debug: 4.4.1
get-uri: 6.0.4
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
pac-resolver: 7.0.1
socks-proxy-agent: 8.0.5
transitivePeerDependencies:
- supports-color
pac-resolver@7.0.1:
dependencies:
degenerator: 5.0.1
netmask: 2.0.2
package-json-from-dist@1.0.1: {}
package-manager-detector@0.2.11:
@ -19771,8 +19462,6 @@ snapshots:
process@0.11.10: {}
progress@2.0.3: {}
promise-limit@2.7.0:
optional: true
@ -19818,19 +19507,6 @@ snapshots:
forwarded: 0.2.0
ipaddr.js: 1.9.1
proxy-agent@6.5.0:
dependencies:
agent-base: 7.1.3
debug: 4.4.1
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
lru-cache: 7.18.3
pac-proxy-agent: 7.2.0
proxy-from-env: 1.1.0
socks-proxy-agent: 8.0.5
transitivePeerDependencies:
- supports-color
proxy-from-env@1.1.0: {}
ps-tree@1.2.0:
@ -19841,51 +19517,12 @@ snapshots:
dependencies:
end-of-stream: 1.4.4
once: 1.4.0
optional: true
punycode.js@2.3.1: {}
punycode@2.3.1: {}
puppeteer-chromium-resolver@24.0.1:
dependencies:
'@puppeteer/browsers': 2.10.5
eight-colors: 1.3.1
gauge: 5.0.2
puppeteer-core: 24.10.2
transitivePeerDependencies:
- bare-buffer
- bufferutil
- supports-color
- utf-8-validate
puppeteer-core@23.11.1:
dependencies:
'@puppeteer/browsers': 2.6.1
chromium-bidi: 0.11.0(devtools-protocol@0.0.1367902)
debug: 4.4.1
devtools-protocol: 0.0.1367902
typed-query-selector: 2.12.0
ws: 8.18.2
transitivePeerDependencies:
- bare-buffer
- bufferutil
- supports-color
- utf-8-validate
puppeteer-core@24.10.2:
dependencies:
'@puppeteer/browsers': 2.10.5
chromium-bidi: 5.1.0(devtools-protocol@0.0.1452169)
debug: 4.4.1
devtools-protocol: 0.0.1452169
typed-query-selector: 2.12.0
ws: 8.18.2
transitivePeerDependencies:
- bare-buffer
- bufferutil
- supports-color
- utf-8-validate
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
@ -20397,7 +20034,7 @@ snapshots:
router@2.2.0:
dependencies:
debug: 4.4.1
debug: 4.4.1(supports-color@8.1.1)
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
@ -20514,7 +20151,7 @@ snapshots:
send@1.2.0:
dependencies:
debug: 4.4.1
debug: 4.4.1(supports-color@8.1.1)
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
@ -20686,7 +20323,7 @@ snapshots:
dependencies:
'@kwsites/file-exists': 1.1.1
'@kwsites/promise-deferred': 1.1.1
debug: 4.4.1
debug: 4.4.1(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@ -20710,8 +20347,6 @@ snapshots:
ansi-styles: 6.2.3
is-fullwidth-code-point: 5.0.0
smart-buffer@4.2.0: {}
smol-toml@1.3.4: {}
socket.io-client@4.8.1:
@ -20732,19 +20367,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
socks-proxy-agent@8.0.5:
dependencies:
agent-base: 7.1.3
debug: 4.4.1
socks: 2.8.4
transitivePeerDependencies:
- supports-color
socks@2.8.4:
dependencies:
ip-address: 9.0.5
smart-buffer: 4.2.0
sonner@2.0.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
@ -21092,6 +20714,7 @@ snapshots:
bare-path: 3.0.0
transitivePeerDependencies:
- bare-buffer
optional: true
tar-stream@2.2.0:
dependencies:
@ -21245,7 +20868,7 @@ snapshots:
cac: 6.7.14
chokidar: 4.0.3
consola: 3.4.2
debug: 4.4.1
debug: 4.4.1(supports-color@8.1.1)
esbuild: 0.25.9
fix-dts-default-cjs-exports: 1.0.1
joycon: 3.1.1
@ -21359,8 +20982,6 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
typed-query-selector@2.12.0: {}
typed-rest-client@1.8.11:
dependencies:
qs: 6.14.0
@ -21390,11 +21011,6 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
unbzip2-stream@1.4.3:
dependencies:
buffer: 5.7.1
through: 2.3.8
underscore@1.13.7: {}
undici-types@5.26.5: {}
@ -21646,7 +21262,7 @@ snapshots:
vite-node@3.2.4(@types/node@20.17.50)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0):
dependencies:
cac: 6.7.14
debug: 4.4.1
debug: 4.4.1(supports-color@8.1.1)
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 6.3.6(@types/node@20.17.50)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
@ -21813,7 +21429,7 @@ snapshots:
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.2.0
debug: 4.4.1
debug: 4.4.1(supports-color@8.1.1)
expect-type: 1.2.1
magic-string: 0.30.17
pathe: 2.0.3
@ -22070,10 +21686,6 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
wide-align@1.1.5:
dependencies:
string-width: 4.2.3
widest-line@5.0.0:
dependencies:
string-width: 7.2.0

View file

@ -1,28 +1,14 @@
import { parseMentions } from "../core/mentions"
import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
import { getCommand } from "../services/command/commands"
// Mock the dependencies
vi.mock("../services/command/commands")
vi.mock("../services/browser/UrlContentFetcher")
const MockedUrlContentFetcher = vi.mocked(UrlContentFetcher)
const mockGetCommand = vi.mocked(getCommand)
describe("Command Mentions", () => {
let mockUrlContentFetcher: any
beforeEach(() => {
vi.clearAllMocks()
// Create a mock UrlContentFetcher instance
mockUrlContentFetcher = {
launchBrowser: vi.fn(),
urlToMarkdown: vi.fn(),
closeBrowser: vi.fn(),
}
MockedUrlContentFetcher.mockImplementation(() => mockUrlContentFetcher)
})
// Helper function to call parseMentions with required parameters
@ -30,7 +16,6 @@ describe("Command Mentions", () => {
return parseMentions(
text,
"/test/cwd", // cwd
mockUrlContentFetcher, // urlContentFetcher
undefined, // fileContextTracker
undefined, // rooIgnoreController
false, // showRooIgnoredFiles

View file

@ -490,19 +490,6 @@ export class NativeToolCallParser {
}
break
case "browser_action":
if (partialArgs.action !== undefined) {
nativeArgs = {
action: partialArgs.action,
url: partialArgs.url,
coordinate: partialArgs.coordinate,
size: partialArgs.size,
text: partialArgs.text,
path: partialArgs.path,
}
}
break
case "codebase_search":
if (partialArgs.query !== undefined) {
nativeArgs = {
@ -838,19 +825,6 @@ export class NativeToolCallParser {
}
break
case "browser_action":
if (args.action !== undefined) {
nativeArgs = {
action: args.action,
url: args.url,
coordinate: args.coordinate,
size: args.size,
text: args.text,
path: args.path,
} as NativeArgsFor<TName>
}
break
case "codebase_search":
if (args.query !== undefined) {
nativeArgs = {

View file

@ -246,7 +246,7 @@ describe("NativeToolCallParser", () => {
name: "read_file" as const,
arguments: JSON.stringify({
files: JSON.stringify([
{ path: "src/services/browser/browserDiscovery.ts" },
{ path: "src/services/example/service.ts" },
{ path: "src/services/mcp/McpServerManager.ts" },
]),
}),
@ -264,7 +264,7 @@ describe("NativeToolCallParser", () => {
}
expect(nativeArgs._legacyFormat).toBe(true)
expect(nativeArgs.files).toHaveLength(2)
expect(nativeArgs.files[0].path).toBe("src/services/browser/browserDiscovery.ts")
expect(nativeArgs.files[0].path).toBe("src/services/example/service.ts")
expect(nativeArgs.files[1].path).toBe("src/services/mcp/McpServerManager.ts")
}
})

View file

@ -60,9 +60,6 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
api: {
getModel: () => ({ id: "test-model", info: {} }),
},
browserSession: {
closeBrowser: vi.fn().mockResolvedValue(undefined),
},
recordToolUsage: vi.fn(),
recordToolError: vi.fn(),
toolRepetitionDetector: {

View file

@ -46,9 +46,6 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () =
api: {
getModel: () => ({ id: "test-model", info: {} }),
},
browserSession: {
closeBrowser: vi.fn().mockResolvedValue(undefined),
},
recordToolUsage: vi.fn(),
toolRepetitionDetector: {
check: vi.fn().mockReturnValue({ allowExecution: true }),

View file

@ -41,9 +41,6 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => {
api: {
getModel: () => ({ id: "test-model", info: {} }),
},
browserSession: {
closeBrowser: vi.fn().mockResolvedValue(undefined),
},
recordToolUsage: vi.fn(),
recordToolError: vi.fn(),
toolRepetitionDetector: {

View file

@ -24,7 +24,6 @@ import { searchReplaceTool } from "../tools/SearchReplaceTool"
import { editFileTool } from "../tools/EditFileTool"
import { applyPatchTool } from "../tools/ApplyPatchTool"
import { searchFilesTool } from "../tools/SearchFilesTool"
import { browserActionTool } from "../tools/BrowserActionTool"
import { executeCommandTool } from "../tools/ExecuteCommandTool"
import { useMcpToolTool } from "../tools/UseMcpToolTool"
import { accessMcpResourceTool } from "../tools/accessMcpResourceTool"
@ -358,8 +357,6 @@ export async function presentAssistantMessage(cline: Task) {
return `[${block.name}]`
case "list_files":
return `[${block.name} for '${block.params.path}']`
case "browser_action":
return `[${block.name} for '${block.params.action}']`
case "use_mcp_tool":
return `[${block.name} for '${block.params.server_name}']`
case "access_mcp_resource":
@ -559,34 +556,6 @@ export async function presentAssistantMessage(cline: Task) {
pushToolResult(formatResponse.toolError(errorString))
}
// Keep browser open during an active session so other tools can run.
// Session is active if we've seen any browser_action_result and the last browser_action is not "close".
try {
const messages = cline.clineMessages || []
const hasStarted = messages.some((m: any) => m.say === "browser_action_result")
let isClosed = false
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]
if (m.say === "browser_action") {
try {
const act = JSON.parse(m.text || "{}")
isClosed = act.action === "close"
} catch {}
break
}
}
const sessionActive = hasStarted && !isClosed
// Only auto-close when no active browser session is present, and this isn't a browser_action
if (!sessionActive && block.name !== "browser_action") {
await cline.browserSession.closeBrowser()
}
} catch {
// On any unexpected error, fall back to conservative behavior
if (block.name !== "browser_action") {
await cline.browserSession.closeBrowser()
}
}
if (!block.partial) {
// Check if this is a custom tool - if so, record as "custom_tool" (like MCP tools)
const isCustomTool = stateExperiments?.customTools && customToolRegistry.has(block.name)
@ -798,15 +767,6 @@ export async function presentAssistantMessage(cline: Task) {
pushToolResult,
})
break
case "browser_action":
await browserActionTool(
cline,
block as ToolUse<"browser_action">,
askApproval,
handleError,
pushToolResult,
)
break
case "execute_command":
await executeCommandTool.handle(cline, block as ToolUse<"execute_command">, {
askApproval,

View file

@ -13,11 +13,10 @@ import { isWriteToolAction, isReadOnlyToolAction } from "./tools"
import { isMcpToolAlwaysAllowed } from "./mcp"
import { getCommandDecision } from "./commands"
// We have 10 different actions that can be auto-approved.
// We have auto-approval actions for different categories.
export type AutoApprovalState =
| "alwaysAllowReadOnly"
| "alwaysAllowWrite"
| "alwaysAllowBrowser"
| "alwaysAllowMcp"
| "alwaysAllowModeSwitch"
| "alwaysAllowSubtasks"
@ -90,10 +89,6 @@ export async function checkAutoApproval({
}
}
if (ask === "browser_action_launch") {
return state.alwaysAllowBrowser === true ? { decision: "approve" } : { decision: "ask" }
}
if (ask === "use_mcp_server") {
if (!text) {
return { decision: "ask" }

View file

@ -227,11 +227,7 @@ describe("CustomModesManager - YAML Edge Cases", () => {
slug: "test-mode",
name: "Test Mode",
roleDefinition: "Test role",
groups: [
"read",
["edit", { fileRegex: "\\.md$", description: "Markdown files only" }],
"browser",
],
groups: ["read", ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }]],
},
],
})
@ -245,20 +241,19 @@ describe("CustomModesManager - YAML Edge Cases", () => {
// Should successfully parse the complex fileRegex syntax
expect(modes).toHaveLength(1)
expect(modes[0].groups).toHaveLength(3)
expect(modes[0].groups).toHaveLength(2)
expect(modes[0].groups[1]).toEqual(["edit", { fileRegex: "\\.md$", description: "Markdown files only" }])
})
it("should handle invalid fileRegex syntax with clear error", async () => {
// This YAML has invalid structure that might cause parsing issues
const invalidYaml = `customModes:
- slug: "test-mode"
name: "Test Mode"
roleDefinition: "Test role"
groups:
- read
- ["edit", { fileRegex: "\\.md$" }] # This line has invalid YAML syntax
- browser`
- slug: "test-mode"
name: "Test Mode"
roleDefinition: "Test role"
groups:
- read
- ["edit", { fileRegex: "\\.md$" }] # This line has invalid YAML syntax`
mockFsReadFile({
[mockRoomodes]: invalidYaml,
@ -433,13 +428,6 @@ describe("CustomModesManager - YAML Edge Cases", () => {
description: "Markdown files with \u2018special\u2019 chars",
},
],
[
"browser",
{
fileRegex: "\\.html?$",
description: "HTML files\u00A0only",
},
],
],
},
],
@ -462,13 +450,6 @@ describe("CustomModesManager - YAML Edge Cases", () => {
description: "Markdown files with 'special' chars",
},
])
expect(modes[0].groups[2]).toEqual([
"browser",
{
fileRegex: "\\.html?$",
description: "HTML files only",
},
])
})
})
})

View file

@ -130,7 +130,7 @@ describe("CustomModesSettings", () => {
customModes: [
{
...validMode,
groups: ["read", "edit", "browser"] as const,
groups: ["read", "edit"] as const,
},
],
}
@ -168,4 +168,41 @@ describe("CustomModesSettings", () => {
expect(settings.customModes[0].customInstructions).toBeDefined()
})
})
describe("deprecated tool group migration", () => {
it("should strip deprecated 'browser' group when validating custom modes settings", () => {
const result = customModesSettingsSchema.parse({
customModes: [
{
slug: "test-mode",
name: "Test Mode",
roleDefinition: "Test role",
groups: ["read", "browser", "edit"],
},
],
})
expect(result.customModes[0].groups).toEqual(["read", "edit"])
})
it("should strip deprecated 'browser' from multiple modes in settings", () => {
const result = customModesSettingsSchema.parse({
customModes: [
{
slug: "mode-a",
name: "Mode A",
roleDefinition: "Role A",
groups: ["read", "browser"],
},
{
slug: "mode-b",
name: "Mode B",
roleDefinition: "Role B",
groups: ["browser", "edit", "command"],
},
],
})
expect(result.customModes[0].groups).toEqual(["read"])
expect(result.customModes[1].groups).toEqual(["edit", "command"])
})
})
})

View file

@ -26,7 +26,7 @@ describe("CustomModeSchema", () => {
slug: "test",
name: "Test Mode",
roleDefinition: "Test role definition",
groups: ["read", "edit", "browser"] as const,
groups: ["read", "edit"] as const,
} satisfies ModeConfig
expect(() => validateCustomMode(validMode)).not.toThrow()
@ -121,18 +121,14 @@ describe("CustomModeSchema", () => {
slug: "markdown-editor",
name: "Markdown Editor",
roleDefinition: "Markdown editing mode",
groups: ["read", ["edit", { fileRegex: "\\.md$" }], "browser"],
groups: ["read", ["edit", { fileRegex: "\\.md$" }]],
}
const modeWithDescription = {
slug: "docs-editor",
name: "Documentation Editor",
roleDefinition: "Documentation editing mode",
groups: [
"read",
["edit", { fileRegex: "\\.(md|txt)$", description: "Documentation files only" }],
"browser",
],
groups: ["read", ["edit", { fileRegex: "\\.(md|txt)$", description: "Documentation files only" }]],
}
expect(() => modeConfigSchema.parse(modeWithJustRegex)).not.toThrow()
@ -195,7 +191,7 @@ describe("CustomModeSchema", () => {
test("accepts multiple groups", () => {
const mode = {
...validBaseMode,
groups: ["read", "edit", "browser"] as const,
groups: ["read", "edit"] as const,
} satisfies ModeConfig
expect(() => modeConfigSchema.parse(mode)).not.toThrow()
@ -204,7 +200,7 @@ describe("CustomModeSchema", () => {
test("accepts all available groups", () => {
const mode = {
...validBaseMode,
groups: ["read", "edit", "browser", "command", "mcp"] as const,
groups: ["read", "edit", "command", "mcp"] as const,
} satisfies ModeConfig
expect(() => modeConfigSchema.parse(mode)).not.toThrow()
@ -252,4 +248,46 @@ describe("CustomModeSchema", () => {
expect(() => modeConfigSchema.parse(modeWithUndefined)).toThrow()
})
})
describe("deprecated tool group migration", () => {
it("should strip deprecated 'browser' string group from mode config", () => {
const result = modeConfigSchema.parse({
slug: "test-mode",
name: "Test Mode",
roleDefinition: "Test role",
groups: ["read", "browser", "edit"],
})
expect(result.groups).toEqual(["read", "edit"])
})
it("should strip deprecated 'browser' tuple group from mode config", () => {
const result = modeConfigSchema.parse({
slug: "test-mode",
name: "Test Mode",
roleDefinition: "Test role",
groups: ["read", ["browser", { fileRegex: ".*", description: "test" }], "edit"],
})
expect(result.groups).toEqual(["read", "edit"])
})
it("should handle mode config where all groups are deprecated", () => {
const result = modeConfigSchema.parse({
slug: "test-mode",
name: "Test Mode",
roleDefinition: "Test role",
groups: ["browser"],
})
expect(result.groups).toEqual([])
})
it("should still reject other invalid group names", () => {
const result = modeConfigSchema.safeParse({
slug: "test-mode",
name: "Test Mode",
roleDefinition: "Test role",
groups: ["read", "nonexistent"],
})
expect(result.success).toBe(false)
})
})
})

View file

@ -117,10 +117,6 @@ describe("getEnvironmentDetails", () => {
deref: vi.fn().mockReturnValue(mockProvider),
[Symbol.toStringTag]: "WeakRef",
} as unknown as WeakRef<ClineProvider>,
browserSession: {
isSessionActive: vi.fn().mockReturnValue(false),
getViewportSize: vi.fn().mockReturnValue({ width: 900, height: 600 }),
} as any,
}
// Mock other dependencies.
@ -448,18 +444,4 @@ describe("getEnvironmentDetails", () => {
expect(getGitStatus).toHaveBeenCalledWith(mockCwd, 5)
})
it("should NOT include Browser Session Status when inactive", async () => {
const result = await getEnvironmentDetails(mockCline as Task)
expect(result).not.toContain("# Browser Session Status")
})
it("should include Browser Session Status with current viewport when active", async () => {
;(mockCline.browserSession as any).isSessionActive = vi.fn().mockReturnValue(true)
;(mockCline.browserSession as any).getViewportSize = vi.fn().mockReturnValue({ width: 1280, height: 720 })
const result = await getEnvironmentDetails(mockCline as Task)
expect(result).toContain("Active - A browser session is currently open and ready for browser_action commands")
expect(result).toContain("Current viewport size: 1280x720 pixels.")
})
})

View file

@ -226,35 +226,6 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
details += `<name>${modeDetails.name}</name>\n`
details += `<model>${modelId}</model>\n`
// Add browser session status - Only show when active to prevent cluttering context
const isBrowserActive = cline.browserSession.isSessionActive()
if (isBrowserActive) {
// Build viewport info for status (prefer actual viewport if available, else fallback to configured setting)
const configuredViewport = (state?.browserViewportSize as string | undefined) ?? "900x600"
let configuredWidth: number | undefined
let configuredHeight: number | undefined
if (configuredViewport.includes("x")) {
const parts = configuredViewport.split("x").map((v) => Number(v))
configuredWidth = parts[0]
configuredHeight = parts[1]
}
let actualWidth: number | undefined
let actualHeight: number | undefined
const vp = cline.browserSession.getViewportSize?.()
if (vp) {
actualWidth = vp.width
actualHeight = vp.height
}
const width = actualWidth ?? configuredWidth
const height = actualHeight ?? configuredHeight
const viewportInfo = width && height ? `\nCurrent viewport size: ${width}x${height} pixels.` : ""
details += `\n# Browser Session Status\nActive - A browser session is currently open and ready for browser_action commands${viewportInfo}\n`
}
if (includeFileDetails) {
details += `\n\n# Current Workspace Directory (${cline.cwd.toPosix()}) Files\n`
const isDesktop = arePathsEqual(cline.cwd, path.join(os.homedir(), "Desktop"))

View file

@ -3,7 +3,6 @@
import * as vscode from "vscode"
import { parseMentions } from "../index"
import { UrlContentFetcher } from "../../../services/browser/UrlContentFetcher"
// Mock vscode
vi.mock("vscode", () => ({
@ -17,143 +16,15 @@ vi.mock("../../../i18n", () => ({
t: vi.fn((key: string) => key),
}))
describe("parseMentions - URL error handling", () => {
let mockUrlContentFetcher: UrlContentFetcher
let consoleErrorSpy: any
describe("parseMentions - URL mention handling", () => {
beforeEach(() => {
vi.clearAllMocks()
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
mockUrlContentFetcher = {
launchBrowser: vi.fn(),
urlToMarkdown: vi.fn(),
closeBrowser: vi.fn(),
} as any
})
it("should handle timeout errors with appropriate message", async () => {
const timeoutError = new Error("Navigation timeout of 30000 ms exceeded")
vi.mocked(mockUrlContentFetcher.urlToMarkdown).mockRejectedValue(timeoutError)
it("should replace URL mentions with quoted URL reference", async () => {
const result = await parseMentions("Check @https://example.com", "/test")
const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
expect(consoleErrorSpy).toHaveBeenCalledWith("Error fetching URL https://example.com:", timeoutError)
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
expect(result.text).toContain("Error fetching content: Navigation timeout of 30000 ms exceeded")
})
it("should handle DNS resolution errors", async () => {
const dnsError = new Error("net::ERR_NAME_NOT_RESOLVED")
vi.mocked(mockUrlContentFetcher.urlToMarkdown).mockRejectedValue(dnsError)
const result = await parseMentions("Check @https://nonexistent.example", "/test", mockUrlContentFetcher)
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
expect(result.text).toContain("Error fetching content: net::ERR_NAME_NOT_RESOLVED")
})
it("should handle network disconnection errors", async () => {
const networkError = new Error("net::ERR_INTERNET_DISCONNECTED")
vi.mocked(mockUrlContentFetcher.urlToMarkdown).mockRejectedValue(networkError)
const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
expect(result.text).toContain("Error fetching content: net::ERR_INTERNET_DISCONNECTED")
})
it("should handle 403 Forbidden errors", async () => {
const forbiddenError = new Error("403 Forbidden")
vi.mocked(mockUrlContentFetcher.urlToMarkdown).mockRejectedValue(forbiddenError)
const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
expect(result.text).toContain("Error fetching content: 403 Forbidden")
})
it("should handle 404 Not Found errors", async () => {
const notFoundError = new Error("404 Not Found")
vi.mocked(mockUrlContentFetcher.urlToMarkdown).mockRejectedValue(notFoundError)
const result = await parseMentions("Check @https://example.com/missing", "/test", mockUrlContentFetcher)
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
expect(result.text).toContain("Error fetching content: 404 Not Found")
})
it("should handle generic errors with fallback message", async () => {
const genericError = new Error("Some unexpected error")
vi.mocked(mockUrlContentFetcher.urlToMarkdown).mockRejectedValue(genericError)
const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
expect(result.text).toContain("Error fetching content: Some unexpected error")
})
it("should handle non-Error objects thrown", async () => {
const nonErrorObject = { code: "UNKNOWN", details: "Something went wrong" }
vi.mocked(mockUrlContentFetcher.urlToMarkdown).mockRejectedValue(nonErrorObject)
const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
expect(result.text).toContain("Error fetching content:")
})
it("should handle browser launch errors correctly", async () => {
const launchError = new Error("Failed to launch browser")
vi.mocked(mockUrlContentFetcher.launchBrowser).mockRejectedValue(launchError)
const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
"Error fetching content for https://example.com: Failed to launch browser",
)
expect(result.text).toContain("Error fetching content: Failed to launch browser")
// Should not attempt to fetch URL if browser launch failed
expect(mockUrlContentFetcher.urlToMarkdown).not.toHaveBeenCalled()
})
it("should handle browser launch errors without message property", async () => {
const launchError = "String error"
vi.mocked(mockUrlContentFetcher.launchBrowser).mockRejectedValue(launchError)
const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
"Error fetching content for https://example.com: String error",
)
expect(result.text).toContain("Error fetching content: String error")
})
it("should successfully fetch URL content when no errors occur", async () => {
vi.mocked(mockUrlContentFetcher.urlToMarkdown).mockResolvedValue("# Example Content\n\nThis is the content.")
const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
expect(vscode.window.showErrorMessage).not.toHaveBeenCalled()
expect(result.text).toContain('<url_content url="https://example.com">')
expect(result.text).toContain("# Example Content\n\nThis is the content.")
expect(result.text).toContain("</url_content>")
})
it("should handle multiple URLs with mixed success and failure", async () => {
vi.mocked(mockUrlContentFetcher.urlToMarkdown)
.mockResolvedValueOnce("# First Site")
.mockRejectedValueOnce(new Error("timeout"))
const result = await parseMentions(
"Check @https://example1.com and @https://example2.com",
"/test",
mockUrlContentFetcher,
)
expect(result.text).toContain('<url_content url="https://example1.com">')
expect(result.text).toContain("# First Site")
expect(result.text).toContain('<url_content url="https://example2.com">')
expect(result.text).toContain("Error fetching content: timeout")
// URL mentions are now replaced with a quoted reference (no fetching)
expect(result.text).toContain("'https://example.com'")
})
})

View file

@ -2,7 +2,6 @@
import { processUserContentMentions } from "../processUserContentMentions"
import { parseMentions } from "../index"
import { UrlContentFetcher } from "../../../services/browser/UrlContentFetcher"
import { FileContextTracker } from "../../context-tracking/FileContextTracker"
// Mock the parseMentions function
@ -11,14 +10,12 @@ vi.mock("../index", () => ({
}))
describe("processUserContentMentions", () => {
let mockUrlContentFetcher: UrlContentFetcher
let mockFileContextTracker: FileContextTracker
let mockRooIgnoreController: any
beforeEach(() => {
vi.clearAllMocks()
mockUrlContentFetcher = {} as UrlContentFetcher
mockFileContextTracker = {} as FileContextTracker
mockRooIgnoreController = {}
@ -42,7 +39,6 @@ describe("processUserContentMentions", () => {
const result = await processUserContentMentions({
userContent,
cwd: "/test",
urlContentFetcher: mockUrlContentFetcher,
fileContextTracker: mockFileContextTracker,
})
@ -65,7 +61,6 @@ describe("processUserContentMentions", () => {
const result = await processUserContentMentions({
userContent,
cwd: "/test",
urlContentFetcher: mockUrlContentFetcher,
fileContextTracker: mockFileContextTracker,
})
@ -74,6 +69,78 @@ describe("processUserContentMentions", () => {
expect(result.mode).toBeUndefined()
})
it("should process tool_result blocks with string content", async () => {
const userContent = [
{
type: "tool_result" as const,
tool_use_id: "123",
content: "<user_message>Tool feedback</user_message>",
},
]
const result = await processUserContentMentions({
userContent,
cwd: "/test",
fileContextTracker: mockFileContextTracker,
})
expect(parseMentions).toHaveBeenCalled()
// String content is now converted to array format to support content blocks
expect(result.content[0]).toEqual({
type: "tool_result",
tool_use_id: "123",
content: [
{
type: "text",
text: "parsed: <user_message>Tool feedback</user_message>",
},
],
})
expect(result.mode).toBeUndefined()
})
it("should process tool_result blocks with array content", async () => {
const userContent = [
{
type: "tool_result" as const,
tool_use_id: "123",
content: [
{
type: "text" as const,
text: "<user_message>Array task</user_message>",
},
{
type: "text" as const,
text: "Regular text",
},
],
},
]
const result = await processUserContentMentions({
userContent,
cwd: "/test",
fileContextTracker: mockFileContextTracker,
})
expect(parseMentions).toHaveBeenCalledTimes(1)
expect(result.content[0]).toEqual({
type: "tool_result",
tool_use_id: "123",
content: [
{
type: "text",
text: "parsed: <user_message>Array task</user_message>",
},
{
type: "text",
text: "Regular text",
},
],
})
expect(result.mode).toBeUndefined()
})
it("should handle mixed content types (text + image)", async () => {
const userContent = [
{
@ -90,7 +157,6 @@ describe("processUserContentMentions", () => {
const result = await processUserContentMentions({
userContent: userContent as any,
cwd: "/test",
urlContentFetcher: mockUrlContentFetcher,
fileContextTracker: mockFileContextTracker,
})
@ -117,14 +183,12 @@ describe("processUserContentMentions", () => {
await processUserContentMentions({
userContent,
cwd: "/test",
urlContentFetcher: mockUrlContentFetcher,
fileContextTracker: mockFileContextTracker,
})
expect(parseMentions).toHaveBeenCalledWith(
"<user_message>Test default</user_message>",
"/test",
mockUrlContentFetcher,
mockFileContextTracker,
undefined,
false, // showRooIgnoredFiles should default to false
@ -144,7 +208,6 @@ describe("processUserContentMentions", () => {
await processUserContentMentions({
userContent,
cwd: "/test",
urlContentFetcher: mockUrlContentFetcher,
fileContextTracker: mockFileContextTracker,
showRooIgnoredFiles: false,
})
@ -152,7 +215,6 @@ describe("processUserContentMentions", () => {
expect(parseMentions).toHaveBeenCalledWith(
"<user_message>Test explicit false</user_message>",
"/test",
mockUrlContentFetcher,
mockFileContextTracker,
undefined,
false,
@ -181,7 +243,6 @@ describe("processUserContentMentions", () => {
const result = await processUserContentMentions({
userContent,
cwd: "/test",
urlContentFetcher: mockUrlContentFetcher,
fileContextTracker: mockFileContextTracker,
})
@ -195,5 +256,88 @@ describe("processUserContentMentions", () => {
text: "command help",
})
})
it("should include slash command content in tool_result string content", async () => {
vi.mocked(parseMentions).mockResolvedValueOnce({
text: "parsed tool output",
slashCommandHelp: "command help",
mode: undefined,
contentBlocks: [],
})
const userContent = [
{
type: "tool_result" as const,
tool_use_id: "123",
content: "<user_message>Tool output</user_message>",
},
]
const result = await processUserContentMentions({
userContent,
cwd: "/test",
fileContextTracker: mockFileContextTracker,
})
expect(result.content).toHaveLength(1)
expect(result.content[0]).toEqual({
type: "tool_result",
tool_use_id: "123",
content: [
{
type: "text",
text: "parsed tool output",
},
{
type: "text",
text: "command help",
},
],
})
})
it("should include slash command content in tool_result array content", async () => {
vi.mocked(parseMentions).mockResolvedValueOnce({
text: "parsed array item",
slashCommandHelp: "command help",
mode: undefined,
contentBlocks: [],
})
const userContent = [
{
type: "tool_result" as const,
tool_use_id: "123",
content: [
{
type: "text" as const,
text: "<user_message>Array item</user_message>",
},
],
},
]
const result = await processUserContentMentions({
userContent,
cwd: "/test",
fileContextTracker: mockFileContextTracker,
})
expect(result.content).toHaveLength(1)
expect(result.content[0]).toEqual({
type: "tool_result",
tool_use_id: "123",
content: [
{
type: "text",
text: "parsed array item",
},
{
type: "text",
text: "command help",
},
],
})
})
})
})

View file

@ -13,42 +13,11 @@ import { extractTextFromFileWithMetadata, type ExtractTextResult } from "../../i
import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file"
import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
import { FileContextTracker } from "../context-tracking/FileContextTracker"
import { RooIgnoreController } from "../ignore/RooIgnoreController"
import { getCommand, type Command } from "../../services/command/commands"
import { t } from "../../i18n"
function getUrlErrorMessage(error: unknown): string {
const errorMessage = error instanceof Error ? error.message : String(error)
// Check for common error patterns and return appropriate message
if (errorMessage.includes("timeout")) {
return t("common:errors.url_timeout")
}
if (errorMessage.includes("net::ERR_NAME_NOT_RESOLVED")) {
return t("common:errors.url_not_found")
}
if (errorMessage.includes("net::ERR_INTERNET_DISCONNECTED")) {
return t("common:errors.no_internet")
}
if (errorMessage.includes("net::ERR_ABORTED")) {
return t("common:errors.url_request_aborted")
}
if (errorMessage.includes("403") || errorMessage.includes("Forbidden")) {
return t("common:errors.url_forbidden")
}
if (errorMessage.includes("404") || errorMessage.includes("Not Found")) {
return t("common:errors.url_page_not_found")
}
// Default error message
return t("common:errors.url_fetch_failed", { error: errorMessage })
}
export async function openMention(cwd: string, mention?: string): Promise<void> {
if (!mention) {
return
@ -128,7 +97,6 @@ ${result.content}`
export async function parseMentions(
text: string,
cwd: string,
urlContentFetcher: UrlContentFetcher,
fileContextTracker?: FileContextTracker,
rooIgnoreController?: RooIgnoreController,
showRooIgnoredFiles: boolean = false,
@ -180,8 +148,7 @@ export async function parseMentions(
parsedText = parsedText.replace(mentionRegexGlobal, (match, mention) => {
mentions.add(mention)
if (mention.startsWith("http")) {
// Keep old style for URLs (still XML-based)
return `'${mention}' (see below for site content)`
return `'${mention}'`
} else if (mention.startsWith("/")) {
// Clean path reference - no "see below" since we format like tool results
const mentionPath = mention.slice(1)
@ -198,49 +165,8 @@ export async function parseMentions(
return match
})
const urlMention = Array.from(mentions).find((mention) => mention.startsWith("http"))
let launchBrowserError: Error | undefined
if (urlMention) {
try {
await urlContentFetcher.launchBrowser()
} catch (error) {
launchBrowserError = error
const errorMessage = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(`Error fetching content for ${urlMention}: ${errorMessage}`)
}
}
for (const mention of mentions) {
if (mention.startsWith("http")) {
let result: string
if (launchBrowserError) {
const errorMessage =
launchBrowserError instanceof Error ? launchBrowserError.message : String(launchBrowserError)
result = `Error fetching content: ${errorMessage}`
} else {
try {
const markdown = await urlContentFetcher.urlToMarkdown(mention)
result = markdown
} catch (error) {
console.error(`Error fetching URL ${mention}:`, error)
// Get raw error message for AI
const rawErrorMessage = error instanceof Error ? error.message : String(error)
// Get localized error message for UI notification
const localizedErrorMessage = getUrlErrorMessage(error)
vscode.window.showErrorMessage(
t("common:errors.url_fetch_error_with_url", { url: mention, error: localizedErrorMessage }),
)
// Send raw error message to AI model
result = `Error fetching content: ${rawErrorMessage}`
}
}
// URLs still use XML format (appended to text for backwards compat)
parsedText += `\n\n<url_content url="${mention}">\n${result}\n</url_content>`
} else if (mention.startsWith("/")) {
if (mention.startsWith("/")) {
const mentionPath = mention.slice(1)
try {
const fileResult = await getFileOrFolderContentWithMetadata(
@ -305,14 +231,6 @@ export async function parseMentions(
}
}
if (urlMention) {
try {
await urlContentFetcher.closeBrowser()
} catch (error) {
console.error(`Error closing browser: ${error.message}`)
}
}
return {
text: parsedText,
contentBlocks,

View file

@ -1,10 +1,9 @@
import type { TextPart, ImagePart } from "../task-persistence/rooMessage"
import type { TextPart, ImagePart, LegacyToolResultBlock } from "../task-persistence/rooMessage"
import { parseMentions, ParseMentionsResult, MentionContentBlock } from "./index"
import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
import { FileContextTracker } from "../context-tracking/FileContextTracker"
export interface ProcessUserContentMentionsResult {
content: Array<TextPart | ImagePart>
content: Array<TextPart | ImagePart | LegacyToolResultBlock>
mode?: string // Mode from the first slash command that has one
}
@ -30,16 +29,14 @@ function contentBlocksToTextParts(contentBlocks: MentionContentBlock[]): TextPar
export async function processUserContentMentions({
userContent,
cwd,
urlContentFetcher,
fileContextTracker,
rooIgnoreController,
showRooIgnoredFiles = false,
includeDiagnosticMessages = true,
maxDiagnosticMessages = 50,
}: {
userContent: Array<TextPart | ImagePart>
userContent: Array<TextPart | ImagePart | LegacyToolResultBlock>
cwd: string
urlContentFetcher: UrlContentFetcher
fileContextTracker: FileContextTracker
rooIgnoreController?: any
showRooIgnoredFiles?: boolean
@ -61,7 +58,6 @@ export async function processUserContentMentions({
const result = await parseMentions(
block.text,
cwd,
urlContentFetcher,
fileContextTracker,
rooIgnoreController,
showRooIgnoredFiles,
@ -98,6 +94,106 @@ export async function processUserContentMentions({
return blocks
}
return block
} else if (block.type === "tool_result") {
if (typeof block.content === "string") {
if (shouldProcessMentions(block.content)) {
const result = await parseMentions(
block.content,
cwd,
fileContextTracker,
rooIgnoreController,
showRooIgnoredFiles,
includeDiagnosticMessages,
maxDiagnosticMessages,
)
// Capture the first mode found
if (!commandMode && result.mode) {
commandMode = result.mode
}
// Build content array with file blocks included
const contentParts: Array<{ type: "text"; text: string }> = [
{
type: "text" as const,
text: result.text,
},
]
// Add file/folder content blocks
for (const contentBlock of result.contentBlocks) {
contentParts.push({
type: "text" as const,
text: contentBlock.content,
})
}
if (result.slashCommandHelp) {
contentParts.push({
type: "text" as const,
text: result.slashCommandHelp,
})
}
return {
...block,
content: contentParts,
}
}
return block
} else if (Array.isArray(block.content)) {
const parsedContent = (
await Promise.all(
block.content.map(async (contentBlock) => {
if (contentBlock.type === "text" && shouldProcessMentions(contentBlock.text)) {
const result = await parseMentions(
contentBlock.text,
cwd,
fileContextTracker,
rooIgnoreController,
showRooIgnoredFiles,
includeDiagnosticMessages,
maxDiagnosticMessages,
)
// Capture the first mode found
if (!commandMode && result.mode) {
commandMode = result.mode
}
// Build blocks array with file content
const blocks: Array<{ type: "text"; text: string }> = [
{
...contentBlock,
text: result.text,
},
]
// Add file/folder content blocks
for (const cb of result.contentBlocks) {
blocks.push({
type: "text" as const,
text: cb.content,
})
}
if (result.slashCommandHelp) {
blocks.push({
type: "text" as const,
text: result.slashCommandHelp,
})
}
return blocks
}
return contentBlock
}),
)
).flat()
return { ...block, content: parsedContent }
}
return block
}
@ -108,5 +204,5 @@ export async function processUserContentMentions({
)
).flat()
return { content: content as Array<TextPart | ImagePart>, mode: commandMode }
return { content: content as Array<TextPart | ImagePart | LegacyToolResultBlock>, mode: commandMode }
}

View file

@ -205,7 +205,6 @@ describe("addCustomInstructions", () => {
false, // supportsImages
undefined, // mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
"architect", // mode
undefined, // customModePrompts
undefined, // customModes
@ -226,7 +225,6 @@ describe("addCustomInstructions", () => {
false, // supportsImages
undefined, // mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
"ask", // mode
undefined, // customModePrompts
undefined, // customModes
@ -249,7 +247,6 @@ describe("addCustomInstructions", () => {
false, // supportsImages
mockMcpHub, // mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
defaultModeSlug, // mode
undefined, // customModePrompts
undefined, // customModes,

View file

@ -220,7 +220,6 @@ describe("SYSTEM_PROMPT", () => {
false, // supportsImages
undefined, // mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
defaultModeSlug, // mode
undefined, // customModePrompts
undefined, // customModes
@ -233,26 +232,6 @@ describe("SYSTEM_PROMPT", () => {
expect(prompt).toMatchFileSnapshot("./__snapshots__/system-prompt/consistent-system-prompt.snap")
})
it("should include browser actions when supportsImages is true", async () => {
const prompt = await SYSTEM_PROMPT(
mockContext,
"/test/path",
true, // supportsImages
undefined, // mcpHub
undefined, // diffStrategy
"1280x800", // browserViewportSize
defaultModeSlug, // mode
undefined, // customModePrompts
undefined, // customModes,
undefined, // globalCustomInstructions
experiments,
undefined, // language
undefined, // rooIgnoreInstructions
)
expect(prompt).toMatchFileSnapshot("./__snapshots__/system-prompt/with-computer-use-support.snap")
})
it("should include MCP server info when mcpHub is provided", async () => {
mockMcpHub = createMockMcpHub(true)
@ -262,7 +241,6 @@ describe("SYSTEM_PROMPT", () => {
false,
mockMcpHub, // mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
defaultModeSlug, // mode
undefined, // customModePrompts
undefined, // customModes,
@ -282,7 +260,6 @@ describe("SYSTEM_PROMPT", () => {
false,
undefined, // explicitly undefined mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
defaultModeSlug, // mode
undefined, // customModePrompts
undefined, // customModes,
@ -295,26 +272,6 @@ describe("SYSTEM_PROMPT", () => {
expect(prompt).toMatchFileSnapshot("./__snapshots__/system-prompt/with-undefined-mcp-hub.snap")
})
it("should handle different browser viewport sizes", async () => {
const prompt = await SYSTEM_PROMPT(
mockContext,
"/test/path",
false,
undefined, // mcpHub
undefined, // diffStrategy
"900x600", // different viewport size
defaultModeSlug, // mode
undefined, // customModePrompts
undefined, // customModes,
undefined, // globalCustomInstructions
experiments,
undefined, // language
undefined, // rooIgnoreInstructions
)
expect(prompt).toMatchFileSnapshot("./__snapshots__/system-prompt/with-different-viewport-size.snap")
})
it("should include vscode language in custom instructions", async () => {
// Mock vscode.env.language
const vscode = vi.mocked(await import("vscode")) as any
@ -349,7 +306,6 @@ describe("SYSTEM_PROMPT", () => {
false,
undefined, // mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
defaultModeSlug, // mode
undefined, // customModePrompts
undefined, // customModes
@ -407,7 +363,6 @@ describe("SYSTEM_PROMPT", () => {
false,
undefined, // mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
"custom-mode", // mode
undefined, // customModePrompts
customModes, // customModes
@ -442,7 +397,6 @@ describe("SYSTEM_PROMPT", () => {
false,
undefined, // mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
defaultModeSlug as Mode, // mode
customModePrompts, // customModePrompts
undefined, // customModes
@ -472,7 +426,6 @@ describe("SYSTEM_PROMPT", () => {
false,
undefined, // mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
defaultModeSlug as Mode, // mode
customModePrompts, // customModePrompts
undefined, // customModes
@ -499,7 +452,6 @@ describe("SYSTEM_PROMPT", () => {
false,
undefined, // mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
defaultModeSlug, // mode
undefined, // customModePrompts
undefined, // customModes
@ -528,7 +480,6 @@ describe("SYSTEM_PROMPT", () => {
false,
undefined, // mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
defaultModeSlug, // mode
undefined, // customModePrompts
undefined, // customModes
@ -557,7 +508,6 @@ describe("SYSTEM_PROMPT", () => {
false,
undefined, // mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
defaultModeSlug, // mode
undefined, // customModePrompts
undefined, // customModes
@ -586,7 +536,6 @@ describe("SYSTEM_PROMPT", () => {
false,
undefined, // mcpHub
undefined, // diffStrategy
undefined, // browserViewportSize
defaultModeSlug, // mode
undefined, // customModePrompts
undefined, // customModes

View file

@ -45,7 +45,6 @@ async function generatePrompt(
mode: Mode,
mcpHub?: McpHub,
diffStrategy?: DiffStrategy,
browserViewportSize?: string,
promptComponent?: PromptComponent,
customModeConfigs?: ModeConfig[],
globalCustomInstructions?: string,
@ -116,7 +115,6 @@ export const SYSTEM_PROMPT = async (
supportsComputerUse: boolean,
mcpHub?: McpHub,
diffStrategy?: DiffStrategy,
browserViewportSize?: string,
mode: Mode = defaultModeSlug,
customModePrompts?: CustomModePrompts,
customModes?: ModeConfig[],
@ -146,7 +144,6 @@ export const SYSTEM_PROMPT = async (
currentMode.slug,
mcpHub,
diffStrategy,
browserViewportSize,
promptComponent,
customModes,
globalCustomInstructions,

View file

@ -20,21 +20,19 @@ describe("filterNativeToolsForMode - disabledTools", () => {
makeTool("execute_command"),
makeTool("read_file"),
makeTool("write_to_file"),
makeTool("browser_action"),
makeTool("apply_diff"),
makeTool("edit"),
]
it("removes tools listed in settings.disabledTools", () => {
const settings = {
disabledTools: ["execute_command", "browser_action"],
disabledTools: ["execute_command"],
}
const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings)
const resultNames = result.map((t) => (t as any).function.name)
expect(resultNames).not.toContain("execute_command")
expect(resultNames).not.toContain("browser_action")
expect(resultNames).toContain("read_file")
expect(resultNames).toContain("write_to_file")
expect(resultNames).toContain("apply_diff")
@ -51,7 +49,6 @@ describe("filterNativeToolsForMode - disabledTools", () => {
expect(resultNames).toContain("execute_command")
expect(resultNames).toContain("read_file")
expect(resultNames).toContain("write_to_file")
expect(resultNames).toContain("browser_action")
expect(resultNames).toContain("apply_diff")
})
@ -67,7 +64,6 @@ describe("filterNativeToolsForMode - disabledTools", () => {
it("combines disabledTools with other setting-based exclusions", () => {
const settings = {
browserToolEnabled: false,
disabledTools: ["execute_command"],
}
@ -75,7 +71,6 @@ describe("filterNativeToolsForMode - disabledTools", () => {
const resultNames = result.map((t) => (t as any).function.name)
expect(resultNames).not.toContain("execute_command")
expect(resultNames).not.toContain("browser_action")
expect(resultNames).toContain("read_file")
})

View file

@ -291,11 +291,6 @@ export function filterNativeToolsForMode(
allowedToolNames.delete("run_slash_command")
}
// Conditionally exclude browser_action if disabled in settings
if (settings?.browserToolEnabled === false) {
allowedToolNames.delete("browser_action")
}
// Remove tools that are explicitly disabled via the disabledTools setting
if (settings?.disabledTools?.length) {
for (const toolName of settings.disabledTools) {
@ -387,11 +382,6 @@ export function isToolAllowedInMode(
return true
}
// Check for browser_action being disabled by user settings
if (toolName === "browser_action" && settings?.browserToolEnabled === false) {
return false
}
// Check if the tool is allowed by the mode's groups
// Resolve to canonical name and check that single value
const canonicalTool = resolveToolAlias(toolName)

View file

@ -1,76 +0,0 @@
import type OpenAI from "openai"
const BROWSER_ACTION_DESCRIPTION = `Request to interact with a Puppeteer-controlled browser. Every action, except close, will be responded to with a screenshot of the browser's current state, along with any new console logs. You may only perform one browser action per message, and wait for the user's response including a screenshot and logs to determine the next action.
This tool is particularly useful for web development tasks as it allows you to launch a browser, navigate to pages, interact with elements through clicks and keyboard input, and capture the results through screenshots and console logs. Use it at key stages of web development tasks - such as after implementing new features, making substantial changes, when troubleshooting issues, or to verify the result of your work. Analyze the provided screenshots to ensure correct rendering or identify errors, and review console logs for runtime issues.
The user may ask generic non-development tasks (such as "what's the latest news" or "look up the weather"), in which case you might use this tool to complete the task if it makes sense to do so, rather than trying to create a website or using curl to answer the question. However, if an available MCP server tool or resource can be used instead, you should prefer to use it over browser_action.
Browser Session Lifecycle:
- Browser sessions start with launch and end with close
- The session remains active across multiple messages and tool uses
- You can use other tools while the browser session is active - it will stay open in the background`
const ACTION_PARAMETER_DESCRIPTION = `Browser action to perform`
const URL_PARAMETER_DESCRIPTION = `URL to open when performing the launch action; must include protocol`
const COORDINATE_PARAMETER_DESCRIPTION = `Screen coordinate for hover or click actions in format 'x,y@WIDTHxHEIGHT' where x,y is the target position on the screenshot image and WIDTHxHEIGHT is the exact pixel dimensions of the screenshot image (not the browser viewport). Example: '450,203@900x600' means click at (450,203) on a 900x600 screenshot. The coordinates will be automatically scaled to match the actual viewport dimensions.`
const SIZE_PARAMETER_DESCRIPTION = `Viewport dimensions for the resize action in format 'WIDTHxHEIGHT' or 'WIDTH,HEIGHT'. Example: '1280x800' or '1280,800'`
const TEXT_PARAMETER_DESCRIPTION = `Text to type when performing the type action, or key name to press when performing the press action (e.g., 'Enter', 'Tab', 'Escape')`
const PATH_PARAMETER_DESCRIPTION = `File path where the screenshot should be saved (relative to workspace). Required for screenshot action. Supports .png, .jpeg, and .webp extensions. Example: 'screenshots/result.png'`
export default {
type: "function",
function: {
name: "browser_action",
description: BROWSER_ACTION_DESCRIPTION,
strict: false,
parameters: {
type: "object",
properties: {
action: {
type: "string",
description: ACTION_PARAMETER_DESCRIPTION,
enum: [
"launch",
"click",
"hover",
"type",
"press",
"scroll_down",
"scroll_up",
"resize",
"close",
"screenshot",
],
},
url: {
type: ["string", "null"],
description: URL_PARAMETER_DESCRIPTION,
},
coordinate: {
type: ["string", "null"],
description: COORDINATE_PARAMETER_DESCRIPTION,
},
size: {
type: ["string", "null"],
description: SIZE_PARAMETER_DESCRIPTION,
},
text: {
type: ["string", "null"],
description: TEXT_PARAMETER_DESCRIPTION,
},
path: {
type: ["string", "null"],
description: PATH_PARAMETER_DESCRIPTION,
},
},
required: ["action"],
additionalProperties: false,
},
},
} satisfies OpenAI.Chat.ChatCompletionTool

View file

@ -4,7 +4,6 @@ import { apply_diff } from "./apply_diff"
import applyPatch from "./apply_patch"
import askFollowupQuestion from "./ask_followup_question"
import attemptCompletion from "./attempt_completion"
import browserAction from "./browser_action"
import codebaseSearch from "./codebase_search"
import editTool from "./edit"
import executeCommand from "./execute_command"
@ -53,7 +52,6 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch
applyPatch,
askFollowupQuestion,
attemptCompletion,
browserAction,
codebaseSearch,
executeCommand,
generateImage,

View file

@ -3,7 +3,6 @@
*/
export interface SystemPromptSettings {
todoListEnabled: boolean
browserToolEnabled?: boolean
useAgentRules: boolean
/** When true, recursively discover and load .roo/rules from subdirectories */
enableSubfolderRules?: boolean

View file

@ -17,8 +17,10 @@ import type { TextPart, ImagePart, FilePart, ToolCallPart, ToolResultPart } from
/**
* Union of content parts that can appear in a user message's content array.
* Includes `LegacyToolResultBlock` for backward compatibility with persisted
* data that stores Anthropic-format tool_result blocks inline in user messages.
*/
export type UserContentPart = TextPart | ImagePart | FilePart
export type UserContentPart = TextPart | ImagePart | FilePart | LegacyToolResultBlock
/**
* A minimal content block with a type discriminator and optional text.
@ -73,9 +75,14 @@ export interface RooMessageMetadata {
/**
* A user-authored message. Content may be a plain string or an array of
* text, image, and file parts. Extends AI SDK `UserModelMessage` with metadata.
* text, image, file, and legacy tool-result parts.
* Overrides the AI SDK `content` field to include `LegacyToolResultBlock`
* for backward compatibility with persisted data.
*/
export type RooUserMessage = UserModelMessage & RooMessageMetadata
export type RooUserMessage = Omit<UserModelMessage, "content"> &
RooMessageMetadata & {
content: string | UserContentPart[]
}
/**
* An assistant-authored message. Content may be a plain string or an array of
@ -215,11 +222,17 @@ export interface LegacyToolUseBlock {
input: unknown
}
/** A text content block within a legacy Anthropic tool result. */
export interface LegacyToolResultTextBlock {
type: "text"
text: string
}
/** Legacy Anthropic `tool_result` content block shape (persisted data from older versions). */
export interface LegacyToolResultBlock {
type: "tool_result"
tool_use_id: string
content?: string | ContentBlockParam[]
content?: string | LegacyToolResultTextBlock[]
is_error?: boolean
}

View file

@ -71,13 +71,11 @@ import { combineCommandSequences } from "../../shared/combineCommandSequences"
import { t } from "../../i18n"
import { getApiMetrics, hasTokenUsageChanged, hasToolUsageChanged } from "../../shared/getApiMetrics"
import { ClineAskResponse } from "../../shared/WebviewMessage"
import { defaultModeSlug, getModeBySlug, getGroupName } from "../../shared/modes"
import { defaultModeSlug, getModeBySlug } from "../../shared/modes"
import { DiffStrategy, type ToolUse, type ToolParamName, toolParamNames } from "../../shared/tools"
import { getModelMaxOutputTokens } from "../../shared/api"
// services
import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
import { BrowserSession } from "../../services/browser/BrowserSession"
import { McpHub } from "../../services/mcp/McpHub"
import { McpServerManager } from "../../services/mcp/McpServerManager"
import { RepoPerTaskCheckpointService } from "../../services/checkpoints"
@ -326,12 +324,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
rooIgnoreController?: RooIgnoreController
rooProtectedController?: RooProtectedController
fileContextTracker: FileContextTracker
urlContentFetcher: UrlContentFetcher
terminalProcess?: RooTerminalProcess
// Computer User
browserSession: BrowserSession
// Editing
diffViewProvider: DiffViewProvider
diffStrategy?: DiffStrategy
@ -644,29 +638,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.api = buildApiHandler(this.apiConfiguration)
this.autoApprovalHandler = new AutoApprovalHandler()
this.urlContentFetcher = new UrlContentFetcher(provider.context)
this.browserSession = new BrowserSession(provider.context, (isActive: boolean) => {
// Add a message to indicate browser session status change
this.say("browser_session_status", isActive ? "Browser session opened" : "Browser session closed")
// Broadcast to browser panel
this.broadcastBrowserSessionUpdate()
// When a browser session becomes active, automatically open/reveal the Browser Session tab
if (isActive) {
try {
// Lazy-load to avoid circular imports at module load time
const { BrowserSessionPanelManager } = require("../webview/BrowserSessionPanelManager")
const providerRef = this.providerRef.deref()
if (providerRef) {
BrowserSessionPanelManager.getInstance(providerRef)
.show()
.catch(() => {})
}
} catch (err) {
console.error("[Task] Failed to auto-open Browser Session panel:", err)
}
}
})
this.consecutiveMistakeLimit = consecutiveMistakeLimit ?? DEFAULT_CONSECUTIVE_MISTAKE_LIMIT
this.providerRef = new WeakRef(provider)
this.globalStoragePath = provider.context.globalStorageUri.fsPath
@ -1601,12 +1572,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
if (message) {
// Check if this is a tool approval ask that needs to be handled.
if (
type === "tool" ||
type === "command" ||
type === "browser_action_launch" ||
type === "use_mcp_server"
) {
if (type === "tool" || type === "command" || type === "use_mcp_server") {
// For tool approvals, we need to approve first, then send
// the message if there's text/images.
this.handleWebviewAskResponse("yesButtonClicked", message.text, message.images)
@ -1633,12 +1599,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
if (message) {
// If this is a tool approval ask, we need to approve first (yesButtonClicked)
// and include any queued text/images.
if (
type === "tool" ||
type === "command" ||
type === "browser_action_launch" ||
type === "use_mcp_server"
) {
if (type === "tool" || type === "command" || type === "use_mcp_server") {
this.handleWebviewAskResponse("yesButtonClicked", message.text, message.images)
} else {
this.handleWebviewAskResponse("messageResponse", message.text, message.images)
@ -1836,7 +1797,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
customModes: state?.customModes,
experiments: state?.experiments,
apiConfiguration,
browserToolEnabled: state?.browserToolEnabled ?? true,
disabledTools: state?.disabledTools,
modelInfo,
includeAllToolsWithRestrictions: false,
@ -2030,11 +1990,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
contextTruncation,
})
}
// Broadcast browser session updates to panel when browser-related messages are added
if (type === "browser_action" || type === "browser_action_result" || type === "browser_session_status") {
this.broadcastBrowserSessionUpdate()
}
}
async sayAndCreateMissingParamError(toolName: ToolName, paramName: string, relPath?: string) {
@ -2572,28 +2527,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
console.error("Error cleaning up command output artifacts:", error)
})
try {
this.urlContentFetcher.closeBrowser()
} catch (error) {
console.error("Error closing URL content fetcher browser:", error)
}
try {
this.browserSession.closeBrowser()
} catch (error) {
console.error("Error closing browser session:", error)
}
// Also close the Browser Session panel when the task is disposed
try {
const provider = this.providerRef.deref()
if (provider) {
const { BrowserSessionPanelManager } = require("../webview/BrowserSessionPanelManager")
BrowserSessionPanelManager.getInstance(provider).dispose()
}
} catch (error) {
console.error("Error closing browser session panel:", error)
}
try {
if (this.rooIgnoreController) {
this.rooIgnoreController.dispose()
@ -2846,7 +2779,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const { content: parsedUserContent, mode: slashCommandMode } = await processUserContentMentions({
userContent: currentUserContent as Array<TextPart | ImagePart>,
cwd: this.cwd,
urlContentFetcher: this.urlContentFetcher,
fileContextTracker: this.fileContextTracker,
rooIgnoreController: this.rooIgnoreController,
showRooIgnoredFiles,
@ -3985,13 +3917,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const state = await this.providerRef.deref()?.getState()
const {
browserViewportSize,
mode,
customModes,
customModePrompts,
customInstructions,
experiments,
browserToolEnabled,
language,
apiConfiguration,
enableSubfolderRules,
@ -4004,24 +3934,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
throw new Error("Provider not available")
}
// Align browser tool enablement with generateSystemPrompt: require model image support,
// mode to include the browser group, and the user setting to be enabled.
const modeConfig = getModeBySlug(mode ?? defaultModeSlug, customModes)
const modeSupportsBrowser = modeConfig?.groups.some((group) => getGroupName(group) === "browser") ?? false
// Check if model supports browser capability (images)
const modelInfo = this.api.getModel().info
const modelSupportsBrowser = (modelInfo as any)?.supportsImages === true
const canUseBrowserTool = modelSupportsBrowser && modeSupportsBrowser && (browserToolEnabled ?? true)
return SYSTEM_PROMPT(
provider.context,
this.cwd,
canUseBrowserTool,
false,
mcpHub,
this.diffStrategy,
browserViewportSize ?? "900x600",
mode ?? defaultModeSlug,
customModePrompts,
customModes,
@ -4031,7 +3951,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
rooIgnoreInstructions,
{
todoListEnabled: apiConfiguration?.todoListEnabled ?? true,
browserToolEnabled: browserToolEnabled ?? true,
useAgentRules:
vscode.workspace.getConfiguration(Package.name).get<boolean>("useAgentRules") ?? true,
enableSubfolderRules: enableSubfolderRules ?? false,
@ -4092,7 +4011,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
customModes: state?.customModes,
experiments: state?.experiments,
apiConfiguration,
browserToolEnabled: state?.browserToolEnabled ?? true,
disabledTools: state?.disabledTools,
modelInfo,
includeAllToolsWithRestrictions: false,
@ -4307,7 +4225,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
customModes: state?.customModes,
experiments: state?.experiments,
apiConfiguration,
browserToolEnabled: state?.browserToolEnabled ?? true,
disabledTools: state?.disabledTools,
modelInfo,
includeAllToolsWithRestrictions: false,
@ -4472,7 +4389,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
customModes: state?.customModes,
experiments: state?.experiments,
apiConfiguration,
browserToolEnabled: state?.browserToolEnabled ?? true,
disabledTools: state?.disabledTools,
modelInfo,
includeAllToolsWithRestrictions: supportsAllowedFunctionNames,
@ -4944,41 +4860,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
return this._messageManager
}
/**
* Broadcast browser session updates to the browser panel (if open)
*/
private broadcastBrowserSessionUpdate(): void {
const provider = this.providerRef.deref()
if (!provider) {
return
}
try {
const { BrowserSessionPanelManager } = require("../webview/BrowserSessionPanelManager")
const panelManager = BrowserSessionPanelManager.getInstance(provider)
// Get browser session messages
const browserSessionStartIndex = this.clineMessages.findIndex(
(m) =>
m.ask === "browser_action_launch" ||
(m.say === "browser_session_status" && m.text?.includes("opened")),
)
const browserSessionMessages =
browserSessionStartIndex !== -1 ? this.clineMessages.slice(browserSessionStartIndex) : []
const isBrowserSessionActive = this.browserSession?.isSessionActive() ?? false
// Update the panel asynchronously
panelManager.updateBrowserSession(browserSessionMessages, isBrowserSessionActive).catch((error: Error) => {
console.error("Failed to broadcast browser session update:", error)
})
} catch (error) {
// Silently fail if panel manager is not available
console.debug("Browser panel not available for update:", error)
}
}
/**
* Process any queued messages by dequeuing and submitting them.
* This ensures that queued user messages are sent when appropriate,

View file

@ -13,8 +13,6 @@ vi.mock("../../../integrations/terminal/TerminalRegistry", () => ({
vi.mock("../../ignore/RooIgnoreController")
vi.mock("../../protect/RooProtectedController")
vi.mock("../../context-tracking/FileContextTracker")
vi.mock("../../../services/browser/UrlContentFetcher")
vi.mock("../../../services/browser/BrowserSession")
vi.mock("../../../integrations/editor/DiffViewProvider")
vi.mock("../../tools/ToolRepetitionDetector")
vi.mock("../../../api", () => ({

View file

@ -985,7 +985,6 @@ describe("Cline", () => {
const { content: processedContent } = await processUserContentMentions({
userContent,
cwd: cline.cwd,
urlContentFetcher: cline.urlContentFetcher,
fileContextTracker: cline.fileContextTracker,
})

View file

@ -14,8 +14,6 @@ vi.mock("../../../integrations/terminal/TerminalRegistry", () => ({
vi.mock("../../ignore/RooIgnoreController")
vi.mock("../../protect/RooProtectedController")
vi.mock("../../context-tracking/FileContextTracker")
vi.mock("../../../services/browser/UrlContentFetcher")
vi.mock("../../../services/browser/BrowserSession")
vi.mock("../../../integrations/editor/DiffViewProvider")
vi.mock("../../tools/ToolRepetitionDetector")
vi.mock("../../../api", () => ({

View file

@ -10,14 +10,14 @@ describe("Native Tools Filtering by Mode", () => {
slug: "architect",
name: "Architect",
roleDefinition: "Test architect",
groups: ["read", "browser", "mcp"] as const,
groups: ["read", "mcp"] as const,
}
const codeMode: ModeConfig = {
slug: "code",
name: "Code",
roleDefinition: "Test code",
groups: ["read", "edit", "browser", "command", "mcp"] as const,
groups: ["read", "edit", "command", "mcp"] as const,
}
// Import the functions we need to test

View file

@ -22,7 +22,6 @@ interface BuildToolsOptions {
customModes: ModeConfig[] | undefined
experiments: Record<string, boolean> | undefined
apiConfiguration: ProviderSettings | undefined
browserToolEnabled: boolean
disabledTools?: string[]
modelInfo?: ModelInfo
/**
@ -88,7 +87,6 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO
customModes,
experiments,
apiConfiguration,
browserToolEnabled,
disabledTools,
modelInfo,
includeAllToolsWithRestrictions,
@ -103,7 +101,6 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO
// Build settings object for tool filtering.
const filterSettings = {
todoListEnabled: apiConfiguration?.todoListEnabled ?? true,
browserToolEnabled: browserToolEnabled ?? true,
disabledTools,
modelInfo,
}

View file

@ -1,280 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { BrowserAction, BrowserActionResult, browserActions, ClineSayBrowserAction } from "@roo-code/types"
import { Task } from "../task/Task"
import { ToolUse, AskApproval, HandleError, PushToolResult } from "../../shared/tools"
import { formatResponse } from "../prompts/responses"
import { scaleCoordinate } from "../../shared/browserUtils"
export async function browserActionTool(
cline: Task,
block: ToolUse,
askApproval: AskApproval,
handleError: HandleError,
pushToolResult: PushToolResult,
) {
const action: BrowserAction | undefined = block.params.action as BrowserAction
const url: string | undefined = block.params.url
const coordinate: string | undefined = block.params.coordinate
const text: string | undefined = block.params.text
const size: string | undefined = block.params.size
const filePath: string | undefined = block.params.path
if (!action || !browserActions.includes(action)) {
// checking for action to ensure it is complete and valid
if (!block.partial) {
// if the block is complete and we don't have a valid action cline is a mistake
cline.consecutiveMistakeCount++
cline.recordToolError("browser_action")
cline.didToolFailInCurrentTurn = true
pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "action"))
// Do not close the browser on parameter validation errors
}
return
}
try {
if (block.partial) {
if (action === "launch") {
await cline.ask("browser_action_launch", url ?? "", block.partial).catch(() => {})
} else {
await cline.say(
"browser_action",
JSON.stringify({
action: action as BrowserAction,
coordinate: coordinate ?? "",
text: text ?? "",
size: size ?? "",
} satisfies ClineSayBrowserAction),
undefined,
block.partial,
)
}
return
} else {
// Initialize with empty object to avoid "used before assigned" errors
let browserActionResult: BrowserActionResult = {}
if (action === "launch") {
if (!url) {
cline.consecutiveMistakeCount++
cline.recordToolError("browser_action")
cline.didToolFailInCurrentTurn = true
pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "url"))
// Do not close the browser on parameter validation errors
return
}
cline.consecutiveMistakeCount = 0
const didApprove = await askApproval("browser_action_launch", url)
if (!didApprove) {
return
}
// NOTE: It's okay that we call cline message since the partial inspect_site is finished streaming.
// The only scenario we have to avoid is sending messages WHILE a partial message exists at the end of the messages array.
// For example the api_req_finished message would interfere with the partial message, so we needed to remove that.
// Launch browser first (this triggers "Browser session opened" status message)
await cline.browserSession.launchBrowser()
// Create browser_action say message AFTER launching so status appears first
await cline.say(
"browser_action",
JSON.stringify({
action: "launch" as BrowserAction,
text: url,
} satisfies ClineSayBrowserAction),
undefined,
false,
)
browserActionResult = await cline.browserSession.navigateToUrl(url)
} else {
// Variables to hold validated and processed parameters
let processedCoordinate = coordinate
if (action === "click" || action === "hover") {
if (!coordinate) {
cline.consecutiveMistakeCount++
cline.recordToolError("browser_action")
cline.didToolFailInCurrentTurn = true
pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "coordinate"))
// Do not close the browser on parameter validation errors
return // can't be within an inner switch
}
// Get viewport dimensions from the browser session
const viewportSize = cline.browserSession.getViewportSize()
const viewportWidth = viewportSize.width || 900 // default to 900 if not available
const viewportHeight = viewportSize.height || 600 // default to 600 if not available
// Scale coordinate from image dimensions to viewport dimensions
try {
processedCoordinate = scaleCoordinate(coordinate, viewportWidth, viewportHeight)
} catch (error) {
cline.consecutiveMistakeCount++
cline.recordToolError("browser_action")
cline.didToolFailInCurrentTurn = true
pushToolResult(
await cline.sayAndCreateMissingParamError(
"browser_action",
"coordinate",
error instanceof Error ? error.message : String(error),
),
)
return
}
}
if (action === "type" || action === "press") {
if (!text) {
cline.consecutiveMistakeCount++
cline.recordToolError("browser_action")
cline.didToolFailInCurrentTurn = true
pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "text"))
// Do not close the browser on parameter validation errors
return
}
}
if (action === "resize") {
if (!size) {
cline.consecutiveMistakeCount++
cline.recordToolError("browser_action")
cline.didToolFailInCurrentTurn = true
pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "size"))
// Do not close the browser on parameter validation errors
return
}
}
if (action === "screenshot") {
if (!filePath) {
cline.consecutiveMistakeCount++
cline.recordToolError("browser_action")
cline.didToolFailInCurrentTurn = true
pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "path"))
// Do not close the browser on parameter validation errors
return
}
}
cline.consecutiveMistakeCount = 0
// Prepare say payload; include executedCoordinate for pointer actions
const sayPayload: ClineSayBrowserAction & { executedCoordinate?: string } = {
action: action as BrowserAction,
coordinate,
text,
size,
}
if ((action === "click" || action === "hover") && processedCoordinate) {
sayPayload.executedCoordinate = processedCoordinate
}
await cline.say("browser_action", JSON.stringify(sayPayload), undefined, false)
switch (action) {
case "click":
browserActionResult = await cline.browserSession.click(processedCoordinate!)
break
case "hover":
browserActionResult = await cline.browserSession.hover(processedCoordinate!)
break
case "type":
browserActionResult = await cline.browserSession.type(text!)
break
case "press":
browserActionResult = await cline.browserSession.press(text!)
break
case "scroll_down":
browserActionResult = await cline.browserSession.scrollDown()
break
case "scroll_up":
browserActionResult = await cline.browserSession.scrollUp()
break
case "resize":
browserActionResult = await cline.browserSession.resize(size!)
break
case "screenshot":
browserActionResult = await cline.browserSession.saveScreenshot(filePath!, cline.cwd)
break
case "close":
browserActionResult = await cline.browserSession.closeBrowser()
break
}
}
switch (action) {
case "launch":
case "click":
case "hover":
case "type":
case "press":
case "scroll_down":
case "scroll_up":
case "resize":
case "screenshot": {
await cline.say("browser_action_result", JSON.stringify(browserActionResult))
const images = browserActionResult?.screenshot ? [browserActionResult.screenshot] : []
let messageText =
action === "screenshot"
? `Screenshot saved to: ${filePath}`
: `The browser action has been executed.`
messageText += `\n\n**CRITICAL**: When providing click/hover coordinates:`
messageText += `\n1. Screenshot dimensions != Browser viewport dimensions`
messageText += `\n2. Measure x,y on the screenshot image you see below`
messageText += `\n3. Use format: <coordinate>x,y@WIDTHxHEIGHT</coordinate> where WIDTHxHEIGHT is the EXACT pixel size of the screenshot image`
messageText += `\n4. Never use the browser viewport size for WIDTHxHEIGHT - it is only for reference and is often larger than the screenshot`
messageText += `\n5. Screenshots are often downscaled - always use the dimensions you see in the image`
messageText += `\nExample: Viewport 1280x800, screenshot 1000x625, click (500,300) -> <coordinate>500,300@1000x625</coordinate>`
// Include browser viewport dimensions (for reference only)
if (browserActionResult?.viewportWidth && browserActionResult?.viewportHeight) {
messageText += `\n\nBrowser viewport: ${browserActionResult.viewportWidth}x${browserActionResult.viewportHeight}`
}
// Include cursor position if available
if (browserActionResult?.currentMousePosition) {
messageText += `\nCursor position: ${browserActionResult.currentMousePosition}`
}
messageText += `\n\nConsole logs:\n${browserActionResult?.logs || "(No new logs)"}\n`
if (images.length > 0) {
const blocks = [
...formatResponse.imageBlocks(images),
{ type: "text", text: messageText } as Anthropic.TextBlockParam,
]
pushToolResult(blocks)
} else {
pushToolResult(messageText)
}
break
}
case "close":
pushToolResult(
formatResponse.toolResult(
`The browser has been closed. You may now proceed to using other tools.`,
),
)
break
}
return
}
} catch (error) {
// Keep the browser session alive on errors; report the error without terminating the session
await handleError("executing browser action", error)
return
}
}

View file

@ -33,13 +33,6 @@ export class ToolRepetitionDetector {
messageDetail: string
}
} {
// Browser scroll actions should not be subject to repetition detection
// as they are frequently needed for navigating through web pages
if (this.isBrowserScrollAction(currentToolCallBlock)) {
// Allow browser scroll actions without counting them as repetitions
return { allowExecution: true }
}
// Serialize the block to a canonical JSON string for comparison
const currentToolCallJson = this.serializeToolUse(currentToolCallBlock)
@ -74,21 +67,6 @@ export class ToolRepetitionDetector {
return { allowExecution: true }
}
/**
* Checks if a tool use is a browser scroll action
*
* @param toolUse The ToolUse object to check
* @returns true if the tool is a browser_action with scroll_down or scroll_up action
*/
private isBrowserScrollAction(toolUse: ToolUse): boolean {
if (toolUse.name !== "browser_action") {
return false
}
const action = toolUse.params.action as string
return action === "scroll_down" || action === "scroll_up"
}
/**
* Serializes a ToolUse object into a canonical JSON string for comparison
*

View file

@ -1,84 +0,0 @@
// Test coordinate scaling functionality in browser actions
import { describe, it, expect } from "vitest"
import { scaleCoordinate } from "../../../shared/browserUtils"
describe("Browser Action Coordinate Scaling", () => {
describe("Coordinate format validation", () => {
it("should match valid coordinate format with image dimensions", () => {
const validFormats = [
"450,300@1024x768",
"0,0@1920x1080",
"1920,1080@1920x1080",
"100,200@800x600",
" 273 , 273 @ 1280x800 ",
"267,273@1280,800", // comma separator for dimensions
"450,300@1024,768", // comma separator for dimensions
]
validFormats.forEach((coord) => {
// Should not throw
expect(() => scaleCoordinate(coord, 900, 600)).not.toThrow()
})
})
it("should not match invalid coordinate formats", () => {
const invalidFormats = [
"450,300", // missing image dimensions
"450,300@", // incomplete dimensions
"450,300@1024", // missing height
"450,300@1024x", // missing height value
"@1024x768", // missing coordinates
"450@1024x768", // missing y coordinate
",300@1024x768", // missing x coordinate
"450,300@1024x768x2", // extra dimension
"a,b@1024x768", // non-numeric coordinates
"450,300@axb", // non-numeric dimensions
]
invalidFormats.forEach((coord) => {
expect(() => scaleCoordinate(coord, 900, 600)).toThrow()
})
})
})
describe("Coordinate scaling logic", () => {
it("should correctly scale coordinates from image to viewport", () => {
// Test case 1: Same dimensions (no scaling)
expect(scaleCoordinate("450,300@900x600", 900, 600)).toBe("450,300")
// Test case 2: Half dimensions (2x upscale)
expect(scaleCoordinate("225,150@450x300", 900, 600)).toBe("450,300")
// Test case 3: Double dimensions (0.5x downscale)
expect(scaleCoordinate("900,600@1800x1200", 900, 600)).toBe("450,300")
// Test case 4: Different aspect ratio
expect(scaleCoordinate("512,384@1024x768", 1920, 1080)).toBe("960,540")
// Test case 5: Edge cases (0,0)
expect(scaleCoordinate("0,0@1024x768", 1920, 1080)).toBe("0,0")
// Test case 6: Edge cases (max coordinates)
expect(scaleCoordinate("1024,768@1024x768", 1920, 1080)).toBe("1920,1080")
})
it("should throw error for invalid coordinate format", () => {
// Test invalid formats
expect(() => scaleCoordinate("450,300", 900, 600)).toThrow("Invalid coordinate format")
expect(() => scaleCoordinate("450,300@1024", 900, 600)).toThrow("Invalid coordinate format")
expect(() => scaleCoordinate("invalid", 900, 600)).toThrow("Invalid coordinate format")
})
it("should handle rounding correctly", () => {
// Test rounding behavior
// 333 / 1000 * 900 = 299.7 -> rounds to 300
expect(scaleCoordinate("333,333@1000x1000", 900, 900)).toBe("300,300")
// 666 / 1000 * 900 = 599.4 -> rounds to 599
expect(scaleCoordinate("666,666@1000x1000", 900, 900)).toBe("599,599")
// 500 / 1000 * 900 = 450.0 -> rounds to 450
expect(scaleCoordinate("500,500@1000x1000", 900, 900)).toBe("450,450")
})
})
})

View file

@ -1,25 +0,0 @@
import { browserActions } from "@roo-code/types"
describe("Browser Action Screenshot", () => {
describe("browserActions array", () => {
it("should include screenshot action", () => {
expect(browserActions).toContain("screenshot")
})
it("should have screenshot as a valid browser action type", () => {
const allActions = [
"launch",
"click",
"hover",
"type",
"press",
"scroll_down",
"scroll_up",
"resize",
"close",
"screenshot",
]
expect(browserActions).toEqual(allActions)
})
})
})

View file

@ -403,166 +403,6 @@ describe("ToolRepetitionDetector", () => {
})
})
// ===== Browser Scroll Action Exclusion tests =====
describe("browser scroll action exclusion", () => {
it("should not count browser scroll_down actions as repetitions", () => {
const detector = new ToolRepetitionDetector(2)
// Create browser_action tool use with scroll_down
const scrollDownTool: ToolUse = {
type: "tool_use",
name: "browser_action" as ToolName,
params: { action: "scroll_down" },
partial: false,
}
// Should allow unlimited scroll_down actions
for (let i = 0; i < 10; i++) {
const result = detector.check(scrollDownTool)
expect(result.allowExecution).toBe(true)
expect(result.askUser).toBeUndefined()
}
})
it("should not count browser scroll_up actions as repetitions", () => {
const detector = new ToolRepetitionDetector(2)
// Create browser_action tool use with scroll_up
const scrollUpTool: ToolUse = {
type: "tool_use",
name: "browser_action" as ToolName,
params: { action: "scroll_up" },
partial: false,
}
// Should allow unlimited scroll_up actions
for (let i = 0; i < 10; i++) {
const result = detector.check(scrollUpTool)
expect(result.allowExecution).toBe(true)
expect(result.askUser).toBeUndefined()
}
})
it("should not count alternating scroll_down and scroll_up as repetitions", () => {
const detector = new ToolRepetitionDetector(2)
const scrollDownTool: ToolUse = {
type: "tool_use",
name: "browser_action" as ToolName,
params: { action: "scroll_down" },
partial: false,
}
const scrollUpTool: ToolUse = {
type: "tool_use",
name: "browser_action" as ToolName,
params: { action: "scroll_up" },
partial: false,
}
// Alternate between scroll_down and scroll_up
for (let i = 0; i < 5; i++) {
let result = detector.check(scrollDownTool)
expect(result.allowExecution).toBe(true)
expect(result.askUser).toBeUndefined()
result = detector.check(scrollUpTool)
expect(result.allowExecution).toBe(true)
expect(result.askUser).toBeUndefined()
}
})
it("should still apply repetition detection to other browser_action types", () => {
const detector = new ToolRepetitionDetector(2)
// Create browser_action tool use with click action
const clickTool: ToolUse = {
type: "tool_use",
name: "browser_action" as ToolName,
params: { action: "click", coordinate: "[100, 200]" },
partial: false,
}
// First call allowed
expect(detector.check(clickTool).allowExecution).toBe(true)
// Second call allowed
expect(detector.check(clickTool).allowExecution).toBe(true)
// Third identical call should be blocked (limit is 2)
const result = detector.check(clickTool)
expect(result.allowExecution).toBe(false)
expect(result.askUser).toBeDefined()
})
it("should still apply repetition detection to non-browser tools", () => {
const detector = new ToolRepetitionDetector(2)
const readFileTool = createToolUse("read_file", "read_file", { path: "test.txt" })
// First call allowed
expect(detector.check(readFileTool).allowExecution).toBe(true)
// Second call allowed
expect(detector.check(readFileTool).allowExecution).toBe(true)
// Third identical call should be blocked (limit is 2)
const result = detector.check(readFileTool)
expect(result.allowExecution).toBe(false)
expect(result.askUser).toBeDefined()
})
it("should not interfere with repetition detection of other tools when scroll actions are interspersed", () => {
const detector = new ToolRepetitionDetector(2)
const scrollTool: ToolUse = {
type: "tool_use",
name: "browser_action" as ToolName,
params: { action: "scroll_down" },
partial: false,
}
const otherTool = createToolUse("execute_command", "execute_command", { command: "ls" })
// First execute_command
expect(detector.check(otherTool).allowExecution).toBe(true)
// Scroll actions in between (should not affect counter)
expect(detector.check(scrollTool).allowExecution).toBe(true)
expect(detector.check(scrollTool).allowExecution).toBe(true)
// Second execute_command
expect(detector.check(otherTool).allowExecution).toBe(true)
// More scroll actions
expect(detector.check(scrollTool).allowExecution).toBe(true)
// Third execute_command should be blocked
const result = detector.check(otherTool)
expect(result.allowExecution).toBe(false)
expect(result.askUser).toBeDefined()
})
it("should handle browser_action with missing or invalid action parameter gracefully", () => {
const detector = new ToolRepetitionDetector(2)
// Browser action without action parameter
const noActionTool: ToolUse = {
type: "tool_use",
name: "browser_action" as ToolName,
params: {},
partial: false,
}
// Should apply normal repetition detection
expect(detector.check(noActionTool).allowExecution).toBe(true)
expect(detector.check(noActionTool).allowExecution).toBe(true)
const result = detector.check(noActionTool)
expect(result.allowExecution).toBe(false)
expect(result.askUser).toBeDefined()
})
})
// ===== Native Protocol (nativeArgs) tests =====
describe("native protocol with nativeArgs", () => {
it("should differentiate read_file calls with different files in nativeArgs", () => {

View file

@ -30,12 +30,8 @@ describe("mode-validator", () => {
describe("architect mode", () => {
it("allows configured tools", () => {
// Architect mode has read, browser, and mcp groups
const architectTools = [
...TOOL_GROUPS.read.tools,
...TOOL_GROUPS.browser.tools,
...TOOL_GROUPS.mcp.tools,
]
// Architect mode has read and mcp groups
const architectTools = [...TOOL_GROUPS.read.tools, ...TOOL_GROUPS.mcp.tools]
architectTools.forEach((tool) => {
expect(isToolAllowedForMode(tool, architectMode, [])).toBe(true)
})
@ -44,8 +40,8 @@ describe("mode-validator", () => {
describe("ask mode", () => {
it("allows configured tools", () => {
// Ask mode has read, browser, and mcp groups
const askTools = [...TOOL_GROUPS.read.tools, ...TOOL_GROUPS.browser.tools, ...TOOL_GROUPS.mcp.tools]
// Ask mode has read and mcp groups
const askTools = [...TOOL_GROUPS.read.tools, ...TOOL_GROUPS.mcp.tools]
askTools.forEach((tool) => {
expect(isToolAllowedForMode(tool, askMode, [])).toBe(true)
})
@ -211,7 +207,7 @@ describe("mode-validator", () => {
})
it("blocks tool when disabledTools is converted to toolRequirements", () => {
const disabledTools = ["execute_command", "browser_action"]
const disabledTools = ["execute_command", "search_files"]
const toolRequirements = disabledTools.reduce(
(acc: Record<string, boolean>, tool: string) => {
acc[tool] = false
@ -223,8 +219,8 @@ describe("mode-validator", () => {
expect(() => validateToolUse("execute_command", codeMode, [], toolRequirements)).toThrow(
'Tool "execute_command" is not allowed in code mode.',
)
expect(() => validateToolUse("browser_action", codeMode, [], toolRequirements)).toThrow(
'Tool "browser_action" is not allowed in code mode.',
expect(() => validateToolUse("search_files", codeMode, [], toolRequirements)).toThrow(
'Tool "search_files" is not allowed in code mode.',
)
})

View file

@ -1,310 +0,0 @@
import * as vscode from "vscode"
import type { ClineMessage } from "@roo-code/types"
import { getUri } from "./getUri"
import { getNonce } from "./getNonce"
import type { ClineProvider } from "./ClineProvider"
import { webviewMessageHandler } from "./webviewMessageHandler"
export class BrowserSessionPanelManager {
private static instances: WeakMap<ClineProvider, BrowserSessionPanelManager> = new WeakMap()
private panel: vscode.WebviewPanel | undefined
private disposables: vscode.Disposable[] = []
private isReady: boolean = false
private pendingUpdate?: { messages: ClineMessage[]; isActive: boolean }
private pendingNavigateIndex?: number
private userManuallyClosedPanel: boolean = false
private constructor(private readonly provider: ClineProvider) {}
/**
* Get or create a BrowserSessionPanelManager instance for the given provider
*/
public static getInstance(provider: ClineProvider): BrowserSessionPanelManager {
let instance = BrowserSessionPanelManager.instances.get(provider)
if (!instance) {
instance = new BrowserSessionPanelManager(provider)
BrowserSessionPanelManager.instances.set(provider, instance)
}
return instance
}
/**
* Show the browser session panel, creating it if necessary
*/
public async show(): Promise<void> {
await this.createOrShowPanel()
// Send initial browser session data
const task = this.provider.getCurrentTask()
if (task) {
const messages = task.clineMessages || []
const browserSessionStartIndex = messages.findIndex(
(m) =>
m.ask === "browser_action_launch" ||
(m.say === "browser_session_status" && m.text?.includes("opened")),
)
const browserSessionMessages =
browserSessionStartIndex !== -1 ? messages.slice(browserSessionStartIndex) : []
const isBrowserSessionActive = task.browserSession?.isSessionActive() ?? false
await this.updateBrowserSession(browserSessionMessages, isBrowserSessionActive)
}
}
private async createOrShowPanel(): Promise<void> {
// If panel already exists, show it
if (this.panel) {
this.panel.reveal(vscode.ViewColumn.One)
return
}
const extensionUri = this.provider.context.extensionUri
const extensionMode = this.provider.context.extensionMode
// Create new panel
this.panel = vscode.window.createWebviewPanel("roo.browserSession", "Browser Session", vscode.ViewColumn.One, {
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [extensionUri],
})
// Set up the webview's HTML content
this.panel.webview.html =
extensionMode === vscode.ExtensionMode.Development
? await this.getHMRHtmlContent(this.panel.webview, extensionUri)
: this.getHtmlContent(this.panel.webview, extensionUri)
// Wire message channel for this panel (state handshake + actions)
this.panel.webview.onDidReceiveMessage(
async (message: any) => {
try {
// Let the shared handler process commands that work for any webview
if (message?.type) {
await webviewMessageHandler(this.provider as any, message)
}
// Panel-specific readiness and initial state
if (message?.type === "webviewDidLaunch") {
this.isReady = true
// Send full extension state to this panel (the sidebar postState targets the main webview)
const state = await (this.provider as any).getStateToPostToWebview?.()
if (state) {
await this.panel?.webview.postMessage({ type: "state", state })
}
// Flush any pending browser session update queued before readiness
if (this.pendingUpdate) {
await this.updateBrowserSession(this.pendingUpdate.messages, this.pendingUpdate.isActive)
this.pendingUpdate = undefined
}
// Flush any pending navigation request queued before readiness
if (this.pendingNavigateIndex !== undefined) {
await this.navigateToStep(this.pendingNavigateIndex)
this.pendingNavigateIndex = undefined
}
}
} catch (err) {
console.error("[BrowserSessionPanel] onDidReceiveMessage error:", err)
}
},
undefined,
this.disposables,
)
// Handle panel disposal - track that user closed it manually
this.panel.onDidDispose(
() => {
// Mark that user manually closed the panel (unless we're programmatically disposing)
if (this.panel) {
this.userManuallyClosedPanel = true
}
this.panel = undefined
this.dispose()
},
null,
this.disposables,
)
}
public async updateBrowserSession(messages: ClineMessage[], isBrowserSessionActive: boolean): Promise<void> {
if (!this.panel) {
return
}
// If the panel isn't ready yet, queue the latest snapshot to post after handshake
if (!this.isReady) {
this.pendingUpdate = { messages, isActive: isBrowserSessionActive }
return
}
await this.panel.webview.postMessage({
type: "browserSessionUpdate",
browserSessionMessages: messages,
isBrowserSessionActive,
})
}
/**
* Navigate the Browser Session panel to a specific step index.
* If the panel isn't ready yet, queue the navigation to run after handshake.
*/
public async navigateToStep(stepIndex: number): Promise<void> {
if (!this.panel) {
return
}
if (!this.isReady) {
this.pendingNavigateIndex = stepIndex
return
}
await this.panel.webview.postMessage({
type: "browserSessionNavigate",
stepIndex,
})
}
/**
* Reset the manual close flag (call this when a new browser session launches)
*/
public resetManualCloseFlag(): void {
this.userManuallyClosedPanel = false
}
/**
* Check if auto-opening should be allowed (not manually closed by user)
*/
public shouldAllowAutoOpen(): boolean {
return !this.userManuallyClosedPanel
}
/**
* Whether the Browser Session panel is currently open.
*/
public isOpen(): boolean {
return !!this.panel
}
/**
* Toggle the Browser Session panel visibility.
* - If open: closes it
* - If closed: opens it and sends initial session snapshot
*/
public async toggle(): Promise<void> {
if (this.panel) {
this.dispose()
} else {
await this.show()
}
}
public dispose(): void {
// Clear the panel reference before disposing to prevent marking as manual close
const panelToDispose = this.panel
this.panel = undefined
while (this.disposables.length) {
const disposable = this.disposables.pop()
if (disposable) {
disposable.dispose()
}
}
try {
panelToDispose?.dispose()
} catch {}
this.isReady = false
this.pendingUpdate = undefined
}
private async getHMRHtmlContent(webview: vscode.Webview, extensionUri: vscode.Uri): Promise<string> {
const fs = require("fs")
const path = require("path")
let localPort = "5173"
try {
const portFilePath = path.resolve(__dirname, "../../.vite-port")
if (fs.existsSync(portFilePath)) {
localPort = fs.readFileSync(portFilePath, "utf8").trim()
}
} catch (err) {
console.error("[BrowserSessionPanel:Vite] Failed to read port file:", err)
}
const localServerUrl = `localhost:${localPort}`
const nonce = getNonce()
const stylesUri = getUri(webview, extensionUri, ["webview-ui", "build", "assets", "index.css"])
const codiconsUri = getUri(webview, extensionUri, ["assets", "codicons", "codicon.css"])
const scriptUri = `http://${localServerUrl}/src/browser-panel.tsx`
const reactRefresh = `
<script nonce="${nonce}" type="module">
import RefreshRuntime from "http://localhost:${localPort}/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
`
const csp = [
"default-src 'none'",
`font-src ${webview.cspSource} data:`,
`style-src ${webview.cspSource} 'unsafe-inline' https://* http://${localServerUrl}`,
`img-src ${webview.cspSource} data:`,
`script-src 'unsafe-eval' ${webview.cspSource} http://${localServerUrl} 'nonce-${nonce}'`,
`connect-src ${webview.cspSource} ws://${localServerUrl} http://${localServerUrl}`,
]
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<title>Browser Session</title>
</head>
<body>
<div id="root"></div>
${reactRefresh}
<script type="module" src="${scriptUri}"></script>
</body>
</html>
`
}
private getHtmlContent(webview: vscode.Webview, extensionUri: vscode.Uri): string {
const stylesUri = getUri(webview, extensionUri, ["webview-ui", "build", "assets", "index.css"])
const scriptUri = getUri(webview, extensionUri, ["webview-ui", "build", "assets", "browser-panel.js"])
const codiconsUri = getUri(webview, extensionUri, ["assets", "codicons", "codicon.css"])
const nonce = getNonce()
const csp = [
"default-src 'none'",
`font-src ${webview.cspSource} data:`,
`style-src ${webview.cspSource} 'unsafe-inline'`,
`img-src ${webview.cspSource} data:`,
`script-src ${webview.cspSource} 'wasm-unsafe-eval' 'nonce-${nonce}'`,
`connect-src ${webview.cspSource}`,
]
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<title>Browser Session</title>
</head>
<body>
<div id="root"></div>
<script nonce="${nonce}" type="module" src="${scriptUri}"></script>
</body>
</html>
`
}
}

View file

@ -2091,7 +2091,6 @@ export class ClineProvider
alwaysAllowExecute,
allowedCommands,
deniedCommands,
alwaysAllowBrowser,
alwaysAllowMcp,
alwaysAllowModeSwitch,
alwaysAllowSubtasks,
@ -2106,11 +2105,6 @@ export class ClineProvider
checkpointTimeout,
taskHistory,
soundVolume,
browserViewportSize,
screenshotQuality,
remoteBrowserHost,
remoteBrowserEnabled,
cachedChromeHostUrl,
writeDelayMs,
terminalShellIntegrationTimeout,
terminalShellIntegrationDisabled,
@ -2133,7 +2127,6 @@ export class ClineProvider
experiments,
maxOpenTabsContext,
maxWorkspaceFiles,
browserToolEnabled,
disabledTools,
telemetrySetting,
showRooIgnoredFiles,
@ -2168,7 +2161,6 @@ export class ClineProvider
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
featureRoomoteControlEnabled,
isBrowserSessionActive,
lockApiConfigAcrossModes,
} = await this.getState()
@ -2210,11 +2202,9 @@ export class ClineProvider
alwaysAllowWriteOutsideWorkspace: alwaysAllowWriteOutsideWorkspace ?? false,
alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? false,
alwaysAllowExecute: alwaysAllowExecute ?? false,
alwaysAllowBrowser: alwaysAllowBrowser ?? false,
alwaysAllowMcp: alwaysAllowMcp ?? false,
alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false,
alwaysAllowSubtasks: alwaysAllowSubtasks ?? false,
isBrowserSessionActive,
allowedMaxRequests,
allowedMaxCost,
autoCondenseContext: autoCondenseContext ?? true,
@ -2239,11 +2229,6 @@ export class ClineProvider
allowedCommands: mergedAllowedCommands,
deniedCommands: mergedDeniedCommands,
soundVolume: soundVolume ?? 0.5,
browserViewportSize: browserViewportSize ?? "900x600",
screenshotQuality: screenshotQuality ?? 75,
remoteBrowserHost,
remoteBrowserEnabled: remoteBrowserEnabled ?? false,
cachedChromeHostUrl: cachedChromeHostUrl,
writeDelayMs: writeDelayMs ?? DEFAULT_WRITE_DELAY_MS,
terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout,
terminalShellIntegrationDisabled: terminalShellIntegrationDisabled ?? true,
@ -2268,7 +2253,6 @@ export class ClineProvider
maxOpenTabsContext: maxOpenTabsContext ?? 20,
maxWorkspaceFiles: maxWorkspaceFiles ?? 200,
cwd,
browserToolEnabled: browserToolEnabled ?? true,
disabledTools,
telemetrySetting,
telemetryKey,
@ -2442,9 +2426,6 @@ export class ClineProvider
)
}
// Get actual browser session state
const isBrowserSessionActive = this.getCurrentTask()?.browserSession?.isSessionActive() ?? false
// Return the same structure as before.
return {
apiConfiguration: providerSettings,
@ -2457,12 +2438,10 @@ export class ClineProvider
alwaysAllowWriteOutsideWorkspace: stateValues.alwaysAllowWriteOutsideWorkspace ?? false,
alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false,
alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false,
alwaysAllowBrowser: stateValues.alwaysAllowBrowser ?? false,
alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false,
alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false,
alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false,
alwaysAllowFollowupQuestions: stateValues.alwaysAllowFollowupQuestions ?? false,
isBrowserSessionActive,
followupAutoApproveTimeoutMs: stateValues.followupAutoApproveTimeoutMs ?? 60000,
diagnosticsEnabled: stateValues.diagnosticsEnabled ?? true,
allowedMaxRequests: stateValues.allowedMaxRequests,
@ -2478,11 +2457,6 @@ export class ClineProvider
enableCheckpoints: stateValues.enableCheckpoints ?? true,
checkpointTimeout: stateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
soundVolume: stateValues.soundVolume,
browserViewportSize: stateValues.browserViewportSize ?? "900x600",
screenshotQuality: stateValues.screenshotQuality ?? 75,
remoteBrowserHost: stateValues.remoteBrowserHost,
remoteBrowserEnabled: stateValues.remoteBrowserEnabled ?? false,
cachedChromeHostUrl: stateValues.cachedChromeHostUrl as string | undefined,
writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS,
terminalShellIntegrationTimeout:
stateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout,
@ -2509,7 +2483,6 @@ export class ClineProvider
customModes,
maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20,
maxWorkspaceFiles: stateValues.maxWorkspaceFiles ?? 200,
browserToolEnabled: stateValues.browserToolEnabled ?? true,
disabledTools: stateValues.disabledTools,
telemetrySetting: stateValues.telemetrySetting || "unset",
showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false,

View file

@ -122,7 +122,7 @@ vi.mock("../../../shared/modes", () => {
slug: "code",
name: "Code Mode",
roleDefinition: "You are a code assistant",
groups: ["read", "edit", "browser"],
groups: ["read", "edit"],
},
{
slug: "architect",
@ -171,7 +171,7 @@ vi.mock("../../../shared/modes", () => {
slug: "code",
name: "Code Mode",
roleDefinition: "You are a code assistant",
groups: ["read", "edit", "browser"],
groups: ["read", "edit"],
}),
defaultModeSlug: "code",
}

View file

@ -78,34 +78,6 @@ vi.mock("@modelcontextprotocol/sdk/types.js", () => ({
},
}))
vi.mock("../../../services/browser/BrowserSession", () => ({
BrowserSession: vi.fn().mockImplementation(() => ({
testConnection: vi.fn().mockImplementation(async (url) => {
if (url === "http://localhost:9222") {
return {
success: true,
message: "Successfully connected to Chrome",
endpoint: "ws://localhost:9222/devtools/browser/123",
}
} else {
return {
success: false,
message: "Failed to connect to Chrome",
endpoint: undefined,
}
}
}),
})),
}))
vi.mock("../../../services/browser/browserDiscovery", () => ({
discoverChromeHostUrl: vi.fn().mockResolvedValue("http://localhost:9222"),
tryChromeHostUrl: vi.fn().mockImplementation(async (url) => {
return url === "http://localhost:9222"
}),
testBrowserConnection: vi.fn(),
}))
// Remove duplicate mock - it's already defined below.
const mockAddCustomInstructions = vi.fn().mockResolvedValue("Combined instructions")
@ -248,7 +220,7 @@ vi.mock("../../../shared/modes", () => ({
slug: "code",
name: "Code Mode",
roleDefinition: "You are a code assistant",
groups: ["read", "edit", "browser"],
groups: ["read", "edit"],
},
{
slug: "architect",
@ -267,7 +239,7 @@ vi.mock("../../../shared/modes", () => ({
slug: "code",
name: "Code Mode",
roleDefinition: "You are a code assistant",
groups: ["read", "edit", "browser"],
groups: ["read", "edit"],
}),
getGroupName: vi.fn().mockImplementation((group: string) => {
// Return appropriate group names for different tool groups
@ -276,8 +248,6 @@ vi.mock("../../../shared/modes", () => ({
return "Read Tools"
case "edit":
return "Edit Tools"
case "browser":
return "Browser Tools"
case "mcp":
return "MCP Tools"
default:
@ -537,7 +507,6 @@ describe("ClineProvider", () => {
const mockState: ExtensionState = {
version: "1.0.0",
isBrowserSessionActive: false,
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
@ -557,21 +526,18 @@ describe("ClineProvider", () => {
},
alwaysAllowWriteOutsideWorkspace: false,
alwaysAllowExecute: false,
alwaysAllowBrowser: false,
alwaysAllowMcp: false,
uriScheme: "vscode",
soundEnabled: false,
ttsEnabled: false,
enableCheckpoints: false,
writeDelayMs: 1000,
browserViewportSize: "900x600",
mcpEnabled: true,
mode: defaultModeSlug,
customModes: [],
experiments: experimentDefault,
maxOpenTabsContext: 20,
maxWorkspaceFiles: 200,
browserToolEnabled: true,
telemetrySetting: "unset",
showRooIgnoredFiles: false,
enableSubfolderRules: false,
@ -804,7 +770,6 @@ describe("ClineProvider", () => {
expect(state).toHaveProperty("alwaysAllowReadOnly")
expect(state).toHaveProperty("alwaysAllowWrite")
expect(state).toHaveProperty("alwaysAllowExecute")
expect(state).toHaveProperty("alwaysAllowBrowser")
expect(state).toHaveProperty("taskHistory")
expect(state).toHaveProperty("soundEnabled")
expect(state).toHaveProperty("ttsEnabled")
@ -1006,21 +971,6 @@ describe("ClineProvider", () => {
expect(provider.providerSettingsManager.activateProfile).toHaveBeenCalledWith({ id: "config-id-123" })
})
test("handles browserToolEnabled setting", async () => {
await provider.resolveWebviewView(mockWebviewView)
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
// Test browserToolEnabled
await messageHandler({ type: "updateSettings", updatedSettings: { browserToolEnabled: true } })
expect(mockContext.globalState.update).toHaveBeenCalledWith("browserToolEnabled", true)
expect(mockPostMessage).toHaveBeenCalled()
// Verify state includes browserToolEnabled
const state = await provider.getState()
expect(state).toHaveProperty("browserToolEnabled")
expect(state.browserToolEnabled).toBe(true) // Default value should be true
})
test("handles showRooIgnoredFiles setting", async () => {
await provider.resolveWebviewView(mockWebviewView)
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
@ -1205,7 +1155,7 @@ describe("ClineProvider", () => {
{ ts: 1000, type: "say", say: "user_feedback" }, // User message 1
{ ts: 2000, type: "say", say: "tool" }, // Tool message
{ ts: 3000, type: "say", say: "text" }, // Message before delete
{ ts: 4000, type: "say", say: "browser_action" }, // Message to delete
{ ts: 4000, type: "say", say: "tool" }, // Message to delete
{ ts: 5000, type: "say", say: "user_feedback" }, // Next user message
{ ts: 6000, type: "say", say: "user_feedback" }, // Final message
] as ClineMessage[]
@ -1293,7 +1243,7 @@ describe("ClineProvider", () => {
{ ts: 1000, type: "say", say: "user_feedback" }, // User message 1
{ ts: 2000, type: "say", say: "tool" }, // Tool message
{ ts: 3000, type: "say", say: "text" }, // Message before edit
{ ts: 4000, type: "say", say: "browser_action" }, // Message to edit
{ ts: 4000, type: "say", say: "tool" }, // Message to edit
{ ts: 5000, type: "say", say: "user_feedback" }, // Next user message
{ ts: 6000, type: "say", say: "user_feedback" }, // Final message
] as ClineMessage[]
@ -1486,7 +1436,6 @@ describe("ClineProvider", () => {
},
mode: "architect",
mcpEnabled: false,
browserViewportSize: "900x600",
experiments: experimentDefault,
} as any)
@ -1503,54 +1452,6 @@ describe("ClineProvider", () => {
}),
)
})
// Tests for browser tool support - simplified to focus on behavior
test("generates system prompt with different browser tool configurations", async () => {
await provider.resolveWebviewView(mockWebviewView)
const handler = getMessageHandler()
// Test 1: Browser tools enabled with compatible model and mode
vi.spyOn(provider, "getState").mockResolvedValueOnce({
apiConfiguration: {
apiProvider: "openrouter",
},
browserToolEnabled: true,
mode: "code", // code mode includes browser tool group
experiments: experimentDefault,
} as any)
await handler({ type: "getSystemPrompt", mode: "code" })
expect(mockPostMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "systemPrompt",
text: expect.any(String),
mode: "code",
}),
)
mockPostMessage.mockClear()
// Test 2: Browser tools disabled
vi.spyOn(provider, "getState").mockResolvedValueOnce({
apiConfiguration: {
apiProvider: "openrouter",
},
browserToolEnabled: false,
mode: "code",
experiments: experimentDefault,
} as any)
await handler({ type: "getSystemPrompt", mode: "code" })
expect(mockPostMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "systemPrompt",
text: expect.any(String),
mode: "code",
}),
)
})
})
describe("handleModeSwitch", () => {
@ -1646,7 +1547,7 @@ describe("ClineProvider", () => {
slug: "code",
name: "Code Mode",
roleDefinition: "You are a code assistant",
groups: ["read", "edit", "browser"],
groups: ["read", "edit"],
}) // Subsequent calls return default mode
// Mock provider settings manager
@ -1845,7 +1746,7 @@ describe("ClineProvider", () => {
slug: "code",
name: "Code Mode",
roleDefinition: "You are a code assistant",
groups: ["read", "edit", "browser"],
groups: ["read", "edit"],
})
// Mock provider settings manager to throw error
@ -2096,77 +1997,6 @@ describe("ClineProvider", () => {
])
})
})
describe("browser connection features", () => {
beforeEach(async () => {
// Reset mocks
vi.clearAllMocks()
await provider.resolveWebviewView(mockWebviewView)
})
// These mocks are already defined at the top of the file
test("handles testBrowserConnection with provided URL", async () => {
// Get the message handler
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
// Test with valid URL
await messageHandler({
type: "testBrowserConnection",
text: "http://localhost:9222",
})
// Verify postMessage was called with success result
expect(mockPostMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "browserConnectionResult",
success: true,
text: expect.stringContaining("Successfully connected to Chrome"),
}),
)
// Reset mock
mockPostMessage.mockClear()
// Test with invalid URL
await messageHandler({
type: "testBrowserConnection",
text: "http://inlocalhost:9222",
})
// Verify postMessage was called with failure result
expect(mockPostMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "browserConnectionResult",
success: false,
text: expect.stringContaining("Failed to connect to Chrome"),
}),
)
})
test("handles testBrowserConnection with auto-discovery", async () => {
// Get the message handler
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
// Test auto-discovery (no URL provided)
await messageHandler({
type: "testBrowserConnection",
})
// Verify discoverChromeHostUrl was called
const { discoverChromeHostUrl } = await import("../../../services/browser/browserDiscovery")
expect(discoverChromeHostUrl).toHaveBeenCalled()
// Verify postMessage was called with success result
expect(mockPostMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "browserConnectionResult",
success: true,
text: expect.stringContaining("Auto-discovered and tested connection to Chrome"),
}),
)
})
})
})
describe("Project MCP Settings", () => {

View file

@ -124,7 +124,7 @@ vi.mock("../../../shared/modes", () => ({
slug: "code",
name: "Code Mode",
roleDefinition: "You are a code assistant",
groups: ["read", "edit", "browser"],
groups: ["read", "edit"],
},
{
slug: "architect",
@ -137,7 +137,7 @@ vi.mock("../../../shared/modes", () => ({
slug: "code",
name: "Code Mode",
roleDefinition: "You are a code assistant",
groups: ["read", "edit", "browser"],
groups: ["read", "edit"],
}),
defaultModeSlug: "code",
}))

View file

@ -126,7 +126,7 @@ vi.mock("../../../shared/modes", () => ({
slug: "code",
name: "Code Mode",
roleDefinition: "You are a code assistant",
groups: ["read", "edit", "browser"],
groups: ["read", "edit"],
},
{
slug: "architect",
@ -139,7 +139,7 @@ vi.mock("../../../shared/modes", () => ({
slug: "code",
name: "Code Mode",
roleDefinition: "You are a code assistant",
groups: ["read", "edit", "browser"],
groups: ["read", "edit"],
}),
defaultModeSlug: "code",
}))

View file

@ -67,18 +67,6 @@ vi.mock("@modelcontextprotocol/sdk/types.js", () => ({
},
}))
vi.mock("../../../services/browser/BrowserSession", () => ({
BrowserSession: vi.fn().mockImplementation(() => ({
testConnection: vi.fn().mockResolvedValue({ success: false }),
})),
}))
vi.mock("../../../services/browser/browserDiscovery", () => ({
discoverChromeHostUrl: vi.fn().mockResolvedValue("http://localhost:9222"),
tryChromeHostUrl: vi.fn().mockResolvedValue(false),
testBrowserConnection: vi.fn(),
}))
vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: vi.fn().mockImplementation(() => ({
connect: vi.fn().mockResolvedValue(undefined),

View file

@ -1,79 +0,0 @@
import { describe, test, expect, vi } from "vitest"
// Module under test
import { generateSystemPrompt } from "../generateSystemPrompt"
// Mock SYSTEM_PROMPT to capture its third argument (browser capability flag)
vi.mock("../../prompts/system", () => ({
SYSTEM_PROMPT: vi.fn(async (_ctx, _cwd, canUseBrowserTool: boolean) => {
// return a simple string to satisfy return type
return `SYSTEM_PROMPT:${canUseBrowserTool}`
}),
}))
// Mock API handler so we control model.info flags
vi.mock("../../../api", () => ({
buildApiHandler: vi.fn((_config) => ({
getModel: () => ({
id: "mock-model",
info: {
supportsImages: true,
contextWindow: 200_000,
maxTokens: 8192,
supportsPromptCache: false,
},
}),
})),
}))
// Minimal mode utilities: provide a custom mode that includes the "browser" group
const mockCustomModes = [
{
slug: "test-mode",
name: "Test Mode",
roleDefinition: "Test role",
description: "",
groups: ["browser"], // critical: include browser group
},
]
// Minimal ClineProvider stub
function makeProviderStub() {
return {
cwd: "/tmp",
context: {} as any,
customModesManager: {
getCustomModes: async () => mockCustomModes,
},
getCurrentTask: () => ({
rooIgnoreController: { getInstructions: () => undefined },
}),
getMcpHub: () => undefined,
getSkillsManager: () => undefined,
// State must enable browser tool and provide apiConfiguration
getState: async () => ({
apiConfiguration: {
apiProvider: "openrouter", // not used by the test beyond handler creation
},
customModePrompts: undefined,
customInstructions: undefined,
browserViewportSize: "900x600",
mcpEnabled: false,
experiments: {},
browserToolEnabled: true, // critical: enabled in settings
language: "en",
}),
} as any
}
describe("generateSystemPrompt browser capability (supportsImages=true)", () => {
test("passes canUseBrowserTool=true when mode has browser group and setting enabled", async () => {
const provider = makeProviderStub()
const message = { mode: "test-mode" } as any
const result = await generateSystemPrompt(provider, message)
// SYSTEM_PROMPT mock encodes the boolean into the returned string
expect(result).toBe("SYSTEM_PROMPT:true")
})
})

View file

@ -1,6 +1,6 @@
import * as vscode from "vscode"
import { WebviewMessage } from "../../shared/WebviewMessage"
import { defaultModeSlug, getModeBySlug, getGroupName } from "../../shared/modes"
import { defaultModeSlug } from "../../shared/modes"
import { buildApiHandler } from "../../api"
import { SYSTEM_PROMPT } from "../prompts/system"
@ -14,10 +14,8 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web
apiConfiguration,
customModePrompts,
customInstructions,
browserViewportSize,
mcpEnabled,
experiments,
browserToolEnabled,
language,
enableSubfolderRules,
} = await provider.getState()
@ -31,36 +29,22 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web
const rooIgnoreInstructions = provider.getCurrentTask()?.rooIgnoreController?.getInstructions()
// Determine if browser tools can be used based on model support, mode, and user settings
let modelInfo: any = undefined
// Create a temporary API handler to check if the model supports browser capability
// This avoids relying on an active Cline instance which might not exist during preview
// Create a temporary API handler to check model info for stealth mode.
// This avoids relying on an active Cline instance which might not exist during preview.
let modelInfo: { isStealthModel?: boolean } | undefined
try {
const tempApiHandler = buildApiHandler(apiConfiguration)
modelInfo = tempApiHandler.getModel().info
} catch (error) {
console.error("Error checking if model supports browser capability:", error)
console.error("Error fetching model info for system prompt preview:", error)
}
// Check if the current mode includes the browser tool group
const modeConfig = getModeBySlug(mode, customModes)
const modeSupportsBrowser = modeConfig?.groups.some((group) => getGroupName(group) === "browser") ?? false
// Check if model supports browser capability (images)
const modelSupportsBrowser = modelInfo && (modelInfo as any)?.supportsImages === true
// Only enable browser tools if the model supports it, the mode includes browser tools,
// and browser tools are enabled in settings
const canUseBrowserTool = modelSupportsBrowser && modeSupportsBrowser && (browserToolEnabled ?? true)
const systemPrompt = await SYSTEM_PROMPT(
provider.context,
cwd,
canUseBrowserTool,
false, // supportsComputerUse — browser removed
mcpEnabled ? provider.getMcpHub() : undefined,
diffStrategy,
browserViewportSize ?? "900x600",
mode,
customModePrompts,
customModes,

View file

@ -29,7 +29,6 @@ import { type ApiMessage } from "../task-persistence/apiMessages"
import { saveTaskMessages } from "../task-persistence"
import { ClineProvider } from "./ClineProvider"
import { BrowserSessionPanelManager } from "./BrowserSessionPanelManager"
import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler"
import { generateErrorDiagnostics } from "./diagnosticsHandler"
import {
@ -52,7 +51,6 @@ import { openFile } from "../../integrations/misc/open-file"
import { openImage, saveImage } from "../../integrations/misc/image-handler"
import { selectImages } from "../../integrations/misc/process-images"
import { getTheme } from "../../integrations/theme/getTheme"
import { discoverChromeHostUrl, tryChromeHostUrl } from "../../services/browser/browserDiscovery"
import { searchWorkspaceFiles } from "../../services/search/file-search"
import { fileExistsAtPath } from "../../utils/fs"
import { playTts, setTtsEnabled, setTtsSpeed, stopTts } from "../../utils/tts"
@ -1185,69 +1183,6 @@ export const webviewMessageHandler = async (
// Cancel any pending auto-approval timeout for the current task
provider.getCurrentTask()?.cancelAutoApprovalTimeout()
break
case "killBrowserSession":
{
const task = provider.getCurrentTask()
if (task?.browserSession) {
await task.browserSession.closeBrowser()
await provider.postStateToWebview()
}
}
break
case "openBrowserSessionPanel":
{
// Toggle the Browser Session panel (open if closed, close if open)
const panelManager = BrowserSessionPanelManager.getInstance(provider)
await panelManager.toggle()
}
break
case "showBrowserSessionPanelAtStep":
{
const panelManager = BrowserSessionPanelManager.getInstance(provider)
// If this is a launch action, reset the manual close flag
if (message.isLaunchAction) {
panelManager.resetManualCloseFlag()
}
// Show panel if:
// 1. Manual click (forceShow) - always show
// 2. Launch action - always show and reset flag
// 3. Auto-open for non-launch action - only if user hasn't manually closed
if (message.forceShow || message.isLaunchAction || panelManager.shouldAllowAutoOpen()) {
// Ensure panel is shown and populated
await panelManager.show()
// Navigate to a specific step if provided
// For launch actions: navigate to step 0
// For manual clicks: navigate to the clicked step
// For auto-opens of regular actions: don't navigate, let BrowserSessionRow's
// internal auto-advance logic handle it (only advances if user is on most recent step)
if (typeof message.stepIndex === "number" && message.stepIndex >= 0) {
await panelManager.navigateToStep(message.stepIndex)
}
}
}
break
case "refreshBrowserSessionPanel":
{
// Re-send the latest browser session snapshot to the panel
const panelManager = BrowserSessionPanelManager.getInstance(provider)
const task = provider.getCurrentTask()
if (task) {
const messages = task.clineMessages || []
const browserSessionStartIndex = messages.findIndex(
(m) =>
m.ask === "browser_action_launch" ||
(m.say === "browser_session_status" && m.text?.includes("opened")),
)
const browserSessionMessages =
browserSessionStartIndex !== -1 ? messages.slice(browserSessionStartIndex) : []
const isBrowserSessionActive = task.browserSession?.isSessionActive() ?? false
await panelManager.updateBrowserSession(browserSessionMessages, isBrowserSessionActive)
}
}
break
case "allowedCommands": {
// Validate and sanitize the commands array
const commands = message.commands ?? []
@ -1476,43 +1411,6 @@ export const webviewMessageHandler = async (
stopTts()
break
case "testBrowserConnection":
// If no text is provided, try auto-discovery
if (!message.text) {
// Use testBrowserConnection for auto-discovery
const chromeHostUrl = await discoverChromeHostUrl()
if (chromeHostUrl) {
// Send the result back to the webview
await provider.postMessageToWebview({
type: "browserConnectionResult",
success: !!chromeHostUrl,
text: `Auto-discovered and tested connection to Chrome: ${chromeHostUrl}`,
values: { endpoint: chromeHostUrl },
})
} else {
await provider.postMessageToWebview({
type: "browserConnectionResult",
success: false,
text: "No Chrome instances found on the network. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).",
})
}
} else {
// Test the provided URL
const customHostUrl = message.text
const hostIsValid = await tryChromeHostUrl(message.text)
// Send the result back to the webview
await provider.postMessageToWebview({
type: "browserConnectionResult",
success: hostIsValid,
text: hostIsValid
? `Successfully connected to Chrome: ${customHostUrl}`
: "Failed to connect to Chrome",
})
}
break
case "updateVSCodeSetting": {
const { setting, value } = message

View file

@ -512,8 +512,6 @@
"pretty-bytes": "^7.0.0",
"proper-lockfile": "^4.1.2",
"ps-tree": "^1.2.0",
"puppeteer-chromium-resolver": "^24.0.0",
"puppeteer-core": "^23.4.0",
"reconnecting-eventsource": "^1.6.4",
"safe-stable-stringify": "^2.5.0",
"sambanova-ai-provider": "^1.2.2",

View file

@ -1,913 +0,0 @@
import * as vscode from "vscode"
import * as fs from "fs/promises"
import * as path from "path"
import { Browser, Page, ScreenshotOptions, TimeoutError, launch, connect, KeyInput } from "puppeteer-core"
// @ts-ignore
import PCR from "puppeteer-chromium-resolver"
import pWaitFor from "p-wait-for"
import delay from "delay"
import { type BrowserActionResult } from "@roo-code/types"
import { fileExistsAtPath } from "../../utils/fs"
import { discoverChromeHostUrl, tryChromeHostUrl } from "./browserDiscovery"
// Timeout constants
const BROWSER_NAVIGATION_TIMEOUT = 15_000 // 15 seconds
interface PCRStats {
puppeteer: { launch: typeof launch }
executablePath: string
}
export class BrowserSession {
private context: vscode.ExtensionContext
private browser?: Browser
private page?: Page
private currentMousePosition?: string
private lastConnectionAttempt?: number
private isUsingRemoteBrowser: boolean = false
private onStateChange?: (isActive: boolean) => void
// Track last known viewport to surface in environment details
private lastViewportWidth?: number
private lastViewportHeight?: number
constructor(context: vscode.ExtensionContext, onStateChange?: (isActive: boolean) => void) {
this.context = context
this.onStateChange = onStateChange
}
private async ensureChromiumExists(): Promise<PCRStats> {
const globalStoragePath = this.context?.globalStorageUri?.fsPath
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
const puppeteerDir = path.join(globalStoragePath, "puppeteer")
const dirExists = await fileExistsAtPath(puppeteerDir)
if (!dirExists) {
await fs.mkdir(puppeteerDir, { recursive: true })
}
// if chromium doesn't exist, this will download it to path.join(puppeteerDir, ".chromium-browser-snapshots")
// if it does exist it will return the path to existing chromium
const stats: PCRStats = await PCR({
downloadPath: puppeteerDir,
})
return stats
}
/**
* Gets the viewport size from global state or returns default
*/
private getViewport() {
const size = (this.context.globalState.get("browserViewportSize") as string | undefined) || "900x600"
const [width, height] = size.split("x").map(Number)
return { width, height }
}
/**
* Launches a local browser instance
*/
private async launchLocalBrowser(): Promise<void> {
console.log("Launching local browser")
const stats = await this.ensureChromiumExists()
this.browser = await stats.puppeteer.launch({
args: [
"--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
],
executablePath: stats.executablePath,
defaultViewport: this.getViewport(),
// headless: false,
})
this.isUsingRemoteBrowser = false
}
/**
* Connects to a browser using a WebSocket URL
*/
private async connectWithChromeHostUrl(chromeHostUrl: string): Promise<boolean> {
try {
this.browser = await connect({
browserURL: chromeHostUrl,
defaultViewport: this.getViewport(),
})
// Cache the successful endpoint
console.log(`Connected to remote browser at ${chromeHostUrl}`)
this.context.globalState.update("cachedChromeHostUrl", chromeHostUrl)
this.lastConnectionAttempt = Date.now()
this.isUsingRemoteBrowser = true
return true
} catch (error) {
console.log(`Failed to connect using WebSocket endpoint: ${error}`)
return false
}
}
/**
* Attempts to connect to a remote browser using various methods
* Returns true if connection was successful, false otherwise
*/
private async connectToRemoteBrowser(): Promise<boolean> {
let remoteBrowserHost = this.context.globalState.get("remoteBrowserHost") as string | undefined
let reconnectionAttempted = false
// Try to connect with cached endpoint first if it exists and is recent (less than 1 hour old)
const cachedChromeHostUrl = this.context.globalState.get("cachedChromeHostUrl") as string | undefined
if (cachedChromeHostUrl && this.lastConnectionAttempt && Date.now() - this.lastConnectionAttempt < 3_600_000) {
console.log(`Attempting to connect using cached Chrome Host Url: ${cachedChromeHostUrl}`)
if (await this.connectWithChromeHostUrl(cachedChromeHostUrl)) {
return true
}
console.log(`Failed to connect using cached Chrome Host Url: ${cachedChromeHostUrl}`)
// Clear the cached endpoint since it's no longer valid
this.context.globalState.update("cachedChromeHostUrl", undefined)
// User wants to give up after one reconnection attempt
if (remoteBrowserHost) {
reconnectionAttempted = true
}
}
// If user provided a remote browser host, try to connect to it
else if (remoteBrowserHost && !reconnectionAttempted) {
console.log(`Attempting to connect to remote browser at ${remoteBrowserHost}`)
try {
const hostIsValid = await tryChromeHostUrl(remoteBrowserHost)
if (!hostIsValid) {
throw new Error("Could not find chromeHostUrl in the response")
}
console.log(`Found WebSocket endpoint: ${remoteBrowserHost}`)
if (await this.connectWithChromeHostUrl(remoteBrowserHost)) {
return true
}
} catch (error) {
console.error(`Failed to connect to remote browser: ${error}`)
// Fall back to auto-discovery if remote connection fails
}
}
try {
console.log("Attempting browser auto-discovery...")
const chromeHostUrl = await discoverChromeHostUrl()
if (chromeHostUrl && (await this.connectWithChromeHostUrl(chromeHostUrl))) {
return true
}
} catch (error) {
console.error(`Auto-discovery failed: ${error}`)
// Fall back to local browser if auto-discovery fails
}
return false
}
async launchBrowser(): Promise<void> {
console.log("launch browser called")
// Check if remote browser connection is enabled
const remoteBrowserEnabled = this.context.globalState.get("remoteBrowserEnabled") as boolean | undefined
if (!remoteBrowserEnabled) {
console.log("Launching local browser")
if (this.browser) {
// throw new Error("Browser already launched")
await this.closeBrowser() // this may happen when the model launches a browser again after having used it already before
} else {
// If browser wasn't open, just reset the state
this.resetBrowserState()
}
await this.launchLocalBrowser()
} else {
console.log("Connecting to remote browser")
// Remote browser connection is enabled
const remoteConnected = await this.connectToRemoteBrowser()
// If all remote connection attempts fail, fall back to local browser
if (!remoteConnected) {
console.log("Falling back to local browser")
await this.launchLocalBrowser()
}
}
// Notify that browser session is now active
if (this.browser && this.onStateChange) {
this.onStateChange(true)
}
}
/**
* Closes the browser and resets browser state
*/
async closeBrowser(): Promise<BrowserActionResult> {
const wasActive = !!(this.browser || this.page)
if (wasActive) {
if (this.isUsingRemoteBrowser && this.browser) {
await this.browser.disconnect().catch(() => {})
} else {
await this.browser?.close().catch(() => {})
}
this.resetBrowserState()
// Notify that browser session is now inactive
if (this.onStateChange) {
this.onStateChange(false)
}
}
return {}
}
/**
* Resets all browser state variables
*/
private resetBrowserState(): void {
this.browser = undefined
this.page = undefined
this.currentMousePosition = undefined
this.isUsingRemoteBrowser = false
this.lastViewportWidth = undefined
this.lastViewportHeight = undefined
}
async doAction(action: (page: Page) => Promise<void>): Promise<BrowserActionResult> {
if (!this.page) {
throw new Error(
"Cannot perform browser action: no active browser session. The browser must be launched first using the 'launch' action before other browser actions can be performed.",
)
}
const logs: string[] = []
let lastLogTs = Date.now()
const consoleListener = (msg: any) => {
if (msg.type() === "log") {
logs.push(msg.text())
} else {
logs.push(`[${msg.type()}] ${msg.text()}`)
}
lastLogTs = Date.now()
}
const errorListener = (err: Error) => {
logs.push(`[Page Error] ${err.toString()}`)
lastLogTs = Date.now()
}
// Add the listeners
this.page.on("console", consoleListener)
this.page.on("pageerror", errorListener)
try {
await action(this.page)
} catch (err) {
if (!(err instanceof TimeoutError)) {
logs.push(`[Error] ${err.toString()}`)
}
}
// Wait for console inactivity, with a timeout
await pWaitFor(() => Date.now() - lastLogTs >= 500, {
timeout: 3_000,
interval: 100,
}).catch(() => {})
// Draw cursor indicator if we have a cursor position
if (this.currentMousePosition) {
await this.drawCursorIndicator(this.page, this.currentMousePosition)
}
let options: ScreenshotOptions = {
encoding: "base64",
// clip: {
// x: 0,
// y: 0,
// width: 900,
// height: 600,
// },
}
let screenshotBase64 = await this.page.screenshot({
...options,
type: "webp",
quality: ((await this.context.globalState.get("screenshotQuality")) as number | undefined) ?? 75,
})
let screenshot = `data:image/webp;base64,${screenshotBase64}`
if (!screenshotBase64) {
console.log("webp screenshot failed, trying png")
screenshotBase64 = await this.page.screenshot({
...options,
type: "png",
})
screenshot = `data:image/png;base64,${screenshotBase64}`
}
if (!screenshotBase64) {
throw new Error("Failed to take screenshot.")
}
// Remove cursor indicator after taking screenshot
if (this.currentMousePosition) {
await this.removeCursorIndicator(this.page)
}
// this.page.removeAllListeners() <- causes the page to crash!
this.page.off("console", consoleListener)
this.page.off("pageerror", errorListener)
// Get actual viewport dimensions
const viewport = this.page.viewport()
// Persist last known viewport dimensions
this.lastViewportWidth = viewport?.width
this.lastViewportHeight = viewport?.height
return {
screenshot,
logs: logs.join("\n"),
currentUrl: this.page.url(),
currentMousePosition: this.currentMousePosition,
viewportWidth: viewport?.width,
viewportHeight: viewport?.height,
}
}
/**
* Extract the root domain from a URL
* e.g., http://localhost:3000/path -> localhost:3000
* e.g., https://example.com/path -> example.com
*/
private getRootDomain(url: string): string {
try {
const urlObj = new URL(url)
// Remove www. prefix if present
return urlObj.host.replace(/^www\./, "")
} catch (error) {
// If URL parsing fails, return the original URL
return url
}
}
/**
* Navigate to a URL with standard loading options
*/
private async navigatePageToUrl(page: Page, url: string): Promise<void> {
await page.goto(url, { timeout: BROWSER_NAVIGATION_TIMEOUT, waitUntil: ["domcontentloaded", "networkidle2"] })
await this.waitTillHTMLStable(page)
}
/**
* Creates a new tab and navigates to the specified URL
*/
private async createNewTab(url: string): Promise<BrowserActionResult> {
if (!this.browser) {
throw new Error("Browser is not launched")
}
// Create a new page
const newPage = await this.browser.newPage()
// Set the new page as the active page
this.page = newPage
// Navigate to the URL
const result = await this.doAction(async (page) => {
await this.navigatePageToUrl(page, url)
})
return result
}
async navigateToUrl(url: string): Promise<BrowserActionResult> {
if (!this.browser) {
throw new Error("Browser is not launched")
}
// Remove trailing slash for comparison
const normalizedNewUrl = url.replace(/\/$/, "")
// Extract the root domain from the URL
const rootDomain = this.getRootDomain(normalizedNewUrl)
// Get all current pages
const pages = await this.browser.pages()
// Try to find a page with the same root domain
let existingPage: Page | undefined
for (const page of pages) {
try {
const pageUrl = page.url()
if (pageUrl && this.getRootDomain(pageUrl) === rootDomain) {
existingPage = page
break
}
} catch (error) {
// Skip pages that might have been closed or have errors
console.log(`Error checking page URL: ${error}`)
continue
}
}
if (existingPage) {
// Tab with the same root domain exists, switch to it
console.log(`Tab with domain ${rootDomain} already exists, switching to it`)
// Update the active page
this.page = existingPage
existingPage.bringToFront()
// Navigate to the new URL if it's different]
const currentUrl = existingPage.url().replace(/\/$/, "") // Remove trailing / if present
if (this.getRootDomain(currentUrl) === rootDomain && currentUrl !== normalizedNewUrl) {
console.log(`Navigating to new URL: ${normalizedNewUrl}`)
console.log(`Current URL: ${currentUrl}`)
console.log(`Root domain: ${this.getRootDomain(currentUrl)}`)
console.log(`New URL: ${normalizedNewUrl}`)
// Navigate to the new URL
return this.doAction(async (page) => {
await this.navigatePageToUrl(page, normalizedNewUrl)
})
} else {
console.log(`Tab with domain ${rootDomain} already exists, and URL is the same: ${normalizedNewUrl}`)
// URL is the same, just reload the page to ensure it's up to date
console.log(`Reloading page: ${normalizedNewUrl}`)
console.log(`Current URL: ${currentUrl}`)
console.log(`Root domain: ${this.getRootDomain(currentUrl)}`)
console.log(`New URL: ${normalizedNewUrl}`)
return this.doAction(async (page) => {
await page.reload({
timeout: BROWSER_NAVIGATION_TIMEOUT,
waitUntil: ["domcontentloaded", "networkidle2"],
})
await this.waitTillHTMLStable(page)
})
}
} else {
// No tab with this root domain exists, create a new one
console.log(`No tab with domain ${rootDomain} exists, creating a new one`)
return this.createNewTab(normalizedNewUrl)
}
}
// page.goto { waitUntil: "networkidle0" } may not ever resolve, and not waiting could return page content too early before js has loaded
// https://stackoverflow.com/questions/52497252/puppeteer-wait-until-page-is-completely-loaded/61304202#61304202
private async waitTillHTMLStable(page: Page, timeout = 5_000) {
const checkDurationMsecs = 500 // 1000
const maxChecks = timeout / checkDurationMsecs
let lastHTMLSize = 0
let checkCounts = 1
let countStableSizeIterations = 0
const minStableSizeIterations = 3
while (checkCounts++ <= maxChecks) {
let html = await page.content()
let currentHTMLSize = html.length
// let bodyHTMLSize = await page.evaluate(() => document.body.innerHTML.length)
console.log("last: ", lastHTMLSize, " <> curr: ", currentHTMLSize)
if (lastHTMLSize !== 0 && currentHTMLSize === lastHTMLSize) {
countStableSizeIterations++
} else {
countStableSizeIterations = 0 //reset the counter
}
if (countStableSizeIterations >= minStableSizeIterations) {
console.log("Page rendered fully...")
break
}
lastHTMLSize = currentHTMLSize
await delay(checkDurationMsecs)
}
}
/**
* Force links and window.open to navigate in the same tab.
* This makes clicks on anchors with target="_blank" stay in the current page
* and also intercepts window.open so SPA/open-in-new-tab patterns don't spawn popups.
*/
private async forceLinksToSameTab(page: Page): Promise<void> {
try {
await page.evaluate(() => {
try {
// Ensure we only install once per document
if ((window as any).__ROO_FORCE_SAME_TAB__) return
;(window as any).__ROO_FORCE_SAME_TAB__ = true
// Override window.open to navigate current tab instead of creating a new one
const originalOpen = window.open
window.open = function (url: string | URL, target?: string, features?: string) {
try {
const href = typeof url === "string" ? url : String(url)
location.href = href
} catch {
// fall back to original if something unexpected occurs
try {
return originalOpen.apply(window, [url as any, "_self", features]) as any
} catch {}
}
return null as any
} as any
// Rewrite anchors that explicitly open new tabs
document.querySelectorAll('a[target="_blank"]').forEach((a) => {
a.setAttribute("target", "_self")
})
// Defensive capture: if an element still tries to open in a new tab, force same-tab
document.addEventListener(
"click",
(ev) => {
const el = (ev.target as HTMLElement | null)?.closest?.(
'a[target="_blank"]',
) as HTMLAnchorElement | null
if (el && el.href) {
ev.preventDefault()
try {
location.href = el.href
} catch {}
}
},
{ capture: true, passive: false },
)
} catch {
// no-op; forcing same-tab is best-effort
}
})
} catch {
// If evaluate fails (e.g., cross-origin/state), continue without breaking the action
}
}
/**
* Handles mouse interaction with network activity monitoring
*/
private async handleMouseInteraction(
page: Page,
coordinate: string,
action: (x: number, y: number) => Promise<void>,
): Promise<void> {
const [x, y] = coordinate.split(",").map(Number)
// Force any new-tab behavior (target="_blank", window.open) to stay in the same tab
await this.forceLinksToSameTab(page)
// Set up network request monitoring
let hasNetworkActivity = false
const requestListener = () => {
hasNetworkActivity = true
}
page.on("request", requestListener)
// Perform the mouse action
await action(x, y)
this.currentMousePosition = coordinate
// Small delay to check if action triggered any network activity
await delay(100)
if (hasNetworkActivity) {
// If we detected network activity, wait for navigation/loading
await page
.waitForNavigation({
waitUntil: ["domcontentloaded", "networkidle2"],
timeout: BROWSER_NAVIGATION_TIMEOUT,
})
.catch(() => {})
await this.waitTillHTMLStable(page)
}
// Clean up listener
page.off("request", requestListener)
}
async click(coordinate: string): Promise<BrowserActionResult> {
return this.doAction(async (page) => {
await this.handleMouseInteraction(page, coordinate, async (x, y) => {
await page.mouse.click(x, y)
})
})
}
async type(text: string): Promise<BrowserActionResult> {
return this.doAction(async (page) => {
await page.keyboard.type(text)
})
}
async press(key: string): Promise<BrowserActionResult> {
return this.doAction(async (page) => {
// Parse key combinations (e.g., "Cmd+K", "Shift+Enter")
const parts = key.split("+").map((k) => k.trim())
const modifiers: string[] = []
let mainKey = parts[parts.length - 1]
// Identify modifiers
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i].toLowerCase()
if (part === "cmd" || part === "command" || part === "meta") {
modifiers.push("Meta")
} else if (part === "ctrl" || part === "control") {
modifiers.push("Control")
} else if (part === "shift") {
modifiers.push("Shift")
} else if (part === "alt" || part === "option") {
modifiers.push("Alt")
}
}
// Map common key aliases to Puppeteer KeyInput values
const mapping: Record<string, KeyInput | string> = {
esc: "Escape",
return: "Enter",
escape: "Escape",
enter: "Enter",
tab: "Tab",
space: "Space",
arrowup: "ArrowUp",
arrowdown: "ArrowDown",
arrowleft: "ArrowLeft",
arrowright: "ArrowRight",
}
mainKey = (mapping[mainKey.toLowerCase()] ?? mainKey) as string
// Avoid new-tab behavior from Enter on links/buttons
await this.forceLinksToSameTab(page)
// Track inflight requests so we can detect brief network bursts
let inflight = 0
const onRequest = () => {
inflight++
}
const onRequestDone = () => {
inflight = Math.max(0, inflight - 1)
}
page.on("request", onRequest)
page.on("requestfinished", onRequestDone)
page.on("requestfailed", onRequestDone)
// Start a short navigation wait in parallel; if no nav, it times out harmlessly
const HARD_CAP_MS = 3000
const navPromise = page
.waitForNavigation({
// domcontentloaded is enough to confirm a submit navigated
waitUntil: ["domcontentloaded"],
timeout: HARD_CAP_MS,
})
.catch(() => undefined)
// Press key combination
if (modifiers.length > 0) {
// Hold down modifiers
for (const modifier of modifiers) {
await page.keyboard.down(modifier as KeyInput)
}
// Press main key
await page.keyboard.press(mainKey as KeyInput)
// Release modifiers
for (const modifier of modifiers) {
await page.keyboard.up(modifier as KeyInput)
}
} else {
// Single key press
await page.keyboard.press(mainKey as KeyInput)
}
// Give time for any requests to kick off
await delay(120)
// Hard-cap the wait to avoid UI hangs
await Promise.race([
navPromise,
pWaitFor(() => inflight === 0, { timeout: HARD_CAP_MS, interval: 100 }).catch(() => {}),
delay(HARD_CAP_MS),
])
// Stabilize DOM briefly before capturing screenshot (shorter cap)
await this.waitTillHTMLStable(page, 2_000)
// Cleanup
page.off("request", onRequest)
page.off("requestfinished", onRequestDone)
page.off("requestfailed", onRequestDone)
})
}
/**
* Scrolls the page by the specified amount
*/
private async scrollPage(page: Page, direction: "up" | "down"): Promise<void> {
const { height } = this.getViewport()
const scrollAmount = direction === "down" ? height : -height
await page.evaluate((scrollHeight) => {
window.scrollBy({
top: scrollHeight,
behavior: "auto",
})
}, scrollAmount)
await delay(300)
}
async scrollDown(): Promise<BrowserActionResult> {
return this.doAction(async (page) => {
await this.scrollPage(page, "down")
})
}
async scrollUp(): Promise<BrowserActionResult> {
return this.doAction(async (page) => {
await this.scrollPage(page, "up")
})
}
async hover(coordinate: string): Promise<BrowserActionResult> {
return this.doAction(async (page) => {
await this.handleMouseInteraction(page, coordinate, async (x, y) => {
await page.mouse.move(x, y)
// Small delay to allow any hover effects to appear
await delay(300)
})
})
}
async resize(size: string): Promise<BrowserActionResult> {
return this.doAction(async (page) => {
const [width, height] = size.split(",").map(Number)
const session = await page.createCDPSession()
await page.setViewport({ width, height })
const { windowId } = await session.send("Browser.getWindowForTarget")
await session.send("Browser.setWindowBounds", {
bounds: { width, height },
windowId,
})
})
}
/**
* Determines image type from file extension
*/
private getImageTypeFromPath(filePath: string): "png" | "jpeg" | "webp" {
const ext = path.extname(filePath).toLowerCase()
if (ext === ".jpg" || ext === ".jpeg") return "jpeg"
if (ext === ".webp") return "webp"
return "png"
}
/**
* Takes a screenshot and saves it to the specified file path.
* @param filePath - The destination file path (relative to workspace)
* @param cwd - Current working directory for resolving relative paths
* @returns BrowserActionResult with screenshot data and saved file path
* @throws Error if the resolved path escapes the workspace directory
*/
async saveScreenshot(filePath: string, cwd: string): Promise<BrowserActionResult> {
// Always resolve the path against the workspace root
const normalizedCwd = path.resolve(cwd)
const fullPath = path.resolve(cwd, filePath)
// Validate that the resolved path stays within the workspace (before calling doAction)
if (!fullPath.startsWith(normalizedCwd + path.sep) && fullPath !== normalizedCwd) {
throw new Error(
`Screenshot path "${filePath}" resolves to "${fullPath}" which is outside the workspace "${normalizedCwd}". ` +
`Paths must be relative to the workspace and cannot escape it.`,
)
}
return this.doAction(async (page) => {
// Ensure directory exists
await fs.mkdir(path.dirname(fullPath), { recursive: true })
// Determine image type from extension
const imageType = this.getImageTypeFromPath(filePath)
// Take screenshot directly to file (more efficient than base64 for file saving)
await page.screenshot({
path: fullPath,
type: imageType,
quality:
imageType === "png"
? undefined
: ((this.context.globalState.get("screenshotQuality") as number | undefined) ?? 75),
})
})
}
/**
* Draws a cursor indicator on the page at the specified position
*/
private async drawCursorIndicator(page: Page, coordinate: string): Promise<void> {
const [x, y] = coordinate.split(",").map(Number)
try {
await page.evaluate(
(cursorX: number, cursorY: number) => {
// Create a cursor indicator element
const cursor = document.createElement("div")
cursor.id = "__roo_cursor_indicator__"
cursor.style.cssText = `
position: fixed;
left: ${cursorX}px;
top: ${cursorY}px;
width: 35px;
height: 35px;
pointer-events: none;
z-index: 2147483647;
`
// Create SVG cursor pointer
const svg = `
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 3L5 17L9 13L12 19L14 18L11 12L17 12L5 3Z"
fill="white"
stroke="black"
stroke-width="1.5"/>
<path d="M5 3L5 17L9 13L12 19L14 18L11 12L17 12L5 3Z"
fill="black"
stroke="white"
stroke-width=".5"/>
</svg>
`
cursor.innerHTML = svg
document.body.appendChild(cursor)
},
x,
y,
)
} catch (error) {
console.error("Failed to draw cursor indicator:", error)
}
}
/**
* Removes the cursor indicator from the page
*/
private async removeCursorIndicator(page: Page): Promise<void> {
try {
await page.evaluate(() => {
const cursor = document.getElementById("__roo_cursor_indicator__")
if (cursor) {
cursor.remove()
}
})
} catch (error) {
console.error("Failed to remove cursor indicator:", error)
}
}
/**
* Returns whether a browser session is currently active
*/
isSessionActive(): boolean {
return !!(this.browser && this.page)
}
/**
* Returns the last known viewport size (if any)
*
* Prefer the live page viewport when available so we stay accurate after:
* - browser_action resize
* - manual window resizes (especially with remote browsers)
*
* Falls back to the configured default viewport when no prior information exists.
*/
getViewportSize(): { width?: number; height?: number } {
// If we have an active page, ask Puppeteer for the current viewport.
// This keeps us in sync with any resizes that happen outside of our own
// browser_action lifecycle (e.g. user dragging the window).
if (this.page) {
const vp = this.page.viewport()
if (vp?.width) this.lastViewportWidth = vp.width
if (vp?.height) this.lastViewportHeight = vp.height
}
// If we've ever observed a viewport, use that.
if (this.lastViewportWidth && this.lastViewportHeight) {
return {
width: this.lastViewportWidth,
height: this.lastViewportHeight,
}
}
// Otherwise fall back to the configured default so the tool can still
// operate before the first screenshot-based action has run.
const { width, height } = this.getViewport()
return { width, height }
}
}

View file

@ -1,143 +0,0 @@
import * as vscode from "vscode"
import * as fs from "fs/promises"
import * as path from "path"
import { Browser, Page, launch } from "puppeteer-core"
import * as cheerio from "cheerio"
import TurndownService from "turndown"
// @ts-ignore
import PCR from "puppeteer-chromium-resolver"
import { fileExistsAtPath } from "../../utils/fs"
import { serializeError } from "serialize-error"
// Timeout constants
const URL_FETCH_TIMEOUT = 30_000 // 30 seconds
const URL_FETCH_FALLBACK_TIMEOUT = 20_000 // 20 seconds for fallback
interface PCRStats {
puppeteer: { launch: typeof launch }
executablePath: string
}
export class UrlContentFetcher {
private context: vscode.ExtensionContext
private browser?: Browser
private page?: Page
constructor(context: vscode.ExtensionContext) {
this.context = context
}
private async ensureChromiumExists(): Promise<PCRStats> {
const globalStoragePath = this.context?.globalStorageUri?.fsPath
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
const puppeteerDir = path.join(globalStoragePath, "puppeteer")
const dirExists = await fileExistsAtPath(puppeteerDir)
if (!dirExists) {
await fs.mkdir(puppeteerDir, { recursive: true })
}
// if chromium doesn't exist, this will download it to path.join(puppeteerDir, ".chromium-browser-snapshots")
// if it does exist it will return the path to existing chromium
const stats: PCRStats = await PCR({
downloadPath: puppeteerDir,
})
return stats
}
async launchBrowser(): Promise<void> {
if (this.browser) {
return
}
const stats = await this.ensureChromiumExists()
const args = [
"--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
"--disable-dev-shm-usage",
"--disable-accelerated-2d-canvas",
"--no-first-run",
"--disable-gpu",
"--disable-features=VizDisplayCompositor",
]
if (process.platform === "linux") {
// Fixes network errors on Linux hosts (see https://github.com/puppeteer/puppeteer/issues/8246)
args.push("--no-sandbox")
}
this.browser = await stats.puppeteer.launch({
args,
executablePath: stats.executablePath,
})
// (latest version of puppeteer does not add headless to user agent)
this.page = await this.browser?.newPage()
// Set additional page configurations to improve loading success
if (this.page) {
await this.page.setViewport({ width: 1280, height: 720 })
await this.page.setExtraHTTPHeaders({
"Accept-Language": "en-US,en;q=0.9",
})
}
}
async closeBrowser(): Promise<void> {
await this.browser?.close()
this.browser = undefined
this.page = undefined
}
// must make sure to call launchBrowser before and closeBrowser after using this
async urlToMarkdown(url: string): Promise<string> {
if (!this.browser || !this.page) {
throw new Error("Browser not initialized")
}
/*
- In Puppeteer, "networkidle2" waits until there are no more than 2 network connections for at least 500 ms (roughly equivalent to Playwright's "networkidle").
- "domcontentloaded" is when the basic DOM is loaded.
This should be sufficient for most doc sites.
*/
try {
await this.page.goto(url, {
timeout: URL_FETCH_TIMEOUT,
waitUntil: ["domcontentloaded", "networkidle2"],
})
} catch (error) {
// Use serialize-error to safely extract error information
const serializedError = serializeError(error)
const errorMessage = serializedError.message || String(error)
const errorName = serializedError.name
// Only retry for timeout or network-related errors
const shouldRetry =
errorMessage.includes("timeout") ||
errorMessage.includes("net::") ||
errorMessage.includes("NetworkError") ||
errorMessage.includes("ERR_") ||
errorName === "TimeoutError"
if (shouldRetry) {
// If networkidle2 fails due to timeout/network issues, try with just domcontentloaded as fallback
console.warn(
`Failed to load ${url} with networkidle2, retrying with domcontentloaded only: ${errorMessage}`,
)
await this.page.goto(url, {
timeout: URL_FETCH_FALLBACK_TIMEOUT,
waitUntil: ["domcontentloaded"],
})
} else {
// For other errors, throw them as-is
throw error
}
}
const content = await this.page.content()
// use cheerio to parse and clean up the HTML
const $ = cheerio.load(content)
$("script, style, nav, footer, header").remove()
// convert cleaned HTML to markdown
const turndownService = new TurndownService()
const markdown = turndownService.turndown($.html())
return markdown
}
}

View file

@ -1,628 +0,0 @@
// npx vitest services/browser/__tests__/BrowserSession.spec.ts
import * as path from "path"
import { BrowserSession } from "../BrowserSession"
import { discoverChromeHostUrl, tryChromeHostUrl } from "../browserDiscovery"
// Mock dependencies
vi.mock("vscode", () => ({
ExtensionContext: vi.fn(),
Uri: {
file: vi.fn((path) => ({ fsPath: path })),
},
}))
// Mock puppeteer-core
vi.mock("puppeteer-core", () => {
const mockBrowser = {
newPage: vi.fn().mockResolvedValue({
goto: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
off: vi.fn(),
screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"),
url: vi.fn().mockReturnValue("https://example.com"),
}),
pages: vi.fn().mockResolvedValue([]),
close: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
}
return {
Browser: vi.fn(),
Page: vi.fn(),
TimeoutError: class TimeoutError extends Error {},
launch: vi.fn().mockResolvedValue(mockBrowser),
connect: vi.fn().mockResolvedValue(mockBrowser),
}
})
// Mock PCR
vi.mock("puppeteer-chromium-resolver", () => {
return {
default: vi.fn().mockResolvedValue({
puppeteer: {
launch: vi.fn().mockImplementation(async () => {
const { launch } = await import("puppeteer-core")
return launch()
}),
},
executablePath: "/mock/path/to/chromium",
}),
}
})
// Mock fs
vi.mock("fs/promises", () => ({
mkdir: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn(),
writeFile: vi.fn(),
access: vi.fn(),
}))
// Mock fileExistsAtPath
vi.mock("../../../utils/fs", () => ({
fileExistsAtPath: vi.fn().mockResolvedValue(false),
}))
// Mock browser discovery functions
vi.mock("../browserDiscovery", () => ({
discoverChromeHostUrl: vi.fn().mockResolvedValue(null),
tryChromeHostUrl: vi.fn().mockResolvedValue(false),
}))
// Mock delay
vi.mock("delay", () => ({
default: vi.fn().mockResolvedValue(undefined),
}))
// Mock p-wait-for
vi.mock("p-wait-for", () => ({
default: vi.fn().mockResolvedValue(undefined),
}))
describe("BrowserSession", () => {
let browserSession: BrowserSession
let mockContext: any
beforeEach(() => {
vi.clearAllMocks()
// Set up mock context
mockContext = {
globalState: {
get: vi.fn(),
update: vi.fn(),
},
globalStorageUri: {
fsPath: "/mock/global/storage/path",
},
extensionUri: {
fsPath: "/mock/extension/path",
},
}
// Create browser session
browserSession = new BrowserSession(mockContext)
})
describe("Remote browser disabled", () => {
it("should launch a local browser when remote browser is disabled", async () => {
// Mock context to indicate remote browser is disabled
mockContext.globalState.get.mockImplementation((key: string) => {
if (key === "remoteBrowserEnabled") return false
return undefined
})
await browserSession.launchBrowser()
const puppeteerCore = await import("puppeteer-core")
// Verify that a local browser was launched
expect(puppeteerCore.launch).toHaveBeenCalled()
// Verify that remote browser connection was not attempted
expect(discoverChromeHostUrl).not.toHaveBeenCalled()
expect(tryChromeHostUrl).not.toHaveBeenCalled()
expect((browserSession as any).isUsingRemoteBrowser).toBe(false)
})
})
describe("Remote browser successfully connects", () => {
it("should connect to a remote browser when enabled and connection succeeds", async () => {
// Mock context to indicate remote browser is enabled
mockContext.globalState.get.mockImplementation((key: string) => {
if (key === "remoteBrowserEnabled") return true
if (key === "remoteBrowserHost") return "http://remote-browser:9222"
return undefined
})
// Mock successful remote browser connection
vi.mocked(tryChromeHostUrl).mockResolvedValue(true)
await browserSession.launchBrowser()
const puppeteerCore = await import("puppeteer-core")
// Verify that connect was called
expect(puppeteerCore.connect).toHaveBeenCalled()
// Verify that local browser was not launched
expect(puppeteerCore.launch).not.toHaveBeenCalled()
expect((browserSession as any).isUsingRemoteBrowser).toBe(true)
})
})
describe("Remote browser enabled but falls back to local", () => {
it("should fall back to local browser when remote connection fails", async () => {
// Mock context to indicate remote browser is enabled
mockContext.globalState.get.mockImplementation((key: string) => {
if (key === "remoteBrowserEnabled") return true
if (key === "remoteBrowserHost") return "http://remote-browser:9222"
return undefined
})
// Mock failed remote browser connection
vi.mocked(tryChromeHostUrl).mockResolvedValue(false)
vi.mocked(discoverChromeHostUrl).mockResolvedValue(null)
await browserSession.launchBrowser()
// Import puppeteer-core to check if launch was called
const puppeteerCore = await import("puppeteer-core")
// Verify that local browser was launched as fallback
expect(puppeteerCore.launch).toHaveBeenCalled()
// Verify that isUsingRemoteBrowser is false
expect((browserSession as any).isUsingRemoteBrowser).toBe(false)
})
})
describe("closeBrowser", () => {
it("should close a local browser properly", async () => {
const puppeteerCore = await import("puppeteer-core")
// Create a mock browser directly
const mockBrowser = {
newPage: vi.fn().mockResolvedValue({}),
pages: vi.fn().mockResolvedValue([]),
close: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
}
// Set browser and page on the session
;(browserSession as any).browser = mockBrowser
;(browserSession as any).page = {}
;(browserSession as any).isUsingRemoteBrowser = false
await browserSession.closeBrowser()
// Verify that browser.close was called
expect(mockBrowser.close).toHaveBeenCalled()
expect(mockBrowser.disconnect).not.toHaveBeenCalled()
// Verify that browser state was reset
expect((browserSession as any).browser).toBeUndefined()
expect((browserSession as any).page).toBeUndefined()
expect((browserSession as any).isUsingRemoteBrowser).toBe(false)
})
it("should disconnect from a remote browser properly", async () => {
// Create a mock browser directly
const mockBrowser = {
newPage: vi.fn().mockResolvedValue({}),
pages: vi.fn().mockResolvedValue([]),
close: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
}
// Set browser and page on the session
;(browserSession as any).browser = mockBrowser
;(browserSession as any).page = {}
;(browserSession as any).isUsingRemoteBrowser = true
await browserSession.closeBrowser()
// Verify that browser.disconnect was called
expect(mockBrowser.disconnect).toHaveBeenCalled()
expect(mockBrowser.close).not.toHaveBeenCalled()
})
})
it("forces same-tab behavior before click", async () => {
// Prepare a minimal mock page with required APIs
const page: any = {
on: vi.fn(),
off: vi.fn(),
screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"),
url: vi.fn().mockReturnValue("https://example.com"),
viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }),
waitForNavigation: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(undefined),
mouse: {
click: vi.fn().mockResolvedValue(undefined),
move: vi.fn().mockResolvedValue(undefined),
},
}
;(browserSession as any).page = page
// Spy on the forceLinksToSameTab helper to ensure it's invoked
const forceSpy = vi.fn().mockResolvedValue(undefined)
;(browserSession as any).forceLinksToSameTab = forceSpy
await browserSession.click("10,20")
expect(forceSpy).toHaveBeenCalledTimes(1)
expect(forceSpy).toHaveBeenCalledWith(page)
expect(page.mouse.click).toHaveBeenCalledWith(10, 20)
})
})
describe("keyboard press", () => {
it("presses a keyboard key", async () => {
// Prepare a minimal mock page with required APIs
const page: any = {
on: vi.fn(),
off: vi.fn(),
screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"),
url: vi.fn().mockReturnValue("https://example.com"),
viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }),
waitForNavigation: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(undefined),
keyboard: {
press: vi.fn().mockResolvedValue(undefined),
type: vi.fn().mockResolvedValue(undefined),
},
}
// Create a fresh BrowserSession with a mock context
const mockCtx: any = {
globalState: { get: vi.fn(), update: vi.fn() },
globalStorageUri: { fsPath: "/mock/global/storage/path" },
extensionUri: { fsPath: "/mock/extension/path" },
}
const session = new BrowserSession(mockCtx)
;(session as any).page = page
await session.press("Enter")
expect(page.keyboard.press).toHaveBeenCalledTimes(1)
expect(page.keyboard.press).toHaveBeenCalledWith("Enter")
})
})
describe("cursor visualization", () => {
it("should draw cursor indicator when cursor position exists", async () => {
// Prepare a minimal mock page with required APIs
const page: any = {
on: vi.fn(),
off: vi.fn(),
screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"),
url: vi.fn().mockReturnValue("https://example.com"),
viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }),
evaluate: vi.fn().mockResolvedValue(undefined),
mouse: {
click: vi.fn().mockResolvedValue(undefined),
},
}
// Create a fresh BrowserSession with a mock context
const mockCtx: any = {
globalState: { get: vi.fn(), update: vi.fn() },
globalStorageUri: { fsPath: "/mock/global/storage/path" },
extensionUri: { fsPath: "/mock/extension/path" },
}
const session = new BrowserSession(mockCtx)
;(session as any).page = page
// Perform a click action which sets cursor position
const result = await session.click("100,200")
// Verify cursor indicator was drawn and removed
// evaluate is called 3 times: 1 for forceLinksToSameTab, 1 for draw cursor, 1 for remove cursor
expect(page.evaluate).toHaveBeenCalled()
// Verify the result includes cursor position
expect(result.currentMousePosition).toBe("100,200")
})
it("should include cursor position in action result", async () => {
// Prepare a minimal mock page with required APIs
const page: any = {
on: vi.fn(),
off: vi.fn(),
screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"),
url: vi.fn().mockReturnValue("https://example.com"),
viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }),
evaluate: vi.fn().mockResolvedValue(undefined),
mouse: {
move: vi.fn().mockResolvedValue(undefined),
},
}
// Create a fresh BrowserSession with a mock context
const mockCtx: any = {
globalState: { get: vi.fn(), update: vi.fn() },
globalStorageUri: { fsPath: "/mock/global/storage/path" },
extensionUri: { fsPath: "/mock/extension/path" },
}
const session = new BrowserSession(mockCtx)
;(session as any).page = page
// Perform a hover action which sets cursor position
const result = await session.hover("150,250")
// Verify the result includes cursor position
expect(result.currentMousePosition).toBe("150,250")
expect(result.viewportWidth).toBe(900)
expect(result.viewportHeight).toBe(600)
})
it("should not draw cursor indicator when no cursor position exists", async () => {
// Prepare a minimal mock page with required APIs
const page: any = {
on: vi.fn(),
off: vi.fn(),
screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"),
url: vi.fn().mockReturnValue("https://example.com"),
viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }),
evaluate: vi.fn().mockResolvedValue(undefined),
}
// Create a fresh BrowserSession with a mock context
const mockCtx: any = {
globalState: { get: vi.fn(), update: vi.fn() },
globalStorageUri: { fsPath: "/mock/global/storage/path" },
extensionUri: { fsPath: "/mock/extension/path" },
}
const session = new BrowserSession(mockCtx)
;(session as any).page = page
// Perform scroll action which doesn't set cursor position
const result = await session.scrollDown()
// Verify evaluate was called only for scroll operation (not for cursor drawing/removal)
// scrollDown calls evaluate once for scrolling
expect(page.evaluate).toHaveBeenCalledTimes(1)
// Verify no cursor position in result
expect(result.currentMousePosition).toBeUndefined()
})
describe("saveScreenshot", () => {
// Use a cross-platform workspace path for testing
const testWorkspace = path.resolve("/workspace")
it("should save screenshot to specified path with png format", async () => {
const mockFs = await import("fs/promises")
const page: any = {
on: vi.fn(),
off: vi.fn(),
screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"),
url: vi.fn().mockReturnValue("https://example.com"),
viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }),
evaluate: vi.fn().mockResolvedValue(undefined),
}
const mockCtx: any = {
globalState: { get: vi.fn(), update: vi.fn() },
globalStorageUri: { fsPath: "/mock/global/storage/path" },
extensionUri: { fsPath: "/mock/extension/path" },
}
const session = new BrowserSession(mockCtx)
;(session as any).page = page
await session.saveScreenshot("screenshots/test.png", testWorkspace)
expect(mockFs.mkdir).toHaveBeenCalledWith(path.join(testWorkspace, "screenshots"), { recursive: true })
expect(page.screenshot).toHaveBeenCalledWith(
expect.objectContaining({
path: path.join(testWorkspace, "screenshots", "test.png"),
type: "png",
}),
)
})
it("should save screenshot with jpeg format for .jpg extension", async () => {
const page: any = {
on: vi.fn(),
off: vi.fn(),
screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"),
url: vi.fn().mockReturnValue("https://example.com"),
viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }),
evaluate: vi.fn().mockResolvedValue(undefined),
}
const mockCtx: any = {
globalState: { get: vi.fn().mockReturnValue(80), update: vi.fn() },
globalStorageUri: { fsPath: "/mock/global/storage/path" },
extensionUri: { fsPath: "/mock/extension/path" },
}
const session = new BrowserSession(mockCtx)
;(session as any).page = page
await session.saveScreenshot("screenshots/test.jpg", testWorkspace)
expect(page.screenshot).toHaveBeenCalledWith(
expect.objectContaining({
path: path.join(testWorkspace, "screenshots", "test.jpg"),
type: "jpeg",
quality: 80,
}),
)
})
it("should save screenshot with webp format", async () => {
const page: any = {
on: vi.fn(),
off: vi.fn(),
screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"),
url: vi.fn().mockReturnValue("https://example.com"),
viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }),
evaluate: vi.fn().mockResolvedValue(undefined),
}
const mockCtx: any = {
globalState: { get: vi.fn().mockReturnValue(75), update: vi.fn() },
globalStorageUri: { fsPath: "/mock/global/storage/path" },
extensionUri: { fsPath: "/mock/extension/path" },
}
const session = new BrowserSession(mockCtx)
;(session as any).page = page
await session.saveScreenshot("test.webp", testWorkspace)
expect(page.screenshot).toHaveBeenCalledWith(
expect.objectContaining({
path: path.join(testWorkspace, "test.webp"),
type: "webp",
quality: 75,
}),
)
})
it("should reject absolute file paths outside workspace", async () => {
// Create a cross-platform absolute path for testing
const absolutePath = path.resolve("/absolute/path/screenshot.png")
const page: any = {
on: vi.fn(),
off: vi.fn(),
screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"),
url: vi.fn().mockReturnValue("https://example.com"),
viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }),
evaluate: vi.fn().mockResolvedValue(undefined),
}
const mockCtx: any = {
globalState: { get: vi.fn(), update: vi.fn() },
globalStorageUri: { fsPath: "/mock/global/storage/path" },
extensionUri: { fsPath: "/mock/extension/path" },
}
const session = new BrowserSession(mockCtx)
;(session as any).page = page
await expect(session.saveScreenshot(absolutePath, testWorkspace)).rejects.toThrow(/outside the workspace/)
expect(page.screenshot).not.toHaveBeenCalled()
})
it("should reject paths with .. that escape the workspace", async () => {
const page: any = {
on: vi.fn(),
off: vi.fn(),
screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"),
url: vi.fn().mockReturnValue("https://example.com"),
viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }),
evaluate: vi.fn().mockResolvedValue(undefined),
}
const mockCtx: any = {
globalState: { get: vi.fn(), update: vi.fn() },
globalStorageUri: { fsPath: "/mock/global/storage/path" },
extensionUri: { fsPath: "/mock/extension/path" },
}
const session = new BrowserSession(mockCtx)
;(session as any).page = page
await expect(session.saveScreenshot("../../etc/passwd", testWorkspace)).rejects.toThrow(
/outside the workspace/,
)
expect(page.screenshot).not.toHaveBeenCalled()
})
it("should allow paths with .. that stay within workspace", async () => {
const mockFs = await import("fs/promises")
const page: any = {
on: vi.fn(),
off: vi.fn(),
screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"),
url: vi.fn().mockReturnValue("https://example.com"),
viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }),
evaluate: vi.fn().mockResolvedValue(undefined),
}
const mockCtx: any = {
globalState: { get: vi.fn(), update: vi.fn() },
globalStorageUri: { fsPath: "/mock/global/storage/path" },
extensionUri: { fsPath: "/mock/extension/path" },
}
const session = new BrowserSession(mockCtx)
;(session as any).page = page
// Path like "subdir/../screenshot.png" should resolve to "screenshot.png" within workspace
await session.saveScreenshot("subdir/../screenshot.png", testWorkspace)
expect(page.screenshot).toHaveBeenCalledWith(
expect.objectContaining({
path: path.join(testWorkspace, "screenshot.png"),
type: "png",
}),
)
})
})
describe("getViewportSize", () => {
it("falls back to configured viewport when no page or last viewport is available", () => {
const localCtx: any = {
globalState: {
get: vi.fn((key: string) => {
if (key === "browserViewportSize") return "1024x768"
return undefined
}),
update: vi.fn(),
},
globalStorageUri: { fsPath: "/mock/global/storage/path" },
extensionUri: { fsPath: "/mock/extension/path" },
}
const session = new BrowserSession(localCtx)
const vp = (session as any).getViewportSize()
expect(vp).toEqual({ width: 1024, height: 768 })
})
it("returns live page viewport when available and updates lastViewport cache", () => {
const localCtx: any = {
globalState: {
get: vi.fn(),
update: vi.fn(),
},
globalStorageUri: { fsPath: "/mock/global/storage/path" },
extensionUri: { fsPath: "/mock/extension/path" },
}
const session = new BrowserSession(localCtx)
;(session as any).page = {
viewport: vi.fn().mockReturnValue({ width: 1111, height: 555 }),
}
const vp = (session as any).getViewportSize()
expect(vp).toEqual({ width: 1111, height: 555 })
expect((session as any).lastViewportWidth).toBe(1111)
expect((session as any).lastViewportHeight).toBe(555)
})
it("returns cached last viewport when page no longer exists", () => {
const localCtx: any = {
globalState: {
get: vi.fn(),
update: vi.fn(),
},
globalStorageUri: { fsPath: "/mock/global/storage/path" },
extensionUri: { fsPath: "/mock/extension/path" },
}
const session = new BrowserSession(localCtx)
;(session as any).lastViewportWidth = 800
;(session as any).lastViewportHeight = 600
const vp = (session as any).getViewportSize()
expect(vp).toEqual({ width: 800, height: 600 })
})
})
})

View file

@ -1,369 +0,0 @@
// npx vitest services/browser/__tests__/UrlContentFetcher.spec.ts
import * as path from "path"
import { UrlContentFetcher } from "../UrlContentFetcher"
// Mock dependencies
vi.mock("vscode", () => ({
ExtensionContext: vi.fn(),
Uri: {
file: vi.fn((path) => ({ fsPath: path })),
},
}))
// Mock fs/promises
vi.mock("fs/promises", () => ({
default: {
mkdir: vi.fn().mockResolvedValue(undefined),
},
mkdir: vi.fn().mockResolvedValue(undefined),
}))
// Mock utils/fs
vi.mock("../../../utils/fs", () => ({
fileExistsAtPath: vi.fn().mockResolvedValue(true),
}))
// Mock cheerio
vi.mock("cheerio", () => ({
load: vi.fn(() => {
const $ = vi.fn((selector) => ({
remove: vi.fn().mockReturnThis(),
})) as any
$.html = vi.fn().mockReturnValue("<html><body>Test content</body></html>")
return $
}),
}))
// Mock turndown
vi.mock("turndown", () => {
return {
default: class MockTurndownService {
turndown = vi.fn().mockReturnValue("# Test content")
},
}
})
// Mock puppeteer-chromium-resolver
vi.mock("puppeteer-chromium-resolver", () => ({
default: vi.fn().mockResolvedValue({
puppeteer: {
launch: vi.fn().mockResolvedValue({
newPage: vi.fn().mockResolvedValue({
goto: vi.fn(),
content: vi.fn().mockResolvedValue("<html><body>Test content</body></html>"),
setViewport: vi.fn().mockResolvedValue(undefined),
setExtraHTTPHeaders: vi.fn().mockResolvedValue(undefined),
}),
close: vi.fn().mockResolvedValue(undefined),
}),
},
executablePath: "/path/to/chromium",
}),
}))
// Mock serialize-error
vi.mock("serialize-error", () => ({
serializeError: vi.fn((error) => {
if (error instanceof Error) {
return { message: error.message, name: error.name }
} else if (typeof error === "string") {
return { message: error }
} else if (error && typeof error === "object" && "message" in error) {
return { message: String(error.message), name: "name" in error ? String(error.name) : undefined }
} else {
return { message: String(error) }
}
}),
}))
describe("UrlContentFetcher", () => {
let urlContentFetcher: UrlContentFetcher
let mockContext: any
let mockPage: any
let mockBrowser: any
let PCR: any
beforeEach(async () => {
vi.clearAllMocks()
mockContext = {
globalStorageUri: {
fsPath: "/test/storage",
},
}
mockPage = {
goto: vi.fn(),
content: vi.fn().mockResolvedValue("<html><body>Test content</body></html>"),
setViewport: vi.fn().mockResolvedValue(undefined),
setExtraHTTPHeaders: vi.fn().mockResolvedValue(undefined),
}
mockBrowser = {
newPage: vi.fn().mockResolvedValue(mockPage),
close: vi.fn().mockResolvedValue(undefined),
}
// Reset PCR mock
// @ts-ignore
PCR = (await import("puppeteer-chromium-resolver")).default
vi.mocked(PCR).mockResolvedValue({
puppeteer: {
launch: vi.fn().mockResolvedValue(mockBrowser),
},
executablePath: "/path/to/chromium",
})
urlContentFetcher = new UrlContentFetcher(mockContext)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe("launchBrowser", () => {
it("should launch browser with correct arguments on non-Linux platforms", async () => {
// Ensure we're not on Linux for this test
const originalPlatform = process.platform
Object.defineProperty(process, "platform", {
value: "darwin", // macOS
})
try {
await urlContentFetcher.launchBrowser()
expect(vi.mocked(PCR)).toHaveBeenCalledWith({
downloadPath: path.join("/test/storage", "puppeteer"),
})
const stats = await vi.mocked(PCR).mock.results[0].value
expect(stats.puppeteer.launch).toHaveBeenCalledWith({
args: [
"--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
"--disable-dev-shm-usage",
"--disable-accelerated-2d-canvas",
"--no-first-run",
"--disable-gpu",
"--disable-features=VizDisplayCompositor",
],
executablePath: "/path/to/chromium",
})
} finally {
// Restore original platform
Object.defineProperty(process, "platform", {
value: originalPlatform,
})
}
})
it("should launch browser with Linux-specific arguments", async () => {
// Mock process.platform to be linux
const originalPlatform = process.platform
Object.defineProperty(process, "platform", {
value: "linux",
})
try {
// Create a new instance to ensure fresh state
const linuxFetcher = new UrlContentFetcher(mockContext)
await linuxFetcher.launchBrowser()
expect(vi.mocked(PCR)).toHaveBeenCalledWith({
downloadPath: path.join("/test/storage", "puppeteer"),
})
const stats = await vi.mocked(PCR).mock.results[vi.mocked(PCR).mock.results.length - 1].value
expect(stats.puppeteer.launch).toHaveBeenCalledWith({
args: [
"--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
"--disable-dev-shm-usage",
"--disable-accelerated-2d-canvas",
"--no-first-run",
"--disable-gpu",
"--disable-features=VizDisplayCompositor",
"--no-sandbox", // Linux-specific argument
],
executablePath: "/path/to/chromium",
})
} finally {
// Restore original platform
Object.defineProperty(process, "platform", {
value: originalPlatform,
})
}
})
it("should set viewport and headers after launching", async () => {
await urlContentFetcher.launchBrowser()
expect(mockPage.setViewport).toHaveBeenCalledWith({ width: 1280, height: 720 })
expect(mockPage.setExtraHTTPHeaders).toHaveBeenCalledWith({
"Accept-Language": "en-US,en;q=0.9",
})
})
it("should not launch browser if already launched", async () => {
await urlContentFetcher.launchBrowser()
const initialCallCount = vi.mocked(PCR).mock.calls.length
await urlContentFetcher.launchBrowser()
expect(vi.mocked(PCR)).toHaveBeenCalledTimes(initialCallCount)
})
})
describe("urlToMarkdown", () => {
beforeEach(async () => {
await urlContentFetcher.launchBrowser()
})
it("should successfully fetch and convert URL to markdown", async () => {
mockPage.goto.mockResolvedValueOnce(undefined)
const result = await urlContentFetcher.urlToMarkdown("https://example.com")
expect(mockPage.goto).toHaveBeenCalledWith("https://example.com", {
timeout: 30000,
waitUntil: ["domcontentloaded", "networkidle2"],
})
expect(result).toBe("# Test content")
})
it("should retry with domcontentloaded only when networkidle2 fails", async () => {
const timeoutError = new Error("Navigation timeout of 30000 ms exceeded")
mockPage.goto.mockRejectedValueOnce(timeoutError).mockResolvedValueOnce(undefined)
const result = await urlContentFetcher.urlToMarkdown("https://example.com")
expect(mockPage.goto).toHaveBeenCalledTimes(2)
expect(mockPage.goto).toHaveBeenNthCalledWith(1, "https://example.com", {
timeout: 30000,
waitUntil: ["domcontentloaded", "networkidle2"],
})
expect(mockPage.goto).toHaveBeenNthCalledWith(2, "https://example.com", {
timeout: 20000,
waitUntil: ["domcontentloaded"],
})
expect(result).toBe("# Test content")
})
it("should retry for network errors", async () => {
const networkError = new Error("net::ERR_CONNECTION_REFUSED")
mockPage.goto.mockRejectedValueOnce(networkError).mockResolvedValueOnce(undefined)
const result = await urlContentFetcher.urlToMarkdown("https://example.com")
expect(mockPage.goto).toHaveBeenCalledTimes(2)
expect(result).toBe("# Test content")
})
it("should retry for TimeoutError", async () => {
const timeoutError = new Error("TimeoutError: Navigation timeout")
timeoutError.name = "TimeoutError"
mockPage.goto.mockRejectedValueOnce(timeoutError).mockResolvedValueOnce(undefined)
const result = await urlContentFetcher.urlToMarkdown("https://example.com")
expect(mockPage.goto).toHaveBeenCalledTimes(2)
expect(result).toBe("# Test content")
})
it("should not retry for non-network/timeout errors", async () => {
const otherError = new Error("Some other error")
mockPage.goto.mockRejectedValueOnce(otherError)
await expect(urlContentFetcher.urlToMarkdown("https://example.com")).rejects.toThrow("Some other error")
expect(mockPage.goto).toHaveBeenCalledTimes(1)
})
it("should throw error if browser not initialized", async () => {
const newFetcher = new UrlContentFetcher(mockContext)
await expect(newFetcher.urlToMarkdown("https://example.com")).rejects.toThrow("Browser not initialized")
})
it("should handle errors without message property", async () => {
const errorWithoutMessage = { code: "UNKNOWN_ERROR" }
mockPage.goto.mockRejectedValueOnce(errorWithoutMessage)
// serialize-error will convert this to a proper error with the object stringified
await expect(urlContentFetcher.urlToMarkdown("https://example.com")).rejects.toThrow()
// Should not retry for non-network errors
expect(mockPage.goto).toHaveBeenCalledTimes(1)
})
it("should handle error objects with message property", async () => {
const errorWithMessage = { message: "Custom error", code: "CUSTOM_ERROR" }
mockPage.goto.mockRejectedValueOnce(errorWithMessage)
await expect(urlContentFetcher.urlToMarkdown("https://example.com")).rejects.toThrow("Custom error")
// Should not retry for error objects with message property (they're treated as known errors)
expect(mockPage.goto).toHaveBeenCalledTimes(1)
})
it("should retry for error objects with network-related messages", async () => {
const errorWithNetworkMessage = { message: "net::ERR_CONNECTION_REFUSED", code: "NETWORK_ERROR" }
mockPage.goto.mockRejectedValueOnce(errorWithNetworkMessage).mockResolvedValueOnce(undefined)
const result = await urlContentFetcher.urlToMarkdown("https://example.com")
// Should retry for network-related errors even in non-Error objects
expect(mockPage.goto).toHaveBeenCalledTimes(2)
expect(result).toBe("# Test content")
})
it("should handle string errors", async () => {
const stringError = "Simple string error"
mockPage.goto.mockRejectedValueOnce(stringError)
await expect(urlContentFetcher.urlToMarkdown("https://example.com")).rejects.toThrow("Simple string error")
expect(mockPage.goto).toHaveBeenCalledTimes(1)
})
it("should retry net::ERR_ABORTED like other network errors", async () => {
const abortedError = new Error("net::ERR_ABORTED at https://example.com")
mockPage.goto.mockRejectedValueOnce(abortedError).mockResolvedValueOnce(undefined)
const result = await urlContentFetcher.urlToMarkdown("https://example.com")
expect(mockPage.goto).toHaveBeenCalledTimes(2)
expect(mockPage.goto).toHaveBeenNthCalledWith(1, "https://example.com", {
timeout: 30000,
waitUntil: ["domcontentloaded", "networkidle2"],
})
expect(mockPage.goto).toHaveBeenNthCalledWith(2, "https://example.com", {
timeout: 20000,
waitUntil: ["domcontentloaded"],
})
expect(result).toBe("# Test content")
})
it("should throw error when ERR_ABORTED retry also fails", async () => {
const abortedError = new Error("net::ERR_ABORTED at https://example.com")
const retryError = new Error("net::ERR_CONNECTION_REFUSED")
mockPage.goto.mockRejectedValueOnce(abortedError).mockRejectedValueOnce(retryError)
await expect(urlContentFetcher.urlToMarkdown("https://example.com")).rejects.toThrow(
"net::ERR_CONNECTION_REFUSED",
)
expect(mockPage.goto).toHaveBeenCalledTimes(2)
})
})
describe("closeBrowser", () => {
it("should close browser and reset state", async () => {
await urlContentFetcher.launchBrowser()
await urlContentFetcher.closeBrowser()
expect(mockBrowser.close).toHaveBeenCalled()
})
it("should handle closing when browser not initialized", async () => {
await expect(urlContentFetcher.closeBrowser()).resolves.not.toThrow()
})
})
})

View file

@ -1,181 +0,0 @@
import * as net from "net"
import axios from "axios"
import * as dns from "dns"
/**
* Check if a port is open on a given host
*/
export async function isPortOpen(host: string, port: number, timeout = 1000): Promise<boolean> {
return new Promise((resolve) => {
const socket = new net.Socket()
let status = false
// Set timeout
socket.setTimeout(timeout)
// Handle successful connection
socket.on("connect", () => {
status = true
socket.destroy()
})
// Handle any errors
socket.on("error", () => {
socket.destroy()
})
// Handle timeout
socket.on("timeout", () => {
socket.destroy()
})
// Handle close
socket.on("close", () => {
resolve(status)
})
// Attempt to connect
socket.connect(port, host)
})
}
/**
* Try to connect to Chrome at a specific IP address
*/
export async function tryChromeHostUrl(chromeHostUrl: string): Promise<boolean> {
try {
console.log(`Trying to connect to Chrome at: ${chromeHostUrl}/json/version`)
await axios.get(`${chromeHostUrl}/json/version`, { timeout: 1000 })
return true
} catch (error) {
return false
}
}
/**
* Get Docker host IP
*/
export async function getDockerHostIP(): Promise<string | null> {
try {
// Try to resolve host.docker.internal (works on Docker Desktop)
return new Promise((resolve) => {
dns.lookup("host.docker.internal", (err: any, address: string) => {
if (err) {
resolve(null)
} else {
resolve(address)
}
})
})
} catch (error) {
console.log("Could not determine Docker host IP:", error)
return null
}
}
/**
* Scan a network range for Chrome debugging port
*/
export async function scanNetworkForChrome(baseIP: string, port: number): Promise<string | null> {
if (!baseIP || !baseIP.match(/^\d+\.\d+\.\d+\./)) {
return null
}
// Extract the network prefix (e.g., "192.168.65.")
const networkPrefix = baseIP.split(".").slice(0, 3).join(".") + "."
// Common Docker host IPs to try first
const priorityIPs = [
networkPrefix + "1", // Common gateway
networkPrefix + "2", // Common host
networkPrefix + "254", // Common host in some Docker setups
]
console.log(`Scanning priority IPs in network ${networkPrefix}*`)
// Check priority IPs first
for (const ip of priorityIPs) {
const isOpen = await isPortOpen(ip, port)
if (isOpen) {
console.log(`Found Chrome debugging port open on ${ip}`)
return ip
}
}
return null
}
// Function to discover Chrome instances on the network
const discoverChromeHosts = async (port: number): Promise<string | null> => {
// Get all network interfaces
const ipAddresses = []
// Try to get Docker host IP
const hostIP = await getDockerHostIP()
if (hostIP) {
console.log("Found Docker host IP:", hostIP)
ipAddresses.push(hostIP)
}
// Remove duplicates
const uniqueIPs = [...new Set(ipAddresses)]
console.log("IP Addresses to try:", uniqueIPs)
// Try connecting to each IP address
for (const ip of uniqueIPs) {
const hostEndpoint = `http://${ip}:${port}`
const hostIsValid = await tryChromeHostUrl(hostEndpoint)
if (hostIsValid) {
// Store the successful IP for future use
console.log(`✅ Found Chrome at ${hostEndpoint}`)
// Return the host URL and endpoint
return hostEndpoint
}
}
return null
}
/**
* Test connection to a remote browser debugging websocket.
* First tries specific hosts, then attempts auto-discovery if needed.
* @param browserHostUrl Optional specific host URL to check first
* @param port Browser debugging port (default: 9222)
* @returns WebSocket debugger URL if connection is successful, null otherwise
*/
export async function discoverChromeHostUrl(port: number = 9222): Promise<string | null> {
// First try specific hosts
const hostsToTry = [`http://localhost:${port}`, `http://127.0.0.1:${port}`]
// Try each host directly first
for (const hostUrl of hostsToTry) {
console.log(`Trying to connect to: ${hostUrl}`)
try {
const hostIsValid = await tryChromeHostUrl(hostUrl)
if (hostIsValid) return hostUrl
} catch (error) {
console.log(`Failed to connect to ${hostUrl}: ${error instanceof Error ? error.message : error}`)
}
}
// If direct connections failed, attempt auto-discovery
console.log("Direct connections failed. Attempting auto-discovery...")
const discoveredHostUrl = await discoverChromeHosts(port)
if (discoveredHostUrl) {
console.log(`Trying to connect to discovered host: ${discoveredHostUrl}`)
try {
const hostIsValid = await tryChromeHostUrl(discoveredHostUrl)
if (hostIsValid) return discoveredHostUrl
console.log(`Failed to connect to discovered host ${discoveredHostUrl}`)
} catch (error) {
console.log(`Error connecting to discovered host: ${error instanceof Error ? error.message : error}`)
}
} else {
console.log("No browser instances discovered on network")
}
return null
}

View file

@ -19,19 +19,19 @@ describe("isToolAllowedForMode", () => {
slug: "markdown-editor",
name: "Markdown Editor",
roleDefinition: "You are a markdown editor",
groups: ["read", ["edit", { fileRegex: "\\.md$" }], "browser"],
groups: ["read", ["edit", { fileRegex: "\\.md$" }]],
},
{
slug: "css-editor",
name: "CSS Editor",
roleDefinition: "You are a CSS editor",
groups: ["read", ["edit", { fileRegex: "\\.css$" }], "browser"],
groups: ["read", ["edit", { fileRegex: "\\.css$" }]],
},
{
slug: "test-exp-mode",
name: "Test Exp Mode",
roleDefinition: "You are an experimental tester",
groups: ["read", "edit", "browser"],
groups: ["read", "edit"],
},
]
@ -42,7 +42,6 @@ describe("isToolAllowedForMode", () => {
it("allows unrestricted tools", () => {
expect(isToolAllowedForMode("read_file", "markdown-editor", customModes)).toBe(true)
expect(isToolAllowedForMode("browser_action", "markdown-editor", customModes)).toBe(true)
})
describe("file restrictions", () => {
@ -151,11 +150,7 @@ describe("isToolAllowedForMode", () => {
slug: "docs-editor",
name: "Documentation Editor",
roleDefinition: "You are a documentation editor",
groups: [
"read",
["edit", { fileRegex: "\\.(md|txt)$", description: "Documentation files only" }],
"browser",
],
groups: ["read", ["edit", { fileRegex: "\\.(md|txt)$", description: "Documentation files only" }]],
},
]
@ -243,7 +238,6 @@ describe("isToolAllowedForMode", () => {
// Should maintain read capabilities
expect(isToolAllowedForMode("read_file", "architect", [])).toBe(true)
expect(isToolAllowedForMode("browser_action", "architect", [])).toBe(true)
expect(isToolAllowedForMode("use_mcp_tool", "architect", [])).toBe(true)
})
@ -535,7 +529,7 @@ describe("isToolAllowedForMode", () => {
slug: "test-custom-tools",
name: "Test Custom Tools Mode",
roleDefinition: "You are a test mode",
groups: ["read", "edit", "browser"],
groups: ["read", "edit"],
},
]
@ -567,7 +561,7 @@ describe("isToolAllowedForMode", () => {
slug: "no-edit-mode",
name: "No Edit Mode",
roleDefinition: "You have no edit powers",
groups: ["read", "browser"], // No edit group
groups: ["read"], // No edit group
},
]
@ -619,7 +613,7 @@ describe("FileRestrictionError", () => {
name: "🪲 Debug",
roleDefinition:
"You are Roo, an expert software debugger specializing in systematic problem diagnosis and resolution.",
groups: ["read", "edit", "browser", "command", "mcp"],
groups: ["read", "edit", "command", "mcp"],
})
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.",

View file

@ -1,95 +0,0 @@
/**
* Parses coordinate string and scales from image dimensions to viewport dimensions
* The LLM examines the screenshot it receives (which may be downscaled by the API)
* and reports coordinates in format: "x,y@widthxheight" where widthxheight is what the LLM observed
*
* Format: "x,y@widthxheight" (required)
* Returns: scaled coordinate string "x,y" in viewport coordinates
* Throws: Error if format is invalid or missing image dimensions
*/
export function scaleCoordinate(coordinate: string, viewportWidth: number, viewportHeight: number): string {
// Parse coordinate with required image dimensions (accepts both 'x' and ',' as dimension separators)
const match = coordinate.match(/^\s*(\d+)\s*,\s*(\d+)\s*@\s*(\d+)\s*[x,]\s*(\d+)\s*$/)
if (!match) {
throw new Error(
`Invalid coordinate format: "${coordinate}". ` +
`Expected format: "x,y@widthxheight" (e.g., "450,300@1024x768")`,
)
}
const [, xStr, yStr, imgWidthStr, imgHeightStr] = match
const x = parseInt(xStr, 10)
const y = parseInt(yStr, 10)
const imgWidth = parseInt(imgWidthStr, 10)
const imgHeight = parseInt(imgHeightStr, 10)
// Scale coordinates from image dimensions to viewport dimensions
const scaledX = Math.round((x / imgWidth) * viewportWidth)
const scaledY = Math.round((y / imgHeight) * viewportHeight)
return `${scaledX},${scaledY}`
}
/**
* Formats a key string into a more readable format (e.g., "Control+c" -> "Ctrl + C")
*/
export function prettyKey(k?: string): string {
if (!k) return ""
return k
.split("+")
.map((part) => {
const p = part.trim()
const lower = p.toLowerCase()
const map: Record<string, string> = {
enter: "Enter",
tab: "Tab",
escape: "Esc",
esc: "Esc",
backspace: "Backspace",
space: "Space",
shift: "Shift",
control: "Ctrl",
ctrl: "Ctrl",
alt: "Alt",
meta: "Meta",
command: "Cmd",
cmd: "Cmd",
arrowup: "Arrow Up",
arrowdown: "Arrow Down",
arrowleft: "Arrow Left",
arrowright: "Arrow Right",
pageup: "Page Up",
pagedown: "Page Down",
home: "Home",
end: "End",
}
if (map[lower]) return map[lower]
const keyMatch = /^Key([A-Z])$/.exec(p)
if (keyMatch) return keyMatch[1].toUpperCase()
const digitMatch = /^Digit([0-9])$/.exec(p)
if (digitMatch) return digitMatch[1]
const spaced = p.replace(/([a-z])([A-Z])/g, "$1 $2")
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
})
.join(" + ")
}
/**
* Wrapper around scaleCoordinate that handles failures gracefully by checking for simple coordinates
*/
export function getViewportCoordinate(
coord: string | undefined,
viewportWidth: number,
viewportHeight: number,
): string {
if (!coord) return ""
try {
return scaleCoordinate(coord, viewportWidth, viewportHeight)
} catch (e) {
// Fallback to simple x,y parsing or return as is
const simpleMatch = /^\s*(\d+)\s*,\s*(\d+)/.exec(coord)
return simpleMatch ? `${simpleMatch[1]},${simpleMatch[2]}` : coord
}
}

View file

@ -1,13 +1,6 @@
import type { TextPart, ImagePart } from "../core/task-persistence/rooMessage"
import type {
ClineAsk,
ToolProgressStatus,
ToolGroup,
ToolName,
BrowserActionParams,
GenerateImageParams,
} from "@roo-code/types"
import type { ClineAsk, ToolProgressStatus, ToolGroup, ToolName, GenerateImageParams } from "@roo-code/types"
export type ToolResponse = string | Array<TextPart | ImagePart>
@ -113,7 +106,6 @@ export type NativeToolArgs = {
question: string
follow_up: Array<{ text: string; mode?: string }>
}
browser_action: BrowserActionParams
codebase_search: { query: string; path?: string }
generate_image: GenerateImageParams
run_slash_command: { command: string; args?: string }
@ -220,11 +212,6 @@ export interface ListFilesToolUse extends ToolUse<"list_files"> {
params: Partial<Pick<Record<ToolParamName, string>, "path" | "recursive">>
}
export interface BrowserActionToolUse extends ToolUse<"browser_action"> {
name: "browser_action"
params: Partial<Pick<Record<ToolParamName, string>, "action" | "url" | "coordinate" | "text" | "size" | "path">>
}
export interface UseMcpToolToolUse extends ToolUse<"use_mcp_tool"> {
name: "use_mcp_tool"
params: Partial<Pick<Record<ToolParamName, string>, "server_name" | "tool_name" | "arguments">>
@ -290,7 +277,6 @@ export const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
apply_patch: "apply patches using codex format",
search_files: "search files",
list_files: "list files",
browser_action: "use a browser",
use_mcp_tool: "use mcp tools",
access_mcp_resource: "access mcp resources",
ask_followup_question: "ask questions",
@ -314,9 +300,6 @@ export const TOOL_GROUPS: Record<ToolGroup, ToolGroupConfig> = {
tools: ["apply_diff", "write_to_file", "generate_image"],
customTools: ["edit", "search_replace", "edit_file", "apply_patch"],
},
browser: {
tools: ["browser_action"],
},
command: {
tools: ["execute_command", "read_command_output"],
},

View file

@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Browser Session</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/browser-panel.tsx"></script>
</body>
</html>

View file

@ -1,12 +0,0 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import "./index.css"
import BrowserSessionPanel from "./components/browser-session/BrowserSessionPanel"
import "../node_modules/@vscode/codicons/dist/codicon.css"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserSessionPanel />
</StrictMode>,
)

View file

@ -1,61 +0,0 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from "react"
import { type ExtensionMessage } from "@roo-code/types"
interface BrowserPanelState {
browserViewportSize: string
isBrowserSessionActive: boolean
language: string
}
const BrowserPanelStateContext = createContext<BrowserPanelState | undefined>(undefined)
export const BrowserPanelStateProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, setState] = useState<BrowserPanelState>({
browserViewportSize: "900x600",
isBrowserSessionActive: false,
language: "en",
})
const handleMessage = useCallback((event: MessageEvent) => {
const message: ExtensionMessage = event.data
switch (message.type) {
case "state":
if (message.state) {
setState((prev) => ({
...prev,
browserViewportSize: message.state?.browserViewportSize || "900x600",
isBrowserSessionActive: message.state?.isBrowserSessionActive || false,
language: message.state?.language || "en",
}))
}
break
case "browserSessionUpdate":
if (message.isBrowserSessionActive !== undefined) {
setState((prev) => ({
...prev,
isBrowserSessionActive: message.isBrowserSessionActive || false,
}))
}
break
}
}, [])
useEffect(() => {
window.addEventListener("message", handleMessage)
return () => {
window.removeEventListener("message", handleMessage)
}
}, [handleMessage])
return <BrowserPanelStateContext.Provider value={state}>{children}</BrowserPanelStateContext.Provider>
}
export const useBrowserPanelState = () => {
const context = useContext(BrowserPanelStateContext)
if (context === undefined) {
throw new Error("useBrowserPanelState must be used within a BrowserPanelStateProvider")
}
return context
}

View file

@ -1,106 +0,0 @@
import React, { useEffect, useState } from "react"
import { type ClineMessage, type ExtensionMessage } from "@roo-code/types"
import { TooltipProvider } from "@src/components/ui/tooltip"
import TranslationProvider from "@src/i18n/TranslationContext"
import { vscode } from "@src/utils/vscode"
import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext"
import BrowserSessionRow from "../chat/BrowserSessionRow"
import ErrorBoundary from "../ErrorBoundary"
import { BrowserPanelStateProvider, useBrowserPanelState } from "./BrowserPanelStateProvider"
interface BrowserSessionPanelState {
messages: ClineMessage[]
}
const BrowserSessionPanelContent: React.FC = () => {
const { browserViewportSize, isBrowserSessionActive } = useBrowserPanelState()
const [state, setState] = useState<BrowserSessionPanelState>({
messages: [],
})
// Target page index to navigate BrowserSessionRow to
const [navigateToStepIndex, setNavigateToStepIndex] = useState<number | undefined>(undefined)
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message: ExtensionMessage = event.data
switch (message.type) {
case "browserSessionUpdate":
if (message.browserSessionMessages) {
setState((prev) => ({
...prev,
messages: message.browserSessionMessages || [],
}))
}
break
case "browserSessionNavigate":
if (typeof message.stepIndex === "number" && message.stepIndex >= 0) {
setNavigateToStepIndex(message.stepIndex)
}
break
}
}
window.addEventListener("message", handleMessage)
return () => {
window.removeEventListener("message", handleMessage)
}
}, [])
return (
<div className="fixed top-0 left-0 right-0 bottom-0 flex flex-col overflow-hidden bg-vscode-editor-background">
<BrowserSessionRow
messages={state.messages}
isLast={true}
lastModifiedMessage={state.messages.at(-1)}
isStreaming={false}
isExpanded={(messageTs: number) => expandedRows[messageTs] ?? false}
onToggleExpand={(messageTs: number) => {
setExpandedRows((prev: Record<number, boolean>) => ({
...prev,
[messageTs]: !prev[messageTs],
}))
}}
fullScreen={true}
browserViewportSizeProp={browserViewportSize}
isBrowserSessionActiveProp={isBrowserSessionActive}
navigateToPageIndex={navigateToStepIndex}
/>
</div>
)
}
const BrowserSessionPanel: React.FC = () => {
// Ensure the panel receives initial state and becomes "ready" without needing a second click
useEffect(() => {
try {
vscode.postMessage({ type: "webviewDidLaunch" })
} catch {
// Ignore errors during initial launch
}
}, [])
return (
<ErrorBoundary>
<ExtensionStateContextProvider>
<TooltipProvider>
<TranslationProvider>
<BrowserPanelStateProvider>
<BrowserSessionPanelContent />
</BrowserPanelStateProvider>
</TranslationProvider>
</TooltipProvider>
</ExtensionStateContextProvider>
</ErrorBoundary>
)
}
export default BrowserSessionPanel

View file

@ -34,7 +34,6 @@ export const AutoApproveDropdown = ({ disabled = false, triggerClassName = "" }:
setAlwaysAllowReadOnly,
setAlwaysAllowWrite,
setAlwaysAllowExecute,
setAlwaysAllowBrowser,
setAlwaysAllowMcp,
setAlwaysAllowModeSwitch,
setAlwaysAllowSubtasks,
@ -57,9 +56,6 @@ export const AutoApproveDropdown = ({ disabled = false, triggerClassName = "" }:
case "alwaysAllowExecute":
setAlwaysAllowExecute(value)
break
case "alwaysAllowBrowser":
setAlwaysAllowBrowser(value)
break
case "alwaysAllowMcp":
setAlwaysAllowMcp(value)
break
@ -85,7 +81,6 @@ export const AutoApproveDropdown = ({ disabled = false, triggerClassName = "" }:
setAlwaysAllowReadOnly,
setAlwaysAllowWrite,
setAlwaysAllowExecute,
setAlwaysAllowBrowser,
setAlwaysAllowMcp,
setAlwaysAllowModeSwitch,
setAlwaysAllowSubtasks,

View file

@ -1,195 +0,0 @@
import { memo, useMemo, useEffect, useRef } from "react"
import { useTranslation } from "react-i18next"
import {
MousePointer as MousePointerIcon,
Keyboard,
ArrowDown,
ArrowUp,
Pointer,
Play,
Check,
Maximize2,
Camera,
} from "lucide-react"
import type { ClineMessage, ClineSayBrowserAction } from "@roo-code/types"
import { getViewportCoordinate as getViewportCoordinateShared, prettyKey } from "@roo/browserUtils"
import { vscode } from "@src/utils/vscode"
import { useExtensionState } from "@src/context/ExtensionStateContext"
interface BrowserActionRowProps {
message: ClineMessage
nextMessage?: ClineMessage
actionIndex?: number
totalActions?: number
}
// Get icon for each action type
const getActionIcon = (action: string) => {
switch (action) {
case "click":
return <MousePointerIcon className="w-3.5 h-3.5 opacity-70" />
case "type":
case "press":
return <Keyboard className="w-3.5 h-3.5 opacity-70" />
case "scroll_down":
return <ArrowDown className="w-3.5 h-3.5 opacity-70" />
case "scroll_up":
return <ArrowUp className="w-3.5 h-3.5 opacity-70" />
case "launch":
return <Play className="w-3.5 h-3.5 opacity-70" />
case "close":
return <Check className="w-3.5 h-3.5 opacity-70" />
case "resize":
return <Maximize2 className="w-3.5 h-3.5 opacity-70" />
case "screenshot":
return <Camera className="w-3.5 h-3.5 opacity-70" />
case "hover":
default:
return <Pointer className="w-3.5 h-3.5 opacity-70" />
}
}
const BrowserActionRow = memo(({ message, nextMessage, actionIndex, totalActions }: BrowserActionRowProps) => {
const { t } = useTranslation()
const { isBrowserSessionActive } = useExtensionState()
const hasHandledAutoOpenRef = useRef(false)
// Parse this specific browser action
const browserAction = useMemo<ClineSayBrowserAction | null>(() => {
try {
return JSON.parse(message.text || "{}") as ClineSayBrowserAction
} catch {
return null
}
}, [message.text])
// Get viewport dimensions from the result message if available
const viewportDimensions = useMemo(() => {
if (!nextMessage || nextMessage.say !== "browser_action_result") return null
try {
const result = JSON.parse(nextMessage.text || "{}")
return {
width: result.viewportWidth,
height: result.viewportHeight,
}
} catch {
return null
}
}, [nextMessage])
// Format action display text
const actionText = useMemo(() => {
if (!browserAction) return t("chat:browser.actions.title")
// Helper to scale coordinates from screenshot dimensions to viewport dimensions
// Matches the backend's scaleCoordinate function logic
const getViewportCoordinate = (coord?: string): string =>
getViewportCoordinateShared(coord, viewportDimensions?.width ?? 0, viewportDimensions?.height ?? 0)
switch (browserAction.action) {
case "launch":
return t("chat:browser.actions.launched")
case "click":
return t("chat:browser.actions.clicked", {
coordinate: browserAction.executedCoordinate || getViewportCoordinate(browserAction.coordinate),
})
case "type":
return t("chat:browser.actions.typed", { text: browserAction.text })
case "press":
return t("chat:browser.actions.pressed", { key: prettyKey(browserAction.text) })
case "hover":
return t("chat:browser.actions.hovered", {
coordinate: browserAction.executedCoordinate || getViewportCoordinate(browserAction.coordinate),
})
case "scroll_down":
return t("chat:browser.actions.scrolledDown")
case "scroll_up":
return t("chat:browser.actions.scrolledUp")
case "resize":
return t("chat:browser.actions.resized", { size: browserAction.size?.split(/[x,]/).join(" x ") })
case "screenshot":
return t("chat:browser.actions.screenshotSaved")
case "close":
return t("chat:browser.actions.closed")
default:
return browserAction.action
}
}, [browserAction, viewportDimensions, t])
// Auto-open Browser Session panel when:
// 1. This is a "launch" action (new browser session) - always opens and navigates to launch
// 2. Regular actions - only open panel if user hasn't manually closed it, let internal auto-advance logic handle step
// Only run this once per action to avoid re-sending messages when scrolling
useEffect(() => {
if (!isBrowserSessionActive || hasHandledAutoOpenRef.current) {
return
}
const isLaunchAction = browserAction?.action === "launch"
if (isLaunchAction) {
// Launch action: navigate to step 0 (the launch)
vscode.postMessage({
type: "showBrowserSessionPanelAtStep",
stepIndex: 0,
isLaunchAction: true,
})
hasHandledAutoOpenRef.current = true
} else {
// Regular actions: just show panel, don't navigate
// BrowserSessionRow's internal auto-advance logic will handle jumping to new steps
// only if user is currently on the most recent step
vscode.postMessage({
type: "showBrowserSessionPanelAtStep",
isLaunchAction: false,
})
hasHandledAutoOpenRef.current = true
}
}, [isBrowserSessionActive, browserAction])
const headerStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "10px",
marginBottom: "10px",
wordBreak: "break-word",
}
return (
<div className="px-[15px] py-[10px] pr-[6px]">
{/* Header with action description - clicking opens Browser Session panel at this step */}
<div
style={headerStyle}
className="cursor-pointer"
onClick={() => {
const idx = typeof actionIndex === "number" ? Math.max(0, actionIndex - 1) : 0
vscode.postMessage({ type: "showBrowserSessionPanelAtStep", stepIndex: idx, forceShow: true })
}}>
<span
className="codicon codicon-globe text-vscode-testing-iconPassed shrink-0"
style={{ marginBottom: "-1.5px" }}
/>
<span style={{ fontWeight: "bold" }}>{t("chat:browser.actions.title")}</span>
{actionIndex !== undefined && totalActions !== undefined && (
<span style={{ fontWeight: "bold" }}>
{" "}
- {actionIndex}/{totalActions} -{" "}
</span>
)}
{browserAction && (
<>
<span className="shrink-0">{getActionIcon(browserAction.action)}</span>
<span className="flex-1 truncate">{actionText}</span>
</>
)}
</div>
</div>
)
})
BrowserActionRow.displayName = "BrowserActionRow"
export default BrowserActionRow

File diff suppressed because it is too large Load diff

View file

@ -1,34 +0,0 @@
import { memo } from "react"
import { Globe } from "lucide-react"
import { ClineMessage } from "@roo-code/types"
interface BrowserSessionStatusRowProps {
message: ClineMessage
}
const BrowserSessionStatusRow = memo(({ message }: BrowserSessionStatusRowProps) => {
const isOpened = message.text?.includes("opened")
return (
<div className="flex items-center gap-2 py-2 px-[15px] text-sm">
<Globe
className="w-4 h-4 shrink-0"
style={{
opacity: 0.7,
color: isOpened ? "#4ade80" : "#9ca3af", // green when opened, gray when closed
}}
/>
<span
style={{
color: isOpened ? "var(--vscode-testing-iconPassed)" : "var(--vscode-descriptionForeground)",
fontWeight: 500,
}}>
{message.text}
</span>
</div>
)
})
BrowserSessionStatusRow.displayName = "BrowserSessionStatusRow"
export default BrowserSessionStatusRow

View file

@ -1558,10 +1558,6 @@ export const ChatRowContent = ({
<ImageBlock imageUri={imageInfo.imageUri} imagePath={imageInfo.imagePath} />
</div>
)
case "browser_action":
case "browser_action_result":
// Handled by BrowserSessionRow; prevent raw JSON (action/result) from rendering here
return null
case "too_many_tools_warning": {
const warningData = safeJsonParse<{
toolCount: number

View file

@ -52,9 +52,6 @@ interface ChatTextAreaProps {
// Edit mode props
isEditMode?: boolean
onCancel?: () => void
// Browser session status
isBrowserSessionActive?: boolean
showBrowserDockToggle?: boolean
// Stop/Queue functionality
isStreaming?: boolean
onStop?: () => void
@ -79,8 +76,6 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
modeShortcutText,
isEditMode = false,
onCancel,
isBrowserSessionActive = false,
showBrowserDockToggle = false,
isStreaming = false,
onStop,
onEnqueueMessage,
@ -1354,12 +1349,6 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
)}
{!isEditMode ? <IndexingStatusBadge /> : null}
{!isEditMode && cloudUserInfo && <CloudAccountSwitcher />}
{/* keep props referenced after moving browser button */}
<div
className="hidden"
data-browser-session-active={isBrowserSessionActive}
data-show-browser-dock-toggle={showBrowserDockToggle}
/>
</div>
</div>
</div>

View file

@ -38,8 +38,6 @@ import TelemetryBanner from "../common/TelemetryBanner"
import VersionIndicator from "../common/VersionIndicator"
import HistoryPreview from "../history/HistoryPreview"
import Announcement from "./Announcement"
import BrowserActionRow from "./BrowserActionRow"
import BrowserSessionStatusRow from "./BrowserSessionStatusRow"
import ChatRow from "./ChatRow"
import WarningRow from "./WarningRow"
import { ChatTextArea } from "./ChatTextArea"
@ -95,7 +93,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
soundVolume,
cloudIsAuthenticated,
messageQueue = [],
isBrowserSessionActive,
showWorktreesInHomeScreen,
} = useExtensionState()
@ -370,13 +367,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
break
}
break
case "browser_action_launch":
setSendingDisabled(isPartial)
setClineAsk("browser_action_launch")
setEnableButtons(!isPartial)
setPrimaryButtonText(t("chat:approve.title"))
setSecondaryButtonText(t("chat:reject.title"))
break
case "command":
setSendingDisabled(isPartial)
setClineAsk("command")
@ -467,8 +457,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
case "api_req_finished":
case "error":
case "text":
case "browser_action":
case "browser_action_result":
case "command_output":
case "mcp_server_request_started":
case "mcp_server_response":
@ -713,7 +701,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
) {
case "followup":
case "tool":
case "browser_action_launch":
case "command": // User can provide feedback to a tool or command use.
case "use_mcp_server":
case "completion_result": // If this happens then the user has feedback for the completion result.
@ -801,7 +788,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
case "api_req_failed":
case "command":
case "tool":
case "browser_action_launch":
case "use_mcp_server":
case "mistake_limit_reached":
// Only send text/images if they exist
@ -886,7 +872,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
break
case "command":
case "tool":
case "browser_action_launch":
case "use_mcp_server":
// Only send text/images if they exist
if (trimmedInput || (images && images.length > 0)) {
@ -1179,43 +1164,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setWasStreaming(isStreaming)
}, [isStreaming, lastMessage, wasStreaming, messages.length])
// Compute current browser session messages for the top banner (not grouped into chat stream)
// Find the FIRST browser session from the beginning to show ALL sessions
const browserSessionStartIndex = useMemo(() => {
for (let i = 0; i < messages.length; i++) {
if (messages[i].ask === "browser_action_launch") {
return i
}
}
return -1
}, [messages])
const _browserSessionMessages = useMemo<ClineMessage[]>(() => {
if (browserSessionStartIndex === -1) return []
return messages.slice(browserSessionStartIndex)
}, [browserSessionStartIndex, messages])
// Show globe toggle only when in a task that has a browser session (active or inactive)
const showBrowserDockToggle = useMemo(
() => Boolean(task && (browserSessionStartIndex !== -1 || isBrowserSessionActive)),
[task, browserSessionStartIndex, isBrowserSessionActive],
)
const isBrowserSessionMessage = useCallback((message: ClineMessage): boolean => {
// Only the launch ask should be hidden from chat (it's shown in the drawer header)
if (message.type === "ask" && message.ask === "browser_action_launch") {
return true
}
// browser_action_result messages are paired with browser_action and should not appear independently
if (message.type === "say" && message.say === "browser_action_result") {
return true
}
return false
}, [])
const groupedMessages = useMemo(() => {
// Only filter out the launch ask and result messages - browser actions appear in chat
const filtered: ClineMessage[] = visibleMessages.filter((msg) => !isBrowserSessionMessage(msg))
const filtered: ClineMessage[] = visibleMessages
// Helper to check if a message is a read_file ask that should be batched
const isReadFileAsk = (msg: ClineMessage): boolean => {
@ -1361,7 +1311,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
} as ClineMessage)
}
return result
}, [isCondensing, visibleMessages, isBrowserSessionMessage])
}, [isCondensing, visibleMessages])
// scrolling
@ -1496,34 +1446,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
(index: number, messageOrGroup: ClineMessage) => {
const hasCheckpoint = modifiedMessages.some((message) => message.say === "checkpoint_saved")
// Check if this is a browser action message
if (messageOrGroup.type === "say" && messageOrGroup.say === "browser_action") {
// Find the corresponding result message by looking for the next browser_action_result after this action's timestamp
const nextMessage = modifiedMessages.find(
(m) => m.ts > messageOrGroup.ts && m.say === "browser_action_result",
)
// Calculate action index and total count
const browserActions = modifiedMessages.filter((m) => m.say === "browser_action")
const actionIndex = browserActions.findIndex((m) => m.ts === messageOrGroup.ts) + 1
const totalActions = browserActions.length
return (
<BrowserActionRow
key={messageOrGroup.ts}
message={messageOrGroup}
nextMessage={nextMessage}
actionIndex={actionIndex}
totalActions={totalActions}
/>
)
}
// Check if this is a browser session status message
if (messageOrGroup.type === "say" && messageOrGroup.say === "browser_session_status") {
return <BrowserSessionStatusRow key={messageOrGroup.ts} message={messageOrGroup} />
}
// regular message
return (
<ChatRow
@ -1900,8 +1822,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
mode={mode}
setMode={setMode}
modeShortcutText={modeShortcutText}
isBrowserSessionActive={!!isBrowserSessionActive}
showBrowserDockToggle={showBrowserDockToggle}
isStreaming={isStreaming}
onStop={handleStopTask}
onEnqueueMessage={handleEnqueueCurrentMessage}

View file

@ -3,15 +3,7 @@ import { useTranslation } from "react-i18next"
import { useCloudUpsell } from "@src/hooks/useCloudUpsell"
import { CloudUpsellDialog } from "@src/components/cloud/CloudUpsellDialog"
import DismissibleUpsell from "@src/components/common/DismissibleUpsell"
import {
ChevronUp,
ChevronDown,
HardDriveDownload,
HardDriveUpload,
FoldVertical,
Globe,
ArrowLeft,
} from "lucide-react"
import { ChevronUp, ChevronDown, HardDriveDownload, HardDriveUpload, FoldVertical, ArrowLeft } from "lucide-react"
import prettyBytes from "pretty-bytes"
import type { ClineMessage } from "@roo-code/types"
@ -68,7 +60,7 @@ const TaskHeader = ({
todos,
}: TaskHeaderProps) => {
const { t } = useTranslation()
const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive } = useExtensionState()
const { apiConfiguration, currentTaskItem, clineMessages } = useExtensionState()
const { id: modelId, info: model } = useSelectedModel(apiConfiguration)
const [isTaskExpanded, setIsTaskExpanded] = useState(false)
const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false)
@ -118,18 +110,6 @@ const TaskHeader = ({
)
const reservedForOutput = maxTokens || 0
// Detect if this task had any browser session activity so we can show a grey globe when inactive
const browserSessionStartIndex = useMemo(() => {
const msgs = clineMessages || []
for (let i = 0; i < msgs.length; i++) {
const m = msgs[i] as any
if (m?.ask === "browser_action_launch") return i
}
return -1
}, [clineMessages])
const showBrowserGlobe = browserSessionStartIndex !== -1 || !!isBrowserSessionActive
const condenseButton = (
<LucideIconButton
title={t("chat:task.condenseContext")}
@ -336,39 +316,6 @@ const TaskHeader = ({
</>
)}
</div>
{showBrowserGlobe && (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<StandardTooltip content={t("chat:browser.session")}>
<Button
variant="ghost"
size="sm"
aria-label={t("chat:browser.session")}
onClick={() => vscode.postMessage({ type: "openBrowserSessionPanel" } as any)}
className={cn(
"relative h-5 w-5 p-0",
"text-vscode-foreground opacity-85",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
)}>
<Globe
className="w-4 h-4"
style={{
color: isBrowserSessionActive
? "#4ade80"
: "var(--vscode-descriptionForeground)",
}}
/>
</Button>
</StandardTooltip>
{isBrowserSessionActive && (
<span
className="text-sm font-medium"
style={{ color: "var(--vscode-testing-iconPassed)" }}>
{t("chat:browser.active")}
</span>
)}
</div>
)}
</div>
)}
{/* Expanded state: Show task text and images */}

View file

@ -1,55 +0,0 @@
import { render, screen, fireEvent } from "@testing-library/react"
import React from "react"
import BrowserSessionRow from "../BrowserSessionRow"
import { ExtensionStateContext } from "@src/context/ExtensionStateContext"
import { TooltipProvider } from "@src/components/ui/tooltip"
describe("BrowserSessionRow - screenshot area", () => {
const renderRow = (messages: any[]) => {
const mockExtState: any = {
// Ensure known viewport so expected aspect ratio is deterministic (600/900 = 66.67%)
browserViewportSize: "900x600",
isBrowserSessionActive: false,
}
return render(
<TooltipProvider>
<ExtensionStateContext.Provider value={mockExtState}>
<BrowserSessionRow
messages={messages as any}
isExpanded={() => true}
onToggleExpand={() => {}}
lastModifiedMessage={undefined as any}
isLast={true}
onHeightChange={() => {}}
isStreaming={false}
/>
</ExtensionStateContext.Provider>
</TooltipProvider>,
)
}
it("reserves height while screenshot is loading (no layout collapse)", () => {
// Only a launch action, no corresponding browser_action_result yet (no screenshot)
const messages = [
{
ts: 1,
say: "browser_action",
text: JSON.stringify({ action: "launch", url: "http://localhost:3000" }),
},
]
renderRow(messages)
// Open the browser session drawer
const globe = screen.getByLabelText("Browser interaction")
fireEvent.click(globe)
const container = screen.getByTestId("screenshot-container") as HTMLDivElement
// padding-bottom should reflect aspect ratio (600/900 * 100) even without an image
const pb = parseFloat(container.style.paddingBottom || "0")
expect(pb).toBeGreaterThan(0)
// Be tolerant of rounding
expect(Math.round(pb)).toBe(67)
})
})

View file

@ -1,42 +0,0 @@
import React from "react"
import { render, screen } from "@testing-library/react"
import BrowserSessionRow from "../BrowserSessionRow"
import { ExtensionStateContext } from "@src/context/ExtensionStateContext"
import { TooltipProvider } from "@radix-ui/react-tooltip"
describe("BrowserSessionRow - Disconnect session button", () => {
const renderRow = (isActive: boolean) => {
const mockExtState: any = {
browserViewportSize: "900x600",
isBrowserSessionActive: isActive,
}
return render(
<TooltipProvider>
<ExtensionStateContext.Provider value={mockExtState}>
<BrowserSessionRow
messages={[] as any}
isExpanded={() => false}
onToggleExpand={() => {}}
lastModifiedMessage={undefined as any}
isLast={true}
onHeightChange={() => {}}
isStreaming={false}
/>
</ExtensionStateContext.Provider>
</TooltipProvider>,
)
}
it("shows the Disconnect session button when a session is active", () => {
renderRow(true)
const btn = screen.getByLabelText("Disconnect session")
expect(btn).toBeInTheDocument()
})
it("does not render the button when no session is active", () => {
renderRow(false)
const btn = screen.queryByLabelText("Disconnect session")
expect(btn).toBeNull()
})
})

View file

@ -1,126 +0,0 @@
import React from "react"
import { describe, it, expect, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import BrowserSessionRow from "../BrowserSessionRow"
// Mock ExtensionStateContext so BrowserSessionRow falls back to props
vi.mock("@src/context/ExtensionStateContext", () => ({
useExtensionState: () => {
throw new Error("No ExtensionStateContext in test environment")
},
}))
// Simplify i18n usage and provide initReactI18next for i18n setup
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
initReactI18next: {
type: "3rdParty",
init: () => {},
},
}))
// Replace ProgressIndicator with a simple test marker
vi.mock("../ProgressIndicator", () => ({
ProgressIndicator: () => <div data-testid="browser-session-spinner" />,
}))
const baseProps = {
isExpanded: () => false,
onToggleExpand: () => {},
lastModifiedMessage: undefined,
isLast: true,
onHeightChange: () => {},
isStreaming: false,
}
describe("BrowserSessionRow - action spinner", () => {
it("does not show spinner when there are no browser actions", () => {
const messages = [
{
type: "say",
say: "task",
ts: 1,
text: "Task started",
} as any,
]
render(<BrowserSessionRow {...baseProps} messages={messages} />)
expect(screen.queryByTestId("browser-session-spinner")).toBeNull()
})
it("shows spinner while the latest browser action is still running", () => {
const messages = [
{
type: "say",
say: "task",
ts: 1,
text: "Task started",
} as any,
{
type: "say",
say: "browser_action",
ts: 2,
text: JSON.stringify({ action: "click" }),
} as any,
{
type: "say",
say: "browser_action_result",
ts: 3,
text: JSON.stringify({ currentUrl: "https://example.com" }),
} as any,
{
type: "say",
say: "browser_action",
ts: 4,
text: JSON.stringify({ action: "scroll_down" }),
} as any,
]
render(<BrowserSessionRow {...baseProps} messages={messages} />)
expect(screen.getByTestId("browser-session-spinner")).toBeInTheDocument()
})
it("hides spinner once the latest browser action has a result", () => {
const messages = [
{
type: "say",
say: "task",
ts: 1,
text: "Task started",
} as any,
{
type: "say",
say: "browser_action",
ts: 2,
text: JSON.stringify({ action: "click" }),
} as any,
{
type: "say",
say: "browser_action_result",
ts: 3,
text: JSON.stringify({ currentUrl: "https://example.com" }),
} as any,
{
type: "say",
say: "browser_action",
ts: 4,
text: JSON.stringify({ action: "scroll_down" }),
} as any,
{
type: "say",
say: "browser_action_result",
ts: 5,
text: JSON.stringify({ currentUrl: "https://example.com/page2" }),
} as any,
]
render(<BrowserSessionRow {...baseProps} messages={messages} />)
expect(screen.queryByTestId("browser-session-spinner")).toBeNull()
})
})

View file

@ -24,10 +24,6 @@ vi.mock("use-sound", () => ({
}))
// Mock components
vi.mock("../BrowserSessionRow", () => ({
default: () => null,
}))
vi.mock("../ChatRow", () => ({
default: () => null,
}))

View file

@ -49,12 +49,6 @@ vi.mock("use-sound", () => ({
}))
// Mock components that use ESM dependencies
vi.mock("../BrowserSessionRow", () => ({
default: function MockBrowserSessionRow({ messages }: { messages: ClineMessage[] }) {
return <div data-testid="browser-session">{JSON.stringify(messages)}</div>
},
}))
vi.mock("../ChatRow", () => ({
default: function MockChatRow({ message }: { message: ClineMessage }) {
return <div data-testid="chat-row">{JSON.stringify(message)}</div>

View file

@ -44,12 +44,6 @@ vi.mock("use-sound", () => ({
}))
// Mock components that use ESM dependencies
vi.mock("../BrowserSessionRow", () => ({
default: function MockBrowserSessionRow({ messages }: { messages: ClineMessage[] }) {
return <div data-testid="browser-session">{JSON.stringify(messages)}</div>
},
}))
vi.mock("../ChatRow", () => ({
default: function MockChatRow({ message }: { message: ClineMessage }) {
return <div data-testid="chat-row">{JSON.stringify(message)}</div>

View file

@ -45,12 +45,6 @@ vi.mock("use-sound", () => ({
}))
// Mock components that use ESM dependencies
vi.mock("../BrowserSessionRow", () => ({
default: function MockBrowserSessionRow({ messages }: { messages: ClineMessage[] }) {
return <div data-testid="browser-session">{JSON.stringify(messages)}</div>
},
}))
vi.mock("../ChatRow", () => ({
default: function MockChatRow({ message }: { message: ClineMessage }) {
return <div data-testid="chat-row">{JSON.stringify(message)}</div>

View file

@ -24,7 +24,6 @@ type AutoApproveSettingsProps = HTMLAttributes<HTMLDivElement> & {
alwaysAllowWrite?: boolean
alwaysAllowWriteOutsideWorkspace?: boolean
alwaysAllowWriteProtected?: boolean
alwaysAllowBrowser?: boolean
alwaysAllowMcp?: boolean
alwaysAllowModeSwitch?: boolean
alwaysAllowSubtasks?: boolean
@ -41,7 +40,6 @@ type AutoApproveSettingsProps = HTMLAttributes<HTMLDivElement> & {
| "alwaysAllowWrite"
| "alwaysAllowWriteOutsideWorkspace"
| "alwaysAllowWriteProtected"
| "alwaysAllowBrowser"
| "alwaysAllowMcp"
| "alwaysAllowModeSwitch"
| "alwaysAllowSubtasks"
@ -61,7 +59,6 @@ export const AutoApproveSettings = ({
alwaysAllowWrite,
alwaysAllowWriteOutsideWorkspace,
alwaysAllowWriteProtected,
alwaysAllowBrowser,
alwaysAllowMcp,
alwaysAllowModeSwitch,
alwaysAllowSubtasks,
@ -155,7 +152,6 @@ export const AutoApproveSettings = ({
<AutoApproveToggle
alwaysAllowReadOnly={alwaysAllowReadOnly}
alwaysAllowWrite={alwaysAllowWrite}
alwaysAllowBrowser={alwaysAllowBrowser}
alwaysAllowMcp={alwaysAllowMcp}
alwaysAllowModeSwitch={alwaysAllowModeSwitch}
alwaysAllowSubtasks={alwaysAllowSubtasks}

View file

@ -8,7 +8,6 @@ type AutoApproveToggles = Pick<
GlobalSettings,
| "alwaysAllowReadOnly"
| "alwaysAllowWrite"
| "alwaysAllowBrowser"
| "alwaysAllowMcp"
| "alwaysAllowModeSwitch"
| "alwaysAllowSubtasks"
@ -41,13 +40,6 @@ export const autoApproveSettingsConfig: Record<AutoApproveSetting, AutoApproveCo
icon: "edit",
testId: "always-allow-write-toggle",
},
alwaysAllowBrowser: {
key: "alwaysAllowBrowser",
labelKey: "settings:autoApprove.browser.label",
descriptionKey: "settings:autoApprove.browser.description",
icon: "globe",
testId: "always-allow-browser-toggle",
},
alwaysAllowMcp: {
key: "alwaysAllowMcp",
labelKey: "settings:autoApprove.mcp.label",

View file

@ -1,243 +0,0 @@
import { VSCodeCheckbox, VSCodeTextField, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { HTMLAttributes, useEffect, useMemo, useState } from "react"
import { Trans } from "react-i18next"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
Slider,
Button,
} from "@/components/ui"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { vscode } from "@/utils/vscode"
import { buildDocLink } from "@src/utils/docLinks"
import { SearchableSetting } from "./SearchableSetting"
import { Section } from "./Section"
import { SectionHeader } from "./SectionHeader"
import { SetCachedStateField } from "./types"
type BrowserSettingsProps = HTMLAttributes<HTMLDivElement> & {
browserToolEnabled?: boolean
browserViewportSize?: string
screenshotQuality?: number
remoteBrowserHost?: string
remoteBrowserEnabled?: boolean
setCachedStateField: SetCachedStateField<
| "browserToolEnabled"
| "browserViewportSize"
| "screenshotQuality"
| "remoteBrowserHost"
| "remoteBrowserEnabled"
>
}
export const BrowserSettings = ({
browserToolEnabled,
browserViewportSize,
screenshotQuality,
remoteBrowserHost,
remoteBrowserEnabled,
setCachedStateField,
...props
}: BrowserSettingsProps) => {
const { t } = useAppTranslation()
const [testingConnection, setTestingConnection] = useState(false)
const [testResult, setTestResult] = useState<{ success: boolean; text: string } | null>(null)
const [discovering, setDiscovering] = useState(false)
// We don't need a local state for useRemoteBrowser since we're using the
// `enableRemoteBrowser` prop directly. This ensures the checkbox always
// reflects the current global state.
// Set up message listener for browser connection results.
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message = event.data
if (message.type === "browserConnectionResult") {
setTestResult({ success: message.success, text: message.text })
setTestingConnection(false)
setDiscovering(false)
}
}
window.addEventListener("message", handleMessage)
return () => {
window.removeEventListener("message", handleMessage)
}
}, [])
const testConnection = async () => {
setTestingConnection(true)
setTestResult(null)
try {
// Send a message to the extension to test the connection.
vscode.postMessage({ type: "testBrowserConnection", text: remoteBrowserHost })
} catch (error) {
setTestResult({
success: false,
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
})
setTestingConnection(false)
}
}
const options = useMemo(
() => [
{
value: "1280x800",
label: t("settings:browser.viewport.options.largeDesktop"),
},
{
value: "900x600",
label: t("settings:browser.viewport.options.smallDesktop"),
},
{ value: "768x1024", label: t("settings:browser.viewport.options.tablet") },
{ value: "360x640", label: t("settings:browser.viewport.options.mobile") },
],
[t],
)
return (
<div {...props}>
<SectionHeader>{t("settings:sections.browser")}</SectionHeader>
<Section>
<SearchableSetting
settingId="browser-enable"
section="browser"
label={t("settings:browser.enable.label")}>
<VSCodeCheckbox
checked={browserToolEnabled}
onChange={(e: any) => setCachedStateField("browserToolEnabled", e.target.checked)}>
<span className="font-medium">{t("settings:browser.enable.label")}</span>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm mt-1">
<Trans i18nKey="settings:browser.enable.description">
<VSCodeLink
href={buildDocLink("features/browser-use", "settings_browser_tool")}
style={{ display: "inline" }}>
{" "}
</VSCodeLink>
</Trans>
</div>
</SearchableSetting>
{browserToolEnabled && (
<div className="flex flex-col gap-3 pl-3 border-l-2 border-vscode-button-background">
<SearchableSetting
settingId="browser-viewport"
section="browser"
label={t("settings:browser.viewport.label")}>
<label className="block font-medium mb-1">{t("settings:browser.viewport.label")}</label>
<Select
value={browserViewportSize}
onValueChange={(value) => setCachedStateField("browserViewportSize", value)}>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("settings:common.select")} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{options.map(({ value, label }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:browser.viewport.description")}
</div>
</SearchableSetting>
<SearchableSetting
settingId="browser-screenshot-quality"
section="browser"
label={t("settings:browser.screenshotQuality.label")}>
<label className="block font-medium mb-1">
{t("settings:browser.screenshotQuality.label")}
</label>
<div className="flex items-center gap-2">
<Slider
min={1}
max={100}
step={1}
value={[screenshotQuality ?? 75]}
onValueChange={([value]) => setCachedStateField("screenshotQuality", value)}
/>
<span className="w-10">{screenshotQuality ?? 75}%</span>
</div>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:browser.screenshotQuality.description")}
</div>
</SearchableSetting>
<SearchableSetting
settingId="browser-remote"
section="browser"
label={t("settings:browser.remote.label")}>
<VSCodeCheckbox
checked={remoteBrowserEnabled}
onChange={(e: any) => {
// Update the global state - remoteBrowserEnabled now means "enable remote browser connection".
setCachedStateField("remoteBrowserEnabled", e.target.checked)
if (!e.target.checked) {
// If disabling remote browser, clear the custom URL.
setCachedStateField("remoteBrowserHost", undefined)
}
}}>
<label className="block font-medium mb-1">{t("settings:browser.remote.label")}</label>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:browser.remote.description")}
</div>
</SearchableSetting>
{remoteBrowserEnabled && (
<>
<div className="flex items-center gap-2">
<VSCodeTextField
value={remoteBrowserHost ?? ""}
onChange={(e: any) =>
setCachedStateField("remoteBrowserHost", e.target.value || undefined)
}
placeholder={t("settings:browser.remote.urlPlaceholder")}
style={{ flexGrow: 1 }}
/>
<Button disabled={testingConnection} onClick={testConnection}>
{testingConnection || discovering
? t("settings:browser.remote.testingButton")
: t("settings:browser.remote.testButton")}
</Button>
</div>
{testResult && (
<div
className={`p-2 rounded-xs text-sm ${
testResult.success
? "bg-green-800/20 text-green-400"
: "bg-red-800/20 text-red-400"
}`}>
{testResult.text}
</div>
)}
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:browser.remote.instructions")}
</div>
</>
)}
</div>
)}
</Section>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show more