diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 98231e8480..ae12d4591b 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to the `@roo-code/cli` package will be documented in this fi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.53] - 2026-02-12 + +### Changed + +- **Auto-Approve by Default**: The CLI now auto-approves all actions (tools, commands, browser, MCP) by default. Followup questions auto-select the first suggestion after a 60-second timeout. +- **New `--require-approval` Flag**: Replaced `-y`/`--yes`/`--dangerously-skip-permissions` flags with a new `-a, --require-approval` flag for users who want manual approval prompts before actions execute. + +### Fixed + +- Spamming the escape key to cancel a running task no longer crashes the cli. + ## [0.0.52] - 2026-02-09 ### Added @@ -67,7 +78,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Skip onboarding flow when a provider is explicitly specified via `--provider` flag or saved in settings -- Unified permission flags: Combined `-y`, `--yes`, and `--dangerously-skip-permissions` into a single option for Claude Code-like CLI compatibility +- Unified permission flags: Combined approval-skipping flags into a single option for Claude Code-like CLI compatibility - Improved Roo Code Router authentication flow and error messaging ### Fixed diff --git a/apps/cli/README.md b/apps/cli/README.md index 7060be6e8f..0e49140b91 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -66,7 +66,7 @@ pnpm --filter @roo-code/cli build ### Interactive Mode (Default) -By default, the CLI prompts for approval before executing actions: +By default, the CLI auto-approves actions and runs in interactive TUI mode: ```bash export OPENROUTER_API_KEY=sk-or-v1-... @@ -82,24 +82,23 @@ roo -w ~/Documents/my-project In interactive mode: -- Tool executions prompt for yes/no approval -- Commands prompt for yes/no approval -- Followup questions show suggestions and wait for user input -- Browser and MCP actions prompt for approval +- Tool executions are auto-approved +- Commands are auto-approved +- Followup questions show suggestions with a 60-second timeout, then auto-select the first suggestion +- Browser and MCP actions are auto-approved -### Non-Interactive Mode (`-y`) +### Approval-Required Mode (`--require-approval`) -For automation and scripts, use `-y` to auto-approve all actions: +If you want manual approval prompts, enable approval-required mode: ```bash -roo "Refactor the utils.ts file" -y -w ~/Documents/my-project +roo "Refactor the utils.ts file" --require-approval -w ~/Documents/my-project ``` -In non-interactive mode: +In approval-required mode: -- Tool, command, browser, and MCP actions are auto-approved -- Followup questions show a 60-second timeout, then auto-select the first suggestion -- Typing any key cancels the timeout and allows manual input +- Tool, command, browser, and MCP actions prompt for yes/no approval +- Followup questions wait for manual input (no auto-timeout) ### Roo Code Cloud Authentication @@ -147,23 +146,23 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo ## Options -| Option | Description | Default | -| ------------------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------- | -| `[prompt]` | Your prompt (positional argument, optional) | None | -| `--prompt-file ` | Read prompt from a file instead of command line argument | None | -| `-w, --workspace ` | Workspace path to operate in | Current directory | -| `-p, --print` | Print response and exit (non-interactive mode) | `false` | -| `-e, --extension ` | Path to the extension bundle directory | Auto-detected | -| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` | -| `-y, --yes, --dangerously-skip-permissions` | Auto-approve all actions (use with caution) | `false` | -| `-k, --api-key ` | API key for the LLM provider | From env var | -| `--provider ` | API provider (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) | -| `-m, --model ` | Model to use | `anthropic/claude-opus-4.6` | -| `--mode ` | Mode to start in (code, architect, ask, debug, etc.) | `code` | -| `-r, --reasoning-effort ` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` | -| `--ephemeral` | Run without persisting state (uses temporary storage) | `false` | -| `--oneshot` | Exit upon task completion | `false` | -| `--output-format ` | Output format with `--print`: `text`, `json`, or `stream-json` | `text` | +| Option | Description | Default | +| --------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------- | +| `[prompt]` | Your prompt (positional argument, optional) | None | +| `--prompt-file ` | Read prompt from a file instead of command line argument | None | +| `-w, --workspace ` | Workspace path to operate in | Current directory | +| `-p, --print` | Print response and exit (non-interactive mode) | `false` | +| `-e, --extension ` | Path to the extension bundle directory | Auto-detected | +| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` | +| `-a, --require-approval` | Require manual approval before actions execute | `false` | +| `-k, --api-key ` | API key for the LLM provider | From env var | +| `--provider ` | API provider (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) | +| `-m, --model ` | Model to use | `anthropic/claude-opus-4.6` | +| `--mode ` | Mode to start in (code, architect, ask, debug, etc.) | `code` | +| `-r, --reasoning-effort ` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` | +| `--ephemeral` | Run without persisting state (uses temporary storage) | `false` | +| `--oneshot` | Exit upon task completion | `false` | +| `--output-format ` | Output format with `--print`: `text`, `json`, or `stream-json` | `text` | ## Auth Commands diff --git a/apps/cli/docs/AGENT_LOOP.md b/apps/cli/docs/AGENT_LOOP.md index a7b1d9eed4..a512d47a50 100644 --- a/apps/cli/docs/AGENT_LOOP.md +++ b/apps/cli/docs/AGENT_LOOP.md @@ -242,7 +242,8 @@ Routes asks to appropriate handlers: - Uses type guards: `isIdleAsk()`, `isInteractiveAsk()`, etc. - Coordinates between `OutputManager` and `PromptManager` -- In non-interactive mode (`-y` flag), auto-approves everything +- By default, the CLI auto-approves tool/command/browser/MCP actions +- In `--require-approval` mode, those actions prompt for manual approval ### OutputManager @@ -320,7 +321,7 @@ if (isInteractiveAsk(ask)) { Enable with `-d` flag. Logs go to `~/.roo/cli-debug.log`: ```bash -roo -d -y -P "Build something" --no-tui +roo -d -P "Build something" --no-tui ``` View logs: diff --git a/apps/cli/package.json b/apps/cli/package.json index 029e677201..028d024e81 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@roo-code/cli", - "version": "0.0.52", + "version": "0.0.53", "description": "Roo Code CLI - Run the Roo Code agent from the command line", "private": true, "type": "module", @@ -15,7 +15,7 @@ "test": "vitest run", "build": "tsup", "build:extension": "pnpm --filter roo-cline bundle", - "dev": "ROO_AUTH_BASE_URL=https://app.roocode.com ROO_SDK_BASE_URL=https://cloud-api.roocode.com ROO_CODE_PROVIDER_URL=https://api.roocode.com/proxy tsx src/index.ts -y", + "dev": "ROO_AUTH_BASE_URL=https://app.roocode.com ROO_SDK_BASE_URL=https://cloud-api.roocode.com ROO_CODE_PROVIDER_URL=https://api.roocode.com/proxy tsx src/index.ts", "dev:local": "ROO_AUTH_BASE_URL=http://localhost:3000 ROO_SDK_BASE_URL=http://localhost:3001 ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy tsx src/index.ts", "clean": "rimraf dist .turbo" }, diff --git a/apps/cli/src/agent/__tests__/extension-client.test.ts b/apps/cli/src/agent/__tests__/extension-client.test.ts index 3d87a30200..7a63fe0174 100644 --- a/apps/cli/src/agent/__tests__/extension-client.test.ts +++ b/apps/cli/src/agent/__tests__/extension-client.test.ts @@ -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) }) diff --git a/apps/cli/src/agent/agent-state.ts b/apps/cli/src/agent/agent-state.ts index ca4a099cca..d1451d62fd 100644 --- a/apps/cli/src/agent/agent-state.ts +++ b/apps/cli/src/agent/agent-state.ts @@ -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: diff --git a/apps/cli/src/agent/ask-dispatcher.ts b/apps/cli/src/agent/ask-dispatcher.ts index fe8c557d8d..44e861ae9b 100644 --- a/apps/cli/src/agent/ask-dispatcher.ts +++ b/apps/cli/src/agent/ask-dispatcher.ts @@ -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 { @@ -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 { - 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. */ diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index 42edff1214..4a0e941b4b 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -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, diff --git a/apps/cli/src/agent/json-event-emitter.ts b/apps/cli/src/agent/json-event-emitter.ts index a1a404e555..578c52d2b8 100644 --- a/apps/cli/src/agent/json-event-emitter.ts +++ b/apps/cli/src/agent/json-event-emitter.ts @@ -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", diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index 881107fb82..1ce2f4a1f1 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -65,8 +65,10 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption flagOptions.reasoningEffort || settings.reasoningEffort || DEFAULT_FLAGS.reasoningEffort const effectiveProvider = flagOptions.provider ?? settings.provider ?? (rooToken ? "roo" : "openrouter") const effectiveWorkspacePath = flagOptions.workspace ? path.resolve(flagOptions.workspace) : process.cwd() - const effectiveDangerouslySkipPermissions = - flagOptions.yes || flagOptions.dangerouslySkipPermissions || settings.dangerouslySkipPermissions || false + const legacyRequireApprovalFromSettings = + settings.requireApproval ?? + (settings.dangerouslySkipPermissions === undefined ? undefined : !settings.dangerouslySkipPermissions) + const effectiveRequireApproval = flagOptions.requireApproval || legacyRequireApprovalFromSettings || false const effectiveExitOnComplete = flagOptions.print || flagOptions.oneshot || settings.oneshot || false const extensionHostOptions: ExtensionHostOptions = { @@ -77,7 +79,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption model: effectiveModel, workspacePath: effectiveWorkspacePath, extensionPath: path.resolve(flagOptions.extension || getDefaultExtensionPath(__dirname)), - nonInteractive: effectiveDangerouslySkipPermissions, + nonInteractive: !effectiveRequireApproval, exitOnError: flagOptions.exitOnError, ephemeral: flagOptions.ephemeral, debug: flagOptions.debug, diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 51218aa860..a1fd1be89e 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -18,8 +18,7 @@ program .option("-p, --print", "Print response and exit (non-interactive mode)", false) .option("-e, --extension ", "Path to the extension bundle directory") .option("-d, --debug", "Enable debug output (includes detailed debug information)", false) - .option("-y, --yes", "Auto-approve all prompts (use with caution)", false) - .option("--dangerously-skip-permissions", "Alias for --yes", false) + .option("-a, --require-approval", "Require manual approval for actions", false) .option("-k, --api-key ", "API key for the LLM provider") .option("--provider ", "API provider (roo, anthropic, openai, openrouter, etc.)") .option("-m, --model ", "Model to use", DEFAULT_FLAGS.model) diff --git a/apps/cli/src/lib/storage/__tests__/settings.test.ts b/apps/cli/src/lib/storage/__tests__/settings.test.ts index d5d520a597..30f1dbe8ec 100644 --- a/apps/cli/src/lib/storage/__tests__/settings.test.ts +++ b/apps/cli/src/lib/storage/__tests__/settings.test.ts @@ -179,20 +179,20 @@ describe("Settings Storage", () => { expect(loaded.reasoningEffort).toBe("low") }) - it("should support dangerouslySkipPermissions setting", async () => { - await saveSettings({ dangerouslySkipPermissions: true }) + it("should support requireApproval setting", async () => { + await saveSettings({ requireApproval: true }) const loaded = await loadSettings() - expect(loaded.dangerouslySkipPermissions).toBe(true) + expect(loaded.requireApproval).toBe(true) }) - it("should support all settings together including dangerouslySkipPermissions", async () => { + it("should support all settings together including requireApproval", async () => { const allSettings = { mode: "architect", provider: "anthropic" as const, model: "claude-sonnet-4-20250514", reasoningEffort: "high" as const, - dangerouslySkipPermissions: true, + requireApproval: true, } await saveSettings(allSettings) @@ -202,7 +202,7 @@ describe("Settings Storage", () => { expect(loaded.provider).toBe("anthropic") expect(loaded.model).toBe("claude-sonnet-4-20250514") expect(loaded.reasoningEffort).toBe("high") - expect(loaded.dangerouslySkipPermissions).toBe(true) + expect(loaded.requireApproval).toBe(true) }) it("should support oneshot setting", async () => { @@ -218,7 +218,7 @@ describe("Settings Storage", () => { provider: "anthropic" as const, model: "claude-sonnet-4-20250514", reasoningEffort: "high" as const, - dangerouslySkipPermissions: true, + requireApproval: true, oneshot: true, } @@ -229,8 +229,15 @@ describe("Settings Storage", () => { expect(loaded.provider).toBe("anthropic") expect(loaded.model).toBe("claude-sonnet-4-20250514") expect(loaded.reasoningEffort).toBe("high") - expect(loaded.dangerouslySkipPermissions).toBe(true) + expect(loaded.requireApproval).toBe(true) expect(loaded.oneshot).toBe(true) }) + + it("should still load legacy dangerouslySkipPermissions setting", async () => { + await saveSettings({ dangerouslySkipPermissions: true }) + const loaded = await loadSettings() + + expect(loaded.dangerouslySkipPermissions).toBe(true) + }) }) }) diff --git a/apps/cli/src/types/types.ts b/apps/cli/src/types/types.ts index 827afad513..162f7bac7b 100644 --- a/apps/cli/src/types/types.ts +++ b/apps/cli/src/types/types.ts @@ -24,8 +24,7 @@ export type FlagOptions = { print: boolean extension?: string debug: boolean - yes: boolean - dangerouslySkipPermissions: boolean + requireApproval: boolean exitOnError: boolean apiKey?: string provider?: SupportedProvider @@ -58,7 +57,9 @@ export interface CliSettings { model?: string /** Default reasoning effort level */ reasoningEffort?: ReasoningEffortFlagOptions - /** Auto-approve all prompts (use with caution) */ + /** Require manual approval for tools/commands/browser/MCP actions */ + requireApproval?: boolean + /** @deprecated Legacy inverse setting kept for backward compatibility */ dangerouslySkipPermissions?: boolean /** Exit upon task completion */ oneshot?: boolean diff --git a/apps/cli/src/ui/components/ChatHistoryItem.tsx b/apps/cli/src/ui/components/ChatHistoryItem.tsx index c51b0faddb..e5bbc79366 100644 --- a/apps/cli/src/ui/components/ChatHistoryItem.tsx +++ b/apps/cli/src/ui/components/ChatHistoryItem.tsx @@ -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 = { directory: theme.toolHeader, search: theme.warningColor, command: theme.successColor, - browser: theme.focusColor, mode: theme.userHeader, completion: theme.successColor, other: theme.toolHeader, diff --git a/apps/cli/src/ui/components/tools/BrowserTool.tsx b/apps/cli/src/ui/components/tools/BrowserTool.tsx deleted file mode 100644 index 5e6d51857a..0000000000 --- a/apps/cli/src/ui/components/tools/BrowserTool.tsx +++ /dev/null @@ -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 = { - 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 ( - - {/* Header */} - - - - {" "} - {displayName} - - {action && ( - - {" "} - → {actionLabel} - - )} - - - {/* Action details */} - - {/* URL for launch action */} - {url && ( - - url: - - {url} - - - )} - - {/* Coordinates for click/hover actions */} - {coordinate && ( - - at: - {coordinate} - - )} - - {/* Text content for type action */} - {content && action === "type" && ( - - text: - "{content}" - - )} - - {/* Key for press action */} - {content && action === "press" && ( - - key: - {content} - - )} - - - ) -} diff --git a/apps/cli/src/ui/components/tools/index.ts b/apps/cli/src/ui/components/tools/index.ts index c628432002..e5f5527c2f 100644 --- a/apps/cli/src/ui/components/tools/index.ts +++ b/apps/cli/src/ui/components/tools/index.ts @@ -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> = { "file-write": FileWriteTool, search: SearchTool, command: CommandTool, - browser: BrowserTool, mode: ModeTool, completion: CompletionTool, other: GenericTool, diff --git a/apps/cli/src/ui/components/tools/types.ts b/apps/cli/src/ui/components/tools/types.ts index a16fbd60ea..29c8444af1 100644 --- a/apps/cli/src/ui/components/tools/types.ts +++ b/apps/cli/src/ui/components/tools/types.ts @@ -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" diff --git a/apps/cli/src/ui/components/tools/utils.ts b/apps/cli/src/ui/components/tools/utils.ts index 31acf2cccb..484125dbb2 100644 --- a/apps/cli/src/ui/components/tools/utils.ts +++ b/apps/cli/src/ui/components/tools/utils.ts @@ -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", diff --git a/apps/cli/src/ui/types.ts b/apps/cli/src/ui/types.ts index c2187fb2b6..3c45377c67 100644 --- a/apps/cli/src/ui/types.ts +++ b/apps/cli/src/ui/types.ts @@ -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<{ diff --git a/apps/cli/src/ui/utils/tools.ts b/apps/cli/src/ui/utils/tools.ts index be3ff9484d..b79a506571 100644 --- a/apps/cli/src/ui/utils/tools.ts +++ b/apps/cli/src/ui/utils/tools.ts @@ -57,17 +57,6 @@ export function extractToolData(toolInfo: Record): 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>).map((f) => ({ @@ -165,12 +154,6 @@ export function formatToolOutput(toolInfo: Record): 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 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") diff --git a/packages/evals/src/cli/runTaskInCli.ts b/packages/evals/src/cli/runTaskInCli.ts index 03b3ad4f70..ea22202305 100644 --- a/packages/evals/src/cli/runTaskInCli.ts +++ b/packages/evals/src/cli/runTaskInCli.ts @@ -43,7 +43,6 @@ export const runTaskWithCli = async ({ run, task, publish, logger, jobToken }: R promptSourcePath, "--workspace", workspacePath, - "--yes", "--reasoning-effort", "disabled", "--oneshot", diff --git a/packages/evals/src/db/queries/__tests__/copyRun.spec.ts b/packages/evals/src/db/queries/__tests__/copyRun.spec.ts index 1537ac1ddb..606a3d0281 100644 --- a/packages/evals/src/db/queries/__tests__/copyRun.spec.ts +++ b/packages/evals/src/db/queries/__tests__/copyRun.spec.ts @@ -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)) diff --git a/packages/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index 50a8c6512f..aad9fab7b1 100644 --- a/packages/types/npm/package.metadata.json +++ b/packages/types/npm/package.metadata.json @@ -1,6 +1,6 @@ { "name": "@roo-code/types", - "version": "1.110.0", + "version": "1.111.0", "description": "TypeScript type definitions for Roo Code.", "publishConfig": { "access": "public", diff --git a/packages/types/src/__tests__/cloud.test.ts b/packages/types/src/__tests__/cloud.test.ts index be8d631ce0..4e9e792a29 100644 --- a/packages/types/src/__tests__/cloud.test.ts +++ b/packages/types/src/__tests__/cloud.test.ts @@ -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", () => { diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index ddb6220609..ff58ba8d30 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -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, diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index a725cb094d..e518972a1c 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -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", diff --git a/packages/types/src/mode.ts b/packages/types/src/mode.ts index c02c47c134..f981ba7bf9 100644 --- a/packages/types/src/mode.ts +++ b/packages/types/src/mode.ts @@ -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 * 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` 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 + 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.", }, diff --git a/packages/types/src/providers/zai.ts b/packages/types/src/providers/zai.ts index 41a6a808ca..69f90f232a 100644 --- a/packages/types/src/providers/zai.ts +++ b/packages/types/src/providers/zai.ts @@ -120,6 +120,21 @@ export const internationalZAiModels = { description: "GLM-4.7 is Zhipu's latest model with built-in thinking capabilities enabled by default. It provides enhanced reasoning for complex tasks while maintaining fast response times.", }, + "glm-5": { + maxTokens: 16_384, + contextWindow: 202_752, + supportsImages: false, + supportsPromptCache: true, + supportsReasoningEffort: ["disable", "medium"], + reasoningEffort: "medium", + preserveReasoning: true, + inputPrice: 0.6, + outputPrice: 2.2, + cacheWritesPrice: 0, + cacheReadsPrice: 0.11, + description: + "GLM-5 is Zhipu's next-generation model with a 202k context window and built-in thinking capabilities. It delivers state-of-the-art reasoning, coding, and agentic performance.", + }, "glm-4.7-flash": { maxTokens: 16_384, contextWindow: 200_000, @@ -281,6 +296,21 @@ export const mainlandZAiModels = { description: "GLM-4.7 is Zhipu's latest model with built-in thinking capabilities enabled by default. It provides enhanced reasoning for complex tasks while maintaining fast response times.", }, + "glm-5": { + maxTokens: 16_384, + contextWindow: 202_752, + supportsImages: false, + supportsPromptCache: true, + supportsReasoningEffort: ["disable", "medium"], + reasoningEffort: "medium", + preserveReasoning: true, + inputPrice: 0.29, + outputPrice: 1.14, + cacheWritesPrice: 0, + cacheReadsPrice: 0.057, + description: + "GLM-5 is Zhipu's next-generation model with a 202k context window and built-in thinking capabilities. It delivers state-of-the-art reasoning, coding, and agentic performance.", + }, "glm-4.7-flash": { maxTokens: 16_384, contextWindow: 204_800, diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index 55b442cca0..f45ab98d8a 100644 --- a/packages/types/src/task.ts +++ b/packages/types/src/task.ts @@ -93,8 +93,6 @@ export interface CreateTaskOptions { consecutiveMistakeLimit?: number experiments?: Record initialTodos?: TodoItem[] - /** Initial status for the task's history item (e.g., "active" for child tasks) */ - initialStatus?: "active" | "delegated" | "completed" /** Whether to start the task loop immediately (default: true). * When false, the caller must invoke `task.start()` manually. */ startTask?: boolean diff --git a/packages/types/src/tool-params.ts b/packages/types/src/tool-params.ts index 75be318d8c..8c3c4d8d8a 100644 --- a/packages/types/src/tool-params.ts +++ b/packages/types/src/tool-params.ts @@ -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 diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index a8ea826d11..4f90b63e9f 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -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 /** @@ -27,7 +34,6 @@ export const toolNames = [ "apply_patch", "search_files", "list_files", - "browser_action", "use_mcp_tool", "access_mcp_resource", "ask_followup_question", diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index a727a25103..a72f087d24 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78c1b16177..0e7846fa2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/__tests__/command-mentions.spec.ts b/src/__tests__/command-mentions.spec.ts index 7b69d245d8..c421a047a1 100644 --- a/src/__tests__/command-mentions.spec.ts +++ b/src/__tests__/command-mentions.spec.ts @@ -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 diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index fae79bc291..287b926a04 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -38,6 +38,10 @@ vi.mock("../core/task-persistence", async (importOriginal) => { saveTaskMessages: vi.fn().mockResolvedValue(undefined), } }) +vi.mock("../core/task-persistence/delegationMeta", () => ({ + readDelegationMeta: vi.fn().mockResolvedValue(null), + saveDelegationMeta: vi.fn().mockResolvedValue(undefined), +})) import { ClineProvider } from "../core/webview/ClineProvider" import { readTaskMessages } from "../core/task-persistence/taskMessages" @@ -149,6 +153,7 @@ describe("History resume delegation - parent metadata transitions", () => { overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), }), updateTaskHistory: vi.fn().mockResolvedValue([]), + log: vi.fn(), } as unknown as ClineProvider // Start with existing messages in history @@ -232,6 +237,7 @@ describe("History resume delegation - parent metadata transitions", () => { overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), }), updateTaskHistory: vi.fn().mockResolvedValue([]), + log: vi.fn(), } as unknown as ClineProvider // Include an assistant message with new_task tool_use to exercise the tool_result path @@ -320,6 +326,7 @@ describe("History resume delegation - parent metadata transitions", () => { overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), }), updateTaskHistory: vi.fn().mockResolvedValue([]), + log: vi.fn(), } as unknown as ClineProvider // No assistant tool_use in history @@ -555,6 +562,7 @@ describe("History resume delegation - parent metadata transitions", () => { overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), }), updateTaskHistory: vi.fn().mockResolvedValue([]), + log: vi.fn(), } as unknown as ClineProvider vi.mocked(readTaskMessages).mockResolvedValue([]) @@ -754,6 +762,7 @@ describe("History resume delegation - parent metadata transitions", () => { overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), }), updateTaskHistory: vi.fn().mockResolvedValue([]), + log: vi.fn(), } as unknown as ClineProvider // Mock read failures or empty returns diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index 4b04fb5bbb..4f624fed5a 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -2,6 +2,12 @@ import { describe, it, expect, vi } from "vitest" import { RooCodeEventName } from "@roo-code/types" + +vi.mock("../core/task-persistence/delegationMeta", () => ({ + readDelegationMeta: vi.fn().mockResolvedValue(null), + saveDelegationMeta: vi.fn().mockResolvedValue(undefined), +})) + import { ClineProvider } from "../core/webview/ClineProvider" describe("ClineProvider.delegateParentAndOpenChild()", () => { @@ -9,7 +15,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { const providerEmit = vi.fn() const parentTask = { taskId: "parent-1", emit: vi.fn() } as any - const childStart = vi.fn() + const childStart = vi.fn().mockResolvedValue(undefined) const updateTaskHistory = vi.fn() const removeClineFromStack = vi.fn().mockResolvedValue(undefined) const createTask = vi.fn().mockResolvedValue({ taskId: "child-1", start: childStart }) @@ -48,6 +54,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { updateTaskHistory, handleModeSwitch, log: vi.fn(), + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, } as unknown as ClineProvider const params = { @@ -63,18 +70,26 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { // Invariant: parent closed before child creation expect(removeClineFromStack).toHaveBeenCalledTimes(1) - // Child task is created with startTask: false and initialStatus: "active" + // Child task is created with startTask: false expect(createTask).toHaveBeenCalledWith("Do something", undefined, parentTask, { initialTodos: [], - initialStatus: "active", startTask: false, }) - // Metadata persistence - parent gets "delegated" status (child status is set at creation via initialStatus) - expect(updateTaskHistory).toHaveBeenCalledTimes(1) + // Metadata persistence - child gets "active" status, parent gets "delegated" status + expect(updateTaskHistory).toHaveBeenCalledTimes(2) - // Parent set to "delegated" - const parentSaved = updateTaskHistory.mock.calls[0][0] + // Child set to "active" (first call) + const childSaved = updateTaskHistory.mock.calls[0][0] + expect(childSaved).toEqual( + expect.objectContaining({ + id: "child-1", + status: "active", + }), + ) + + // Parent set to "delegated" (second call) + const parentSaved = updateTaskHistory.mock.calls[1][0] expect(parentSaved).toEqual( expect.objectContaining({ id: "parent-1", @@ -99,7 +114,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { const callOrder: string[] = [] const parentTask = { taskId: "parent-1", emit: vi.fn() } as any - const childStart = vi.fn(() => callOrder.push("child.start")) + const childStart = vi.fn(() => { + callOrder.push("child.start") + return Promise.resolve() + }) const updateTaskHistory = vi.fn(async () => { callOrder.push("updateTaskHistory") @@ -130,6 +148,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { updateTaskHistory, handleModeSwitch, log: vi.fn(), + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, } as unknown as ClineProvider await (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, { @@ -139,7 +158,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { mode: "code", }) - // Verify ordering: createTask → updateTaskHistory → child.start - expect(callOrder).toEqual(["createTask", "updateTaskHistory", "child.start"]) + // Verify ordering: createTask → updateTaskHistory (child) → updateTaskHistory (parent) → child.start + expect(callOrder).toEqual(["createTask", "updateTaskHistory", "updateTaskHistory", "child.start"]) }) }) diff --git a/src/__tests__/removeClineFromStack-delegation.spec.ts b/src/__tests__/removeClineFromStack-delegation.spec.ts index a72f580d6f..adc7f1e659 100644 --- a/src/__tests__/removeClineFromStack-delegation.spec.ts +++ b/src/__tests__/removeClineFromStack-delegation.spec.ts @@ -1,6 +1,12 @@ // npx vitest run __tests__/removeClineFromStack-delegation.spec.ts import { describe, it, expect, vi } from "vitest" + +vi.mock("../core/task-persistence/delegationMeta", () => ({ + readDelegationMeta: vi.fn().mockResolvedValue(null), + saveDelegationMeta: vi.fn().mockResolvedValue(undefined), +})) + import { ClineProvider } from "../core/webview/ClineProvider" describe("ClineProvider.removeClineFromStack() delegation awareness", () => { @@ -38,6 +44,7 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => { log: vi.fn(), getTaskWithId, updateTaskHistory, + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, } return { provider, childTask, updateTaskHistory, getTaskWithId } @@ -183,6 +190,7 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => { log: vi.fn(), getTaskWithId: vi.fn(), updateTaskHistory: vi.fn(), + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, } // Should not throw @@ -263,6 +271,7 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => { log: vi.fn(), getTaskWithId, updateTaskHistory, + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, } // Simulate what delegateParentAndOpenChild does: pop B with skipDelegationRepair diff --git a/src/api/index.ts b/src/api/index.ts index 31da830220..0f3bb1f9b9 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -88,6 +88,13 @@ export interface ApiHandlerCreateMessageMetadata { * Only applies to providers that support function calling restrictions (e.g., Gemini). */ allowedFunctionNames?: string[] + /** Provider-specific options for tool definitions (e.g. cache control). */ + toolProviderOptions?: Record> + /** Provider-specific options for the system prompt (e.g. cache control). + * Cache-aware providers use this to inject the system prompt as a cached + * system message, since AI SDK v6 does not support providerOptions on the + * `system` string parameter. */ + systemProviderOptions?: Record> } export interface ApiHandler { diff --git a/src/api/providers/__tests__/anthropic.spec.ts b/src/api/providers/__tests__/anthropic.spec.ts index 75fcb119ad..d65506135b 100644 --- a/src/api/providers/__tests__/anthropic.spec.ts +++ b/src/api/providers/__tests__/anthropic.spec.ts @@ -399,7 +399,7 @@ describe("AnthropicHandler", () => { expect(endChunk).toBeDefined() }) - it("should pass system prompt via system param with systemProviderOptions for cache control", async () => { + it("should pass system prompt via system param when no systemProviderOptions", async () => { setupStreamTextMock([{ type: "text-delta", text: "test" }]) const stream = handler.createMessage(systemPrompt, [ @@ -410,16 +410,37 @@ describe("AnthropicHandler", () => { // Consume } - // Verify streamText was called with system + systemProviderOptions (not as a message) + // Without systemProviderOptions, system prompt is passed via the system parameter const callArgs = mockStreamText.mock.calls[0]![0] expect(callArgs.system).toBe(systemPrompt) - expect(callArgs.systemProviderOptions).toEqual({ - anthropic: { cacheControl: { type: "ephemeral" } }, - }) // System prompt should NOT be in the messages array const systemMessages = callArgs.messages.filter((m: any) => m.role === "system") expect(systemMessages).toHaveLength(0) }) + + it("should inject system prompt as cached system message when systemProviderOptions provided", async () => { + setupStreamTextMock([{ type: "text-delta", text: "test" }]) + + const cacheOpts = { anthropic: { cacheControl: { type: "ephemeral" } } } + const stream = handler.createMessage( + systemPrompt, + [{ role: "user", content: [{ type: "text" as const, text: "test" }] }], + { taskId: "test-task", systemProviderOptions: cacheOpts }, + ) + + for await (const _chunk of stream) { + // Consume + } + + // With systemProviderOptions, system prompt is injected as messages[0] + const callArgs = mockStreamText.mock.calls[0]![0] + expect(callArgs.system).toBeUndefined() + // System prompt should be the first message with providerOptions + const systemMessages = callArgs.messages.filter((m: any) => m.role === "system") + expect(systemMessages).toHaveLength(1) + expect(systemMessages[0].content).toBe(systemPrompt) + expect(systemMessages[0].providerOptions).toEqual(cacheOpts) + }) }) describe("completePrompt", () => { diff --git a/src/api/providers/__tests__/bedrock.spec.ts b/src/api/providers/__tests__/bedrock.spec.ts index 645202486c..62acd7bae4 100644 --- a/src/api/providers/__tests__/bedrock.spec.ts +++ b/src/api/providers/__tests__/bedrock.spec.ts @@ -1279,4 +1279,165 @@ describe("AwsBedrockHandler", () => { expect(mockCaptureException).toHaveBeenCalled() }) }) + + describe("AI SDK v6 usage field paths", () => { + const systemPrompt = "You are a helpful assistant" + const messages: RooMessage[] = [ + { + role: "user", + content: "Hello", + }, + ] + + function setupStream(usage: Record, providerMetadata: Record = {}) { + async function* mockFullStream() { + yield { type: "text-delta", text: "reply" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve(usage), + providerMetadata: Promise.resolve(providerMetadata), + }) + } + + describe("cache tokens", () => { + it("should read cache tokens from v6 top-level cachedInputTokens", async () => { + setupStream({ inputTokens: 100, outputTokens: 50, cachedInputTokens: 30 }) + + const generator = handler.createMessage(systemPrompt, messages) + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c: any) => c.type === "usage") as any + expect(usageChunk).toBeDefined() + expect(usageChunk.cacheReadTokens).toBe(30) + }) + + it("should read cache tokens from v6 inputTokenDetails.cacheReadTokens", async () => { + setupStream({ + inputTokens: 100, + outputTokens: 50, + inputTokenDetails: { cacheReadTokens: 25 }, + }) + + const generator = handler.createMessage(systemPrompt, messages) + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c: any) => c.type === "usage") as any + expect(usageChunk).toBeDefined() + expect(usageChunk.cacheReadTokens).toBe(25) + }) + + it("should prefer v6 top-level cachedInputTokens over providerMetadata.bedrock", async () => { + setupStream( + { inputTokens: 100, outputTokens: 50, cachedInputTokens: 30 }, + { bedrock: { usage: { cacheReadInputTokens: 20 } } }, + ) + + const generator = handler.createMessage(systemPrompt, messages) + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c: any) => c.type === "usage") as any + expect(usageChunk).toBeDefined() + expect(usageChunk.cacheReadTokens).toBe(30) + }) + + it("should fall back to providerMetadata.bedrock.usage.cacheReadInputTokens", async () => { + setupStream( + { inputTokens: 100, outputTokens: 50 }, + { bedrock: { usage: { cacheReadInputTokens: 20 } } }, + ) + + const generator = handler.createMessage(systemPrompt, messages) + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c: any) => c.type === "usage") as any + expect(usageChunk).toBeDefined() + expect(usageChunk.cacheReadTokens).toBe(20) + }) + + it("should read cacheWriteTokens from v6 inputTokenDetails.cacheWriteTokens", async () => { + setupStream({ + inputTokens: 100, + outputTokens: 50, + inputTokenDetails: { cacheWriteTokens: 15 }, + }) + + const generator = handler.createMessage(systemPrompt, messages) + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c: any) => c.type === "usage") as any + expect(usageChunk).toBeDefined() + expect(usageChunk.cacheWriteTokens).toBe(15) + }) + }) + + describe("reasoning tokens", () => { + it("should read reasoning tokens from v6 top-level reasoningTokens", async () => { + setupStream({ inputTokens: 100, outputTokens: 50, reasoningTokens: 40 }) + + const generator = handler.createMessage(systemPrompt, messages) + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c: any) => c.type === "usage") as any + expect(usageChunk).toBeDefined() + expect(usageChunk.reasoningTokens).toBe(40) + }) + + it("should read reasoning tokens from v6 outputTokenDetails.reasoningTokens", async () => { + setupStream({ + inputTokens: 100, + outputTokens: 50, + outputTokenDetails: { reasoningTokens: 35 }, + }) + + const generator = handler.createMessage(systemPrompt, messages) + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c: any) => c.type === "usage") as any + expect(usageChunk).toBeDefined() + expect(usageChunk.reasoningTokens).toBe(35) + }) + + it("should prefer v6 top-level reasoningTokens over outputTokenDetails", async () => { + setupStream({ + inputTokens: 100, + outputTokens: 50, + reasoningTokens: 40, + outputTokenDetails: { reasoningTokens: 15 }, + }) + + const generator = handler.createMessage(systemPrompt, messages) + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c: any) => c.type === "usage") as any + expect(usageChunk).toBeDefined() + expect(usageChunk.reasoningTokens).toBe(40) + }) + }) + }) }) diff --git a/src/api/providers/__tests__/gemini.spec.ts b/src/api/providers/__tests__/gemini.spec.ts index c70b4be8d1..2972eb47e5 100644 --- a/src/api/providers/__tests__/gemini.spec.ts +++ b/src/api/providers/__tests__/gemini.spec.ts @@ -472,4 +472,168 @@ describe("GeminiHandler", () => { expect(mockCaptureException).toHaveBeenCalled() }) }) + + describe("AI SDK v6 usage field paths", () => { + const mockMessages: RooMessage[] = [ + { + role: "user", + content: "Hello", + }, + ] + const systemPrompt = "You are a helpful assistant" + + function setupStream(usage: Record) { + const mockFullStream = (async function* () { + yield { type: "text-delta", text: "reply" } + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve(usage), + providerMetadata: Promise.resolve({}), + }) + } + + describe("cache tokens", () => { + it("should read cache tokens from v6 top-level cachedInputTokens", async () => { + setupStream({ inputTokens: 100, outputTokens: 50, cachedInputTokens: 30 }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk!.cacheReadTokens).toBe(30) + }) + + it("should read cache tokens from v6 inputTokenDetails.cacheReadTokens", async () => { + setupStream({ + inputTokens: 100, + outputTokens: 50, + inputTokenDetails: { cacheReadTokens: 25 }, + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk!.cacheReadTokens).toBe(25) + }) + + it("should prefer v6 top-level cachedInputTokens over legacy details", async () => { + setupStream({ + inputTokens: 100, + outputTokens: 50, + cachedInputTokens: 30, + details: { cachedInputTokens: 20 }, + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk!.cacheReadTokens).toBe(30) + }) + + it("should fall back to legacy details.cachedInputTokens", async () => { + setupStream({ + inputTokens: 100, + outputTokens: 50, + details: { cachedInputTokens: 20 }, + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk!.cacheReadTokens).toBe(20) + }) + }) + + describe("reasoning tokens", () => { + it("should read reasoning tokens from v6 top-level reasoningTokens", async () => { + setupStream({ inputTokens: 100, outputTokens: 50, reasoningTokens: 40 }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk!.reasoningTokens).toBe(40) + }) + + it("should read reasoning tokens from v6 outputTokenDetails.reasoningTokens", async () => { + setupStream({ + inputTokens: 100, + outputTokens: 50, + outputTokenDetails: { reasoningTokens: 35 }, + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk!.reasoningTokens).toBe(35) + }) + + it("should prefer v6 top-level reasoningTokens over legacy details", async () => { + setupStream({ + inputTokens: 100, + outputTokens: 50, + reasoningTokens: 40, + details: { reasoningTokens: 15 }, + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk!.reasoningTokens).toBe(40) + }) + + it("should fall back to legacy details.reasoningTokens", async () => { + setupStream({ + inputTokens: 100, + outputTokens: 50, + details: { reasoningTokens: 15 }, + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk!.reasoningTokens).toBe(15) + }) + }) + }) }) diff --git a/src/api/providers/__tests__/minimax.spec.ts b/src/api/providers/__tests__/minimax.spec.ts index 3538184eee..84ecce0f24 100644 --- a/src/api/providers/__tests__/minimax.spec.ts +++ b/src/api/providers/__tests__/minimax.spec.ts @@ -338,16 +338,12 @@ describe("MiniMaxHandler", () => { expect(mockMergeEnvironmentDetailsForMiniMax).toHaveBeenCalledWith(messages) const callArgs = mockStreamText.mock.calls[0]?.[0] + // Cache control is now applied centrally in Task.ts, not per-provider expect(callArgs.messages).toEqual( expect.arrayContaining([ expect.objectContaining({ role: "user", content: [{ type: "text", text: "Merged message" }], - providerOptions: { - anthropic: { - cacheControl: { type: "ephemeral" }, - }, - }, }), ]), ) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index df6f7cb841..e87e8e6f4e 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -84,7 +84,13 @@ describe("NativeOllamaHandler", () => { expect(results).toHaveLength(3) expect(results[0]).toEqual({ type: "text", text: "Hello" }) expect(results[1]).toEqual({ type: "text", text: " world" }) - expect(results[2]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 2 }) + expect(results[2]).toEqual({ + type: "usage", + inputTokens: 10, + outputTokens: 2, + totalInputTokens: 10, + totalOutputTokens: 2, + }) }) it("should not include providerOptions by default (no num_ctx)", async () => { diff --git a/src/api/providers/__tests__/openai-native-usage.spec.ts b/src/api/providers/__tests__/openai-native-usage.spec.ts index 0fbb9614be..5973818c98 100644 --- a/src/api/providers/__tests__/openai-native-usage.spec.ts +++ b/src/api/providers/__tests__/openai-native-usage.spec.ts @@ -353,4 +353,201 @@ describe("OpenAiNativeHandler - usage metrics", () => { expect(callArgs.providerOptions.openai.promptCacheRetention).toBeUndefined() }) }) + + describe("AI SDK v6 usage field paths", () => { + describe("cache tokens", () => { + it("should read cache tokens from v6 top-level cachedInputTokens", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + cachedInputTokens: 30, + }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].cacheReadTokens).toBe(30) + }) + + it("should read cache tokens from v6 inputTokenDetails.cacheReadTokens", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + inputTokenDetails: { cacheReadTokens: 25 }, + }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].cacheReadTokens).toBe(25) + }) + + it("should prefer v6 top-level cachedInputTokens over legacy details", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + cachedInputTokens: 30, + details: { cachedInputTokens: 20 }, + }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].cacheReadTokens).toBe(30) + }) + + it("should read cacheWriteTokens from v6 inputTokenDetails.cacheWriteTokens", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + inputTokenDetails: { cacheWriteTokens: 15 }, + }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].cacheWriteTokens).toBe(15) + }) + }) + + describe("reasoning tokens", () => { + it("should read reasoning tokens from v6 top-level reasoningTokens", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + reasoningTokens: 40, + }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].reasoningTokens).toBe(40) + }) + + it("should read reasoning tokens from v6 outputTokenDetails.reasoningTokens", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + outputTokenDetails: { reasoningTokens: 35 }, + }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].reasoningTokens).toBe(35) + }) + + it("should prefer v6 top-level reasoningTokens over legacy details", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + reasoningTokens: 40, + details: { reasoningTokens: 15 }, + }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].reasoningTokens).toBe(40) + }) + }) + }) }) diff --git a/src/api/providers/__tests__/openai-usage-tracking.spec.ts b/src/api/providers/__tests__/openai-usage-tracking.spec.ts index 19ba1dc275..44d909209a 100644 --- a/src/api/providers/__tests__/openai-usage-tracking.spec.ts +++ b/src/api/providers/__tests__/openai-usage-tracking.spec.ts @@ -98,6 +98,8 @@ describe("OpenAiHandler with usage tracking fix", () => { type: "usage", inputTokens: 10, outputTokens: 5, + totalInputTokens: 10, + totalOutputTokens: 5, }) const lastChunk = chunks[chunks.length - 1] @@ -133,6 +135,8 @@ describe("OpenAiHandler with usage tracking fix", () => { type: "usage", inputTokens: 10, outputTokens: 5, + totalInputTokens: 10, + totalOutputTokens: 5, }) }) @@ -229,5 +233,202 @@ describe("OpenAiHandler with usage tracking fix", () => { }), ) }) + + describe("AI SDK v6 usage field paths", () => { + describe("cache tokens", () => { + it("should read cache tokens from v6 top-level cachedInputTokens when providerMetadata is empty", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + mockStreamText.mockReturnValueOnce({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + cachedInputTokens: 30, + }), + providerMetadata: Promise.resolve(undefined), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].cacheReadTokens).toBe(30) + }) + + it("should read cache tokens from v6 inputTokenDetails.cacheReadTokens when providerMetadata is empty", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + mockStreamText.mockReturnValueOnce({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + inputTokenDetails: { cacheReadTokens: 25 }, + }), + providerMetadata: Promise.resolve(undefined), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].cacheReadTokens).toBe(25) + }) + + it("should prefer providerMetadata.openai.cachedPromptTokens over v6 top-level", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + mockStreamText.mockReturnValueOnce({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + cachedInputTokens: 30, + }), + providerMetadata: Promise.resolve({ + openai: { + cachedPromptTokens: 80, + }, + }), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].cacheReadTokens).toBe(80) + }) + + it("should prefer v6 top-level cachedInputTokens over legacy details when providerMetadata is empty", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + mockStreamText.mockReturnValueOnce({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + cachedInputTokens: 30, + details: { cachedInputTokens: 20 }, + }), + providerMetadata: Promise.resolve(undefined), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].cacheReadTokens).toBe(30) + }) + }) + + describe("reasoning tokens", () => { + it("should read reasoning tokens from v6 top-level reasoningTokens when providerMetadata is empty", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + mockStreamText.mockReturnValueOnce({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + reasoningTokens: 40, + }), + providerMetadata: Promise.resolve(undefined), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].reasoningTokens).toBe(40) + }) + + it("should read reasoning tokens from v6 outputTokenDetails.reasoningTokens when providerMetadata is empty", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + mockStreamText.mockReturnValueOnce({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + outputTokenDetails: { reasoningTokens: 35 }, + }), + providerMetadata: Promise.resolve(undefined), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].reasoningTokens).toBe(35) + }) + + it("should prefer providerMetadata.openai.reasoningTokens over v6 top-level", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + mockStreamText.mockReturnValueOnce({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + reasoningTokens: 40, + }), + providerMetadata: Promise.resolve({ + openai: { + reasoningTokens: 20, + }, + }), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].reasoningTokens).toBe(20) + }) + }) + }) }) }) diff --git a/src/api/providers/__tests__/requesty.spec.ts b/src/api/providers/__tests__/requesty.spec.ts index 0967eefdd7..852ce17e6b 100644 --- a/src/api/providers/__tests__/requesty.spec.ts +++ b/src/api/providers/__tests__/requesty.spec.ts @@ -178,6 +178,8 @@ describe("RequestyHandler", () => { cacheReadTokens: 2, reasoningTokens: undefined, totalCost: expect.any(Number), + totalInputTokens: 10, + totalOutputTokens: 20, }) }) diff --git a/src/api/providers/__tests__/roo.spec.ts b/src/api/providers/__tests__/roo.spec.ts index 1be5d11656..3e8278afb6 100644 --- a/src/api/providers/__tests__/roo.spec.ts +++ b/src/api/providers/__tests__/roo.spec.ts @@ -105,7 +105,12 @@ function createMockStreamResult(options?: { toolCallParts?: Array<{ type: string; id?: string; toolName?: string; delta?: string }> inputTokens?: number outputTokens?: number - providerMetadata?: Record + providerMetadata?: Record + usage?: { + cachedInputTokens?: number + inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } + details?: { cachedInputTokens?: number } + } }) { const { textChunks = ["Test response"], @@ -114,6 +119,7 @@ function createMockStreamResult(options?: { inputTokens = 10, outputTokens = 5, providerMetadata = undefined, + usage = undefined, } = options ?? {} const fullStream = (async function* () { @@ -130,7 +136,7 @@ function createMockStreamResult(options?: { return { fullStream, - usage: Promise.resolve({ inputTokens, outputTokens }), + usage: Promise.resolve({ inputTokens, outputTokens, ...usage }), providerMetadata: Promise.resolve(providerMetadata), } } @@ -767,6 +773,65 @@ describe("RooHandler", () => { expect(usageChunk).toBeDefined() expect(usageChunk.cacheWriteTokens).toBe(20) expect(usageChunk.cacheReadTokens).toBe(30) + expect(usageChunk.totalInputTokens).toBe(100) + }) + + it("should fall back to anthropic metadata when roo metadata is missing", async () => { + mockStreamText.mockReturnValue( + createMockStreamResult({ + inputTokens: 120, + outputTokens: 40, + providerMetadata: { + anthropic: { + cacheCreationInputTokens: 25, + usage: { + cache_read_input_tokens: 35, + }, + }, + }, + }), + ) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk.inputTokens).toBe(120) + expect(usageChunk.cacheWriteTokens).toBe(25) + expect(usageChunk.cacheReadTokens).toBe(35) + expect(usageChunk.totalInputTokens).toBe(120) + }) + + it("should fall back to AI SDK usage cache fields when provider metadata is missing", async () => { + mockStreamText.mockReturnValue( + createMockStreamResult({ + inputTokens: 140, + outputTokens: 30, + usage: { + cachedInputTokens: 22, + inputTokenDetails: { + cacheWriteTokens: 11, + }, + }, + }), + ) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk.inputTokens).toBe(140) + expect(usageChunk.cacheWriteTokens).toBe(11) + expect(usageChunk.cacheReadTokens).toBe(22) + expect(usageChunk.totalInputTokens).toBe(140) }) }) diff --git a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts index f482c7cf2d..1864a6a4b5 100644 --- a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts @@ -191,6 +191,8 @@ describe("VercelAiGatewayHandler", () => { cacheWriteTokens: 2, cacheReadTokens: 3, totalCost: 0.005, + totalInputTokens: 10, + totalOutputTokens: 5, }) }) @@ -281,6 +283,8 @@ describe("VercelAiGatewayHandler", () => { cacheWriteTokens: 2, cacheReadTokens: 3, totalCost: 0.005, + totalInputTokens: 10, + totalOutputTokens: 5, }) }) diff --git a/src/api/providers/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index 789915d6ef..7703f31786 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -121,6 +121,22 @@ describe("ZAiHandler", () => { expect(model.info.preserveReasoning).toBe(true) }) + it("should return GLM-5 international model with thinking support", () => { + const testModelId: InternationalZAiModelId = "glm-5" + const handlerWithModel = new ZAiHandler({ + apiModelId: testModelId, + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual(internationalZAiModels[testModelId]) + expect(model.info.contextWindow).toBe(202_752) + expect(model.info.supportsReasoningEffort).toEqual(["disable", "medium"]) + expect(model.info.reasoningEffort).toBe("medium") + expect(model.info.preserveReasoning).toBe(true) + }) + it("should return GLM-4.5v international model with vision support", () => { const testModelId: InternationalZAiModelId = "glm-4.5v" const handlerWithModel = new ZAiHandler({ @@ -203,6 +219,22 @@ describe("ZAiHandler", () => { expect(model.info.reasoningEffort).toBe("medium") expect(model.info.preserveReasoning).toBe(true) }) + + it("should return GLM-5 China model with thinking support", () => { + const testModelId: MainlandZAiModelId = "glm-5" + const handlerWithModel = new ZAiHandler({ + apiModelId: testModelId, + zaiApiKey: "test-zai-api-key", + zaiApiLine: "china_coding", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual(mainlandZAiModels[testModelId]) + expect(model.info.contextWindow).toBe(202_752) + expect(model.info.supportsReasoningEffort).toEqual(["disable", "medium"]) + expect(model.info.reasoningEffort).toBe("medium") + expect(model.info.preserveReasoning).toBe(true) + }) }) describe("International API", () => { @@ -508,6 +540,74 @@ describe("ZAiHandler", () => { }) }) + describe("GLM-5 Thinking Mode", () => { + it("should enable thinking by default for GLM-5 (default reasoningEffort is medium)", async () => { + const handlerWithModel = new ZAiHandler({ + apiModelId: "glm-5", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + }) + + async function* mockFullStream() { + yield { type: "text-delta", text: "response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + const stream = handlerWithModel.createMessage("system prompt", []) + for await (const _chunk of stream) { + // drain + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: { + zhipu: { + thinking: { type: "enabled" }, + }, + }, + }), + ) + }) + + it("should disable thinking for GLM-5 when reasoningEffort is set to disable", async () => { + const handlerWithModel = new ZAiHandler({ + apiModelId: "glm-5", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + enableReasoningEffort: true, + reasoningEffort: "disable", + }) + + async function* mockFullStream() { + yield { type: "text-delta", text: "response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + const stream = handlerWithModel.createMessage("system prompt", []) + for await (const _chunk of stream) { + // drain + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: { + zhipu: { + thinking: { type: "disabled" }, + }, + }, + }), + ) + }) + }) + describe("completePrompt", () => { it("should complete a prompt using generateText", async () => { mockGenerateText.mockResolvedValue({ diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index 131b36992e..d95d5d7786 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -26,6 +26,7 @@ import { handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" +import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" import { calculateApiCostAnthropic } from "../../shared/cost" import { DEFAULT_HEADERS } from "./constants" @@ -96,6 +97,7 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple // Convert tools to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) // Build Anthropic provider options const anthropicProviderOptions: Record = {} @@ -119,45 +121,18 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple anthropicProviderOptions.disableParallelToolUse = true } - /** - * Vertex API has specific limitations for prompt caching: - * 1. Maximum of 4 blocks can have cache_control - * 2. Only text blocks can be cached (images and other content types cannot) - * 3. Cache control can only be applied to user messages, not assistant messages - * - * Our caching strategy: - * - Cache the system prompt (1 block) - * - Cache the last text block of the second-to-last user message (1 block) - * - Cache the last text block of the last user message (1 block) - * This ensures we stay under the 4-block limit while maintaining effective caching - * for the most relevant context. - */ - const cacheProviderOption = { anthropic: { cacheControl: { type: "ephemeral" as const } } } - - const userMsgIndices = messages.reduce( - (acc, msg, index) => ("role" in msg && msg.role === "user" ? [...acc, index] : acc), - [] as number[], + // Breakpoint 1: System prompt caching — inject as cached system message + const effectiveSystemPrompt = applySystemPromptCaching( + systemPrompt, + aiSdkMessages, + metadata?.systemProviderOptions, ) - const targetIndices = new Set() - const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1 - const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 - - if (lastUserMsgIndex >= 0) targetIndices.add(lastUserMsgIndex) - if (secondLastUserMsgIndex >= 0) targetIndices.add(secondLastUserMsgIndex) - - if (targetIndices.size > 0) { - this.applyCacheControlToAiSdkMessages(messages as ModelMessage[], targetIndices, cacheProviderOption) - } - // Build streamText request // Cast providerOptions to any to bypass strict JSONObject typing — the AI SDK accepts the correct runtime values const requestOptions: Parameters[0] = { model: this.provider(modelConfig.id), - system: systemPrompt, - ...({ - systemProviderOptions: { anthropic: { cacheControl: { type: "ephemeral" } } }, - } as Record), + system: effectiveSystemPrompt, messages: aiSdkMessages, temperature: modelConfig.temperature, maxOutputTokens: modelConfig.maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, @@ -216,12 +191,19 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple const inputTokens = usage.inputTokens ?? 0 const outputTokens = usage.outputTokens ?? 0 - // Extract cache metrics from Anthropic's providerMetadata + // Extract cache metrics from Anthropic's providerMetadata. + // In @ai-sdk/anthropic v3.0.38+, cacheReadInputTokens may only exist at + // usage.cache_read_input_tokens rather than the top-level property. const anthropicMeta = providerMetadata?.anthropic as - | { cacheCreationInputTokens?: number; cacheReadInputTokens?: number } + | { + cacheCreationInputTokens?: number + cacheReadInputTokens?: number + usage?: { cache_read_input_tokens?: number } + } | undefined const cacheWriteTokens = anthropicMeta?.cacheCreationInputTokens ?? 0 - const cacheReadTokens = anthropicMeta?.cacheReadInputTokens ?? 0 + const cacheReadTokens = + anthropicMeta?.cacheReadInputTokens ?? anthropicMeta?.usage?.cache_read_input_tokens ?? 0 const { totalCost } = calculateApiCostAnthropic( info, @@ -238,29 +220,9 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, totalCost, - } - } - - /** - * Apply cacheControl providerOptions to the correct AI SDK messages by walking - * the original Anthropic messages and converted AI SDK messages in parallel. - * - * convertToAiSdkMessages() can split a single Anthropic user message (containing - * tool_results + text) into 2 AI SDK messages (tool role + user role). This method - * accounts for that split so cache control lands on the right message. - */ - private applyCacheControlToAiSdkMessages( - aiSdkMessages: { role: string; providerOptions?: Record> }[], - targetIndices: Set, - cacheProviderOption: Record>, - ): void { - for (const idx of targetIndices) { - if (idx >= 0 && idx < aiSdkMessages.length) { - aiSdkMessages[idx].providerOptions = { - ...aiSdkMessages[idx].providerOptions, - ...cacheProviderOption, - } - } + // Anthropic: inputTokens is non-cached only; total = input + cache write + cache read + totalInputTokens: inputTokens + (cacheWriteTokens ?? 0) + (cacheReadTokens ?? 0), + totalOutputTokens: outputTokens, } } diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 1f519250fa..81189a7868 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -24,6 +24,7 @@ import { handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" +import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" import { calculateApiCostAnthropic } from "../../shared/cost" import { DEFAULT_HEADERS } from "./constants" @@ -82,6 +83,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa // Convert tools to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) // Build Anthropic provider options const anthropicProviderOptions: Record = {} @@ -105,34 +107,20 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa anthropicProviderOptions.disableParallelToolUse = true } - // Apply cache control to user messages - // Strategy: cache the last 2 user messages (write-to-cache + read-from-cache) - const cacheProviderOption = { anthropic: { cacheControl: { type: "ephemeral" as const } } } - - const userMsgIndices = messages.reduce( - (acc, msg, index) => ("role" in msg && msg.role === "user" ? [...acc, index] : acc), - [] as number[], + // Breakpoint 1: System prompt caching — inject as cached system message + // AI SDK v6 does not support providerOptions on the system string parameter, + // so cache-aware providers convert it to a system message with providerOptions. + const effectiveSystemPrompt = applySystemPromptCaching( + systemPrompt, + aiSdkMessages, + metadata?.systemProviderOptions, ) - const targetIndices = new Set() - const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1 - const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 - - if (lastUserMsgIndex >= 0) targetIndices.add(lastUserMsgIndex) - if (secondLastUserMsgIndex >= 0) targetIndices.add(secondLastUserMsgIndex) - - if (targetIndices.size > 0) { - this.applyCacheControlToAiSdkMessages(messages as ModelMessage[], targetIndices, cacheProviderOption) - } - // Build streamText request // Cast providerOptions to any to bypass strict JSONObject typing — the AI SDK accepts the correct runtime values const requestOptions: Parameters[0] = { model: this.provider(modelConfig.id), - system: systemPrompt, - ...({ - systemProviderOptions: { anthropic: { cacheControl: { type: "ephemeral" } } }, - } as Record), + system: effectiveSystemPrompt, messages: aiSdkMessages, temperature: modelConfig.temperature, maxOutputTokens: modelConfig.maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, @@ -191,12 +179,19 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa const inputTokens = usage.inputTokens ?? 0 const outputTokens = usage.outputTokens ?? 0 - // Extract cache metrics from Anthropic's providerMetadata + // Extract cache metrics from Anthropic's providerMetadata. + // In @ai-sdk/anthropic v3.0.38+, cacheReadInputTokens may only exist at + // usage.cache_read_input_tokens rather than the top-level property. const anthropicMeta = providerMetadata?.anthropic as - | { cacheCreationInputTokens?: number; cacheReadInputTokens?: number } + | { + cacheCreationInputTokens?: number + cacheReadInputTokens?: number + usage?: { cache_read_input_tokens?: number } + } | undefined const cacheWriteTokens = anthropicMeta?.cacheCreationInputTokens ?? 0 - const cacheReadTokens = anthropicMeta?.cacheReadInputTokens ?? 0 + const cacheReadTokens = + anthropicMeta?.cacheReadInputTokens ?? anthropicMeta?.usage?.cache_read_input_tokens ?? 0 const { totalCost } = calculateApiCostAnthropic( info, @@ -213,29 +208,9 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, totalCost, - } - } - - /** - * Apply cacheControl providerOptions to the correct AI SDK messages by walking - * the original Anthropic messages and converted AI SDK messages in parallel. - * - * convertToAiSdkMessages() can split a single Anthropic user message (containing - * tool_results + text) into 2 AI SDK messages (tool role + user role). This method - * accounts for that split so cache control lands on the right message. - */ - private applyCacheControlToAiSdkMessages( - aiSdkMessages: { role: string; providerOptions?: Record> }[], - targetIndices: Set, - cacheProviderOption: Record>, - ): void { - for (const idx of targetIndices) { - if (idx >= 0 && idx < aiSdkMessages.length) { - aiSdkMessages[idx].providerOptions = { - ...aiSdkMessages[idx].providerOptions, - ...cacheProviderOption, - } - } + // Anthropic: inputTokens is non-cached only; total = input + cache write + cache read + totalInputTokens: inputTokens + (cacheWriteTokens ?? 0) + (cacheReadTokens ?? 0), + totalOutputTokens: outputTokens, } } diff --git a/src/api/providers/azure.ts b/src/api/providers/azure.ts index baad07558c..c039f0f373 100644 --- a/src/api/providers/azure.ts +++ b/src/api/providers/azure.ts @@ -13,6 +13,7 @@ import { mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" +import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -88,6 +89,12 @@ export class AzureHandler extends BaseProvider implements SingleCompletionHandle usage: { inputTokens?: number outputTokens?: number + totalInputTokens?: number + totalOutputTokens?: number + cachedInputTokens?: number + reasoningTokens?: number + inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } + outputTokenDetails?: { reasoningTokens?: number } details?: { cachedInputTokens?: number reasoningTokens?: number @@ -100,19 +107,28 @@ export class AzureHandler extends BaseProvider implements SingleCompletionHandle } }, ): ApiStreamUsageChunk { - // Extract cache metrics from Azure's providerMetadata if available - const cacheReadTokens = providerMetadata?.azure?.promptCacheHitTokens ?? usage.details?.cachedInputTokens + // Extract cache metrics from Azure's providerMetadata, then v6 fields, then legacy + const cacheReadTokens = + providerMetadata?.azure?.promptCacheHitTokens ?? + usage.cachedInputTokens ?? + usage.inputTokenDetails?.cacheReadTokens ?? + usage.details?.cachedInputTokens // Azure uses OpenAI-compatible caching which does not report cache write tokens separately; // promptCacheMissTokens represents tokens NOT found in cache (processed from scratch), not tokens written to cache. - const cacheWriteTokens = undefined + const cacheWriteTokens = usage.inputTokenDetails?.cacheWriteTokens + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 return { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, + inputTokens, + outputTokens, cacheReadTokens, cacheWriteTokens, - reasoningTokens: usage.details?.reasoningTokens, + reasoningTokens: + usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, } } @@ -144,11 +160,12 @@ export class AzureHandler extends BaseProvider implements SingleCompletionHandle // Convert tools to OpenAI format first, then to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) // Build the request options const requestOptions: Parameters[0] = { model: languageModel, - system: systemPrompt, + system: systemPrompt || undefined, messages: aiSdkMessages, temperature: this.options.modelTemperature ?? temperature ?? AZURE_DEFAULT_TEMPERATURE, maxOutputTokens: this.getMaxOutputTokens(), diff --git a/src/api/providers/baseten.ts b/src/api/providers/baseten.ts index 4057b4714d..601e93b6c5 100644 --- a/src/api/providers/baseten.ts +++ b/src/api/providers/baseten.ts @@ -13,6 +13,7 @@ import { mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" +import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -69,16 +70,29 @@ export class BasetenHandler extends BaseProvider implements SingleCompletionHand protected processUsageMetrics(usage: { inputTokens?: number outputTokens?: number + totalInputTokens?: number + totalOutputTokens?: number + cachedInputTokens?: number + reasoningTokens?: number + inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } + outputTokenDetails?: { reasoningTokens?: number } details?: { cachedInputTokens?: number reasoningTokens?: number } }): ApiStreamUsageChunk { + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 return { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, - reasoningTokens: usage.details?.reasoningTokens, + inputTokens, + outputTokens, + cacheReadTokens: + usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens ?? usage.details?.cachedInputTokens, + reasoningTokens: + usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, } } @@ -105,10 +119,11 @@ export class BasetenHandler extends BaseProvider implements SingleCompletionHand const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) const requestOptions: Parameters[0] = { model: languageModel, - system: systemPrompt, + system: systemPrompt || undefined, messages: aiSdkMessages, temperature: this.options.modelTemperature ?? temperature ?? BASETEN_DEFAULT_TEMPERATURE, maxOutputTokens: this.getMaxOutputTokens(), diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index bf713ea016..3513971915 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -32,6 +32,7 @@ import { handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" +import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" import { getModelParams } from "../transform/model-params" import { shouldUseReasoningBudget } from "../../shared/api" import { BaseProvider } from "./base-provider" @@ -210,6 +211,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH // Convert tools to AI SDK format let openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) const toolChoice = mapToolChoice(metadata?.tool_choice) // Build provider options for reasoning, betas, etc. @@ -251,65 +253,34 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH } } - // Prompt caching: use AI SDK's cachePoint mechanism - // The AI SDK's @ai-sdk/amazon-bedrock supports cachePoint in providerOptions per message. - // - // Strategy: Bedrock allows up to 4 cache checkpoints. We use them as: - // 1. System prompt (via systemProviderOptions below) - // 2-4. Up to 3 user messages in the conversation history - // - // For the message cache points, we target the last 2 user messages (matching - // Anthropic's strategy: write-to-cache + read-from-cache) PLUS an earlier "anchor" - // user message near the middle of the conversation. This anchor ensures the 20-block - // lookback window has a stable cache entry to hit, covering all assistant/tool messages - // between the anchor and the recent messages. - // - // We identify targets in the ORIGINAL Anthropic messages (before AI SDK conversion) - // because convertToAiSdkMessages() splits user messages containing tool_results into - // separate "tool" + "user" role messages, which would skew naive counting. + // Prompt caching — only apply cache annotations when caching is enabled. + // This avoids the need to strip annotations after the fact, and keeps + // Bedrock decoupled from knowledge of what Task.ts stamps universally. const usePromptCache = Boolean(this.options.awsUsePromptCache && this.supportsAwsPromptCache(modelConfig)) - if (usePromptCache) { - const cachePointOption = { bedrock: { cachePoint: { type: "default" as const } } } + // Breakpoint 1: System prompt caching — only when Bedrock prompt cache is enabled + const effectiveSystemPrompt = usePromptCache + ? applySystemPromptCaching(systemPrompt, aiSdkMessages, metadata?.systemProviderOptions) + : systemPrompt || undefined - // Find all user message indices in the original (pre-conversion) message array. - const originalUserIndices = filteredMessages.reduce( - (acc, msg, idx) => ("role" in msg && msg.role === "user" ? [...acc, idx] : acc), - [], - ) - - // Select up to 3 user messages for cache points (system prompt uses the 4th): - // - Last user message: write to cache for next request - // - Second-to-last user message: read from cache for current request - // - An "anchor" message earlier in the conversation for 20-block window coverage - const targetOriginalIndices = new Set() - const numUserMsgs = originalUserIndices.length - - if (numUserMsgs >= 1) { - // Always cache the last user message - targetOriginalIndices.add(originalUserIndices[numUserMsgs - 1]) - } - if (numUserMsgs >= 2) { - // Cache the second-to-last user message - targetOriginalIndices.add(originalUserIndices[numUserMsgs - 2]) - } - if (numUserMsgs >= 5) { - // Add an anchor cache point roughly in the first third of user messages. - // This ensures that the 20-block lookback from the second-to-last breakpoint - // can find a stable cache entry, covering all the assistant and tool messages - // in the middle of the conversation. We pick the user message at ~1/3 position. - const anchorIdx = Math.floor(numUserMsgs / 3) - // Only add if it's not already one of the last-2 targets - if (!targetOriginalIndices.has(originalUserIndices[anchorIdx])) { - targetOriginalIndices.add(originalUserIndices[anchorIdx]) + // Strip non-Bedrock cache annotations from messages when caching is disabled, + // and strip Bedrock-specific annotations when caching is disabled. + if (!usePromptCache) { + for (const msg of aiSdkMessages) { + if (msg.providerOptions?.bedrock) { + const { bedrock: _, ...rest } = msg.providerOptions + msg.providerOptions = Object.keys(rest).length > 0 ? rest : undefined } } - - // Apply cachePoint to the correct AI SDK messages by walking both arrays in parallel. - // A single original user message with tool_results becomes [tool-role msg, user-role msg] - // in the AI SDK array, while a plain user message becomes [user-role msg]. - if (targetOriginalIndices.size > 0) { - this.applyCachePointsToAiSdkMessages(aiSdkMessages, targetOriginalIndices, cachePointOption) + // Also strip cache annotations from tool definitions + if (aiSdkTools) { + for (const key of Object.keys(aiSdkTools)) { + const tool = aiSdkTools[key] as { providerOptions?: Record> } + if (tool.providerOptions?.bedrock) { + const { bedrock: _, ...rest } = tool.providerOptions + tool.providerOptions = Object.keys(rest).length > 0 ? rest : undefined + } + } } } @@ -317,10 +288,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH // Cast providerOptions to any to bypass strict JSONObject typing — the AI SDK accepts the correct runtime values const requestOptions: Parameters[0] = { model: this.provider(modelConfig.id), - system: systemPrompt, - ...(usePromptCache && { - systemProviderOptions: { bedrock: { cachePoint: { type: "default" } } } as Record, - }), + system: effectiveSystemPrompt, messages: aiSdkMessages, temperature: modelConfig.temperature ?? (this.options.modelTemperature as number), maxOutputTokens: modelConfig.maxTokens || (modelConfig.info.maxTokens as number), @@ -383,7 +351,20 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH * Process usage metrics from the AI SDK response. */ private processUsageMetrics( - usage: { inputTokens?: number; outputTokens?: number }, + usage: { + inputTokens?: number + outputTokens?: number + totalInputTokens?: number + totalOutputTokens?: number + cachedInputTokens?: number + reasoningTokens?: number + inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } + outputTokenDetails?: { reasoningTokens?: number } + details?: { + cachedInputTokens?: number + reasoningTokens?: number + } + }, info: ModelInfo, providerMetadata?: Record>, ): ApiStreamUsageChunk { @@ -392,8 +373,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH // The AI SDK exposes reasoningTokens as a top-level field on usage, and also // under outputTokenDetails.reasoningTokens — there is no .details property. - const reasoningTokens = - (usage as any).reasoningTokens ?? (usage as any).outputTokenDetails?.reasoningTokens ?? 0 + const reasoningTokens = usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? 0 // Extract cache metrics primarily from usage (AI SDK standard locations), // falling back to providerMetadata.bedrock.usage for provider-specific fields. @@ -401,12 +381,11 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH | { cacheReadInputTokens?: number; cacheWriteInputTokens?: number } | undefined const cacheReadTokens = - (usage as any).inputTokenDetails?.cacheReadTokens ?? - (usage as any).cachedInputTokens ?? + usage.cachedInputTokens ?? + usage.inputTokenDetails?.cacheReadTokens ?? bedrockUsage?.cacheReadInputTokens ?? 0 - const cacheWriteTokens = - (usage as any).inputTokenDetails?.cacheWriteTokens ?? bedrockUsage?.cacheWriteInputTokens ?? 0 + const cacheWriteTokens = usage.inputTokenDetails?.cacheWriteTokens ?? bedrockUsage?.cacheWriteInputTokens ?? 0 // For prompt routers, the AI SDK surfaces the invoked model ID in // providerMetadata.bedrock.trace.promptRouter.invokedModelId. @@ -449,6 +428,9 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH reasoningTokens, info: costInfo, }), + // AI SDK normalizes inputTokens to total (OpenAI convention) for Bedrock + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, } } @@ -706,29 +688,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH ) } - /** - * Apply cachePoint providerOptions to the correct AI SDK messages by walking - * the original Anthropic messages and converted AI SDK messages in parallel. - * - * convertToAiSdkMessages() can split a single Anthropic user message (containing - * tool_results + text) into 2 AI SDK messages (tool role + user role). This method - * accounts for that split so cache points land on the right message. - */ - private applyCachePointsToAiSdkMessages( - aiSdkMessages: { role: string; providerOptions?: Record> }[], - targetIndices: Set, - cachePointOption: Record>, - ): void { - for (const idx of targetIndices) { - if (idx >= 0 && idx < aiSdkMessages.length) { - aiSdkMessages[idx].providerOptions = { - ...aiSdkMessages[idx].providerOptions, - ...cachePointOption, - } - } - } - } - /************************************************************************************ * * AMAZON REGIONS diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 6dc3016356..72c582dfef 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -13,6 +13,7 @@ import { mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" +import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -70,6 +71,12 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan usage: { inputTokens?: number outputTokens?: number + totalInputTokens?: number + totalOutputTokens?: number + cachedInputTokens?: number + reasoningTokens?: number + inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } + outputTokenDetails?: { reasoningTokens?: number } details?: { cachedInputTokens?: number reasoningTokens?: number @@ -82,17 +89,27 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan } }, ): ApiStreamUsageChunk { - // Extract cache metrics from DeepSeek's providerMetadata - const cacheReadTokens = providerMetadata?.deepseek?.promptCacheHitTokens ?? usage.details?.cachedInputTokens - const cacheWriteTokens = providerMetadata?.deepseek?.promptCacheMissTokens + // Extract cache metrics from DeepSeek's providerMetadata, then v6 fields, then legacy + const cacheReadTokens = + providerMetadata?.deepseek?.promptCacheHitTokens ?? + usage.cachedInputTokens ?? + usage.inputTokenDetails?.cacheReadTokens ?? + usage.details?.cachedInputTokens + const cacheWriteTokens = + providerMetadata?.deepseek?.promptCacheMissTokens ?? usage.inputTokenDetails?.cacheWriteTokens + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 return { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, + inputTokens, + outputTokens, cacheReadTokens, cacheWriteTokens, - reasoningTokens: usage.details?.reasoningTokens, + reasoningTokens: + usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, } } @@ -122,11 +139,12 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan // Convert tools to OpenAI format first, then to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) // Build the request options const requestOptions: Parameters[0] = { model: languageModel, - system: systemPrompt, + system: systemPrompt || undefined, messages: aiSdkMessages, temperature: this.options.modelTemperature ?? temperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE, maxOutputTokens: this.getMaxOutputTokens(), diff --git a/src/api/providers/fireworks.ts b/src/api/providers/fireworks.ts index cfc459c144..468c1f1840 100644 --- a/src/api/providers/fireworks.ts +++ b/src/api/providers/fireworks.ts @@ -13,6 +13,7 @@ import { mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" +import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -71,6 +72,12 @@ export class FireworksHandler extends BaseProvider implements SingleCompletionHa usage: { inputTokens?: number outputTokens?: number + totalInputTokens?: number + totalOutputTokens?: number + cachedInputTokens?: number + reasoningTokens?: number + inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } + outputTokenDetails?: { reasoningTokens?: number } details?: { cachedInputTokens?: number reasoningTokens?: number @@ -83,17 +90,27 @@ export class FireworksHandler extends BaseProvider implements SingleCompletionHa } }, ): ApiStreamUsageChunk { - // Extract cache metrics from Fireworks' providerMetadata if available - const cacheReadTokens = providerMetadata?.fireworks?.promptCacheHitTokens ?? usage.details?.cachedInputTokens - const cacheWriteTokens = providerMetadata?.fireworks?.promptCacheMissTokens + // Extract cache metrics from Fireworks' providerMetadata, then v6 fields, then legacy + const cacheReadTokens = + providerMetadata?.fireworks?.promptCacheHitTokens ?? + usage.cachedInputTokens ?? + usage.inputTokenDetails?.cacheReadTokens ?? + usage.details?.cachedInputTokens + const cacheWriteTokens = + providerMetadata?.fireworks?.promptCacheMissTokens ?? usage.inputTokenDetails?.cacheWriteTokens + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 return { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, + inputTokens, + outputTokens, cacheReadTokens, cacheWriteTokens, - reasoningTokens: usage.details?.reasoningTokens, + reasoningTokens: + usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, } } @@ -122,11 +139,12 @@ export class FireworksHandler extends BaseProvider implements SingleCompletionHa // Convert tools to OpenAI format first, then to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) // Build the request options const requestOptions: Parameters[0] = { model: languageModel, - system: systemPrompt, + system: systemPrompt || undefined, messages: aiSdkMessages, temperature: this.options.modelTemperature ?? temperature ?? FIREWORKS_DEFAULT_TEMPERATURE, maxOutputTokens: this.getMaxOutputTokens(), diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index b7aebf409c..50518b0b3b 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -21,6 +21,7 @@ import { handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" +import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { t } from "i18next" import type { ApiStream, ApiStreamUsageChunk, GroundingSource } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -103,6 +104,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl } const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) // Build tool choice - use 'required' when allowedFunctionNames restricts available tools const toolChoice = @@ -113,7 +115,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl // Build the request options const requestOptions: Parameters[0] = { model: this.provider(modelId), - system: systemInstruction, + system: systemInstruction || undefined, messages: aiSdkMessages, temperature: temperatureConfig, maxOutputTokens, @@ -246,6 +248,12 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl usage: { inputTokens?: number outputTokens?: number + totalInputTokens?: number + totalOutputTokens?: number + cachedInputTokens?: number + reasoningTokens?: number + inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } + outputTokenDetails?: { reasoningTokens?: number } details?: { cachedInputTokens?: number reasoningTokens?: number @@ -256,8 +264,10 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl ): ApiStreamUsageChunk { const inputTokens = usage.inputTokens || 0 const outputTokens = usage.outputTokens || 0 - const cacheReadTokens = usage.details?.cachedInputTokens - const reasoningTokens = usage.details?.reasoningTokens + const cacheReadTokens = + usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens ?? usage.details?.cachedInputTokens + const reasoningTokens = + usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens return { type: "usage", @@ -272,6 +282,9 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl cacheReadTokens, reasoningTokens, }), + // Gemini: inputTokens is already total + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, } } diff --git a/src/api/providers/lite-llm.ts b/src/api/providers/lite-llm.ts index 8bca632f4c..bcfbedd408 100644 --- a/src/api/providers/lite-llm.ts +++ b/src/api/providers/lite-llm.ts @@ -96,18 +96,30 @@ export class LiteLLMHandler extends OpenAICompatibleHandler implements SingleCom protected override processUsageMetrics(usage: { inputTokens?: number outputTokens?: number + totalInputTokens?: number + totalOutputTokens?: number + cachedInputTokens?: number + reasoningTokens?: number + inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } + outputTokenDetails?: { reasoningTokens?: number } details?: { cachedInputTokens?: number reasoningTokens?: number } raw?: Record }): ApiStreamUsageChunk { + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 return { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, - cacheReadTokens: usage.details?.cachedInputTokens, - reasoningTokens: usage.details?.reasoningTokens, + inputTokens, + outputTokens, + cacheReadTokens: + usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens ?? usage.details?.cachedInputTokens, + reasoningTokens: + usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, } } } diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts index 751c4ccde4..9731174f54 100644 --- a/src/api/providers/lm-studio.ts +++ b/src/api/providers/lm-studio.ts @@ -20,6 +20,7 @@ import { mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" +import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream } from "../transform/stream" import { OpenAICompatibleHandler, OpenAICompatibleConfig } from "./openai-compatible" @@ -68,10 +69,11 @@ export class LmStudioHandler extends OpenAICompatibleHandler implements SingleCo const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) const requestOptions: Parameters[0] = { model: languageModel, - system: systemPrompt, + system: systemPrompt || undefined, messages: aiSdkMessages, temperature: model.temperature ?? this.config.temperature ?? LMSTUDIO_DEFAULT_TEMPERATURE, maxOutputTokens: this.getMaxOutputTokens(), diff --git a/src/api/providers/minimax.ts b/src/api/providers/minimax.ts index 17b0055e4e..4bb62f73af 100644 --- a/src/api/providers/minimax.ts +++ b/src/api/providers/minimax.ts @@ -16,6 +16,7 @@ import { handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" +import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { calculateApiCostAnthropic } from "../../shared/cost" import { DEFAULT_HEADERS } from "./constants" @@ -75,6 +76,7 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand const aiSdkMessages = mergedMessages as ModelMessage[] const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) const anthropicProviderOptions: Record = {} @@ -89,29 +91,9 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand anthropicProviderOptions.disableParallelToolUse = true } - const cacheProviderOption = { anthropic: { cacheControl: { type: "ephemeral" as const } } } - const userMsgIndices = mergedMessages.reduce( - (acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), - [] as number[], - ) - - const targetIndices = new Set() - const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1 - const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 - - if (lastUserMsgIndex >= 0) targetIndices.add(lastUserMsgIndex) - if (secondLastUserMsgIndex >= 0) targetIndices.add(secondLastUserMsgIndex) - - if (targetIndices.size > 0) { - this.applyCacheControlToAiSdkMessages(aiSdkMessages, targetIndices, cacheProviderOption) - } - const requestOptions = { model: this.client(modelConfig.id), - system: systemPrompt, - ...({ - systemProviderOptions: { anthropic: { cacheControl: { type: "ephemeral" } } }, - } as Record), + system: systemPrompt || undefined, messages: aiSdkMessages, temperature: modelParams.temperature, maxOutputTokens: modelParams.maxTokens ?? modelConfig.info.maxTokens, @@ -184,21 +166,9 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, totalCost, - } - } - - private applyCacheControlToAiSdkMessages( - aiSdkMessages: { role: string; providerOptions?: Record> }[], - targetIndices: Set, - cacheProviderOption: Record>, - ): void { - for (const idx of targetIndices) { - if (idx >= 0 && idx < aiSdkMessages.length) { - aiSdkMessages[idx].providerOptions = { - ...aiSdkMessages[idx].providerOptions, - ...cacheProviderOption, - } - } + // MiniMax uses Anthropic SDK: inputTokens is non-cached only + totalInputTokens: inputTokens + (cacheWriteTokens ?? 0) + (cacheReadTokens ?? 0), + totalOutputTokens: outputTokens, } } diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index e11ac0a79e..aa77b3a762 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -13,6 +13,7 @@ import { import type { ApiHandlerOptions } from "../../shared/api" import { convertToAiSdkMessages, convertToolsForAiSdk, consumeAiSdkStream, handleAiSdkError } from "../transform/ai-sdk" +import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -76,17 +77,29 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand protected processUsageMetrics(usage: { inputTokens?: number outputTokens?: number + totalInputTokens?: number + totalOutputTokens?: number + cachedInputTokens?: number + reasoningTokens?: number + inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } + outputTokenDetails?: { reasoningTokens?: number } details?: { cachedInputTokens?: number reasoningTokens?: number } }): ApiStreamUsageChunk { + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 return { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, - cacheReadTokens: usage.details?.cachedInputTokens, - reasoningTokens: usage.details?.reasoningTokens, + inputTokens, + outputTokens, + cacheReadTokens: + usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens ?? usage.details?.cachedInputTokens, + reasoningTokens: + usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, } } @@ -149,12 +162,13 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand // Convert tools to OpenAI format first, then to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) // Build the request options // Use MISTRAL_DEFAULT_TEMPERATURE (1) as fallback to match original behavior const requestOptions: Parameters[0] = { model: languageModel, - system: systemPrompt, + system: systemPrompt || undefined, messages: aiSdkMessages, temperature: this.options.modelTemperature ?? MISTRAL_DEFAULT_TEMPERATURE, maxOutputTokens: this.getMaxOutputTokens(), diff --git a/src/api/providers/moonshot.ts b/src/api/providers/moonshot.ts index 3e90e48f7a..6c314a0f70 100644 --- a/src/api/providers/moonshot.ts +++ b/src/api/providers/moonshot.ts @@ -46,6 +46,12 @@ export class MoonshotHandler extends OpenAICompatibleHandler { protected override processUsageMetrics(usage: { inputTokens?: number outputTokens?: number + totalInputTokens?: number + totalOutputTokens?: number + cachedInputTokens?: number + reasoningTokens?: number + inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } + outputTokenDetails?: { reasoningTokens?: number } details?: { cachedInputTokens?: number reasoningTokens?: number @@ -55,12 +61,20 @@ export class MoonshotHandler extends OpenAICompatibleHandler { // Moonshot uses cached_tokens at the top level of raw usage data const rawUsage = usage.raw as { cached_tokens?: number } | undefined + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 return { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, - cacheWriteTokens: 0, - cacheReadTokens: rawUsage?.cached_tokens ?? usage.details?.cachedInputTokens, + inputTokens, + outputTokens, + cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens ?? 0, + cacheReadTokens: + rawUsage?.cached_tokens ?? + usage.cachedInputTokens ?? + usage.inputTokenDetails?.cacheReadTokens ?? + usage.details?.cachedInputTokens, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, } } diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index f1da6a01bd..25b7070cbe 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -14,6 +14,7 @@ import { handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" +import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream } from "../transform/stream" import { BaseProvider } from "./base-provider" @@ -99,12 +100,13 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) const providerOptions = this.buildProviderOptions(useR1Format) const requestOptions: Parameters[0] = { model: languageModel, - system: systemPrompt, + system: systemPrompt || undefined, messages: aiSdkMessages, temperature, tools: aiSdkTools, @@ -123,10 +125,14 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio const usage = await result.usage if (usage) { + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 yield { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, + inputTokens, + outputTokens, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, } } diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index d96e744661..e9720049d1 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -252,14 +252,27 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion if (usage) { const inputTokens = usage.inputTokens || 0 const outputTokens = usage.outputTokens || 0 - const details = (usage as any).details as - | { cachedInputTokens?: number; reasoningTokens?: number } - | undefined - const cacheReadTokens = details?.cachedInputTokens ?? 0 + const typedUsage = usage as { + inputTokens?: number + outputTokens?: number + cachedInputTokens?: number + reasoningTokens?: number + inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } + outputTokenDetails?: { reasoningTokens?: number } + details?: { cachedInputTokens?: number; reasoningTokens?: number } + } + const cacheReadTokens = + typedUsage.cachedInputTokens ?? + typedUsage.inputTokenDetails?.cacheReadTokens ?? + typedUsage.details?.cachedInputTokens ?? + 0 // The OpenAI Responses API does not report cache write tokens separately; // only cached (read) tokens are available via usage.details.cachedInputTokens. - const cacheWriteTokens = 0 - const reasoningTokens = details?.reasoningTokens + const cacheWriteTokens = typedUsage.inputTokenDetails?.cacheWriteTokens ?? 0 + const reasoningTokens = + typedUsage.reasoningTokens ?? + typedUsage.outputTokenDetails?.reasoningTokens ?? + typedUsage.details?.reasoningTokens yield { type: "usage", @@ -269,6 +282,8 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion cacheReadTokens: cacheReadTokens || undefined, ...(typeof reasoningTokens === "number" ? { reasoningTokens } : {}), totalCost: 0, // Subscription-based pricing + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, } } } catch (usageError) { diff --git a/src/api/providers/openai-compatible.ts b/src/api/providers/openai-compatible.ts index 56ca670756..3deeba6cf9 100644 --- a/src/api/providers/openai-compatible.ts +++ b/src/api/providers/openai-compatible.ts @@ -20,6 +20,7 @@ import { handleAiSdkError, } from "../transform/ai-sdk" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" +import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" @@ -95,18 +96,40 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si protected processUsageMetrics(usage: { inputTokens?: number outputTokens?: number + totalInputTokens?: number + totalOutputTokens?: number + cachedInputTokens?: number + reasoningTokens?: number + inputTokenDetails?: { + cacheReadTokens?: number + cacheWriteTokens?: number + noCacheTokens?: number + } + outputTokenDetails?: { + reasoningTokens?: number + } details?: { cachedInputTokens?: number reasoningTokens?: number } raw?: Record }): ApiStreamUsageChunk { + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 return { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, - cacheReadTokens: usage.details?.cachedInputTokens, - reasoningTokens: usage.details?.reasoningTokens, + inputTokens, + outputTokens, + // P1: AI SDK v6 top-level + // P2: AI SDK v6 structured (LanguageModelInputTokenDetails) + // P3: Legacy AI SDK standard (usage.details) + cacheReadTokens: + usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens ?? usage.details?.cachedInputTokens, + cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens, + reasoningTokens: + usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, } } @@ -137,11 +160,12 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si // Convert tools to OpenAI format first, then to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) // Build the request options const requestOptions: Parameters[0] = { model: languageModel, - system: systemPrompt, + system: systemPrompt || undefined, messages: aiSdkMessages, temperature: model.temperature ?? this.config.temperature ?? 0, maxOutputTokens: this.getMaxOutputTokens(), diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index c966cc3537..5bc7ae8382 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -26,6 +26,7 @@ import { mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" +import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -344,6 +345,12 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio usage: { inputTokens?: number outputTokens?: number + totalInputTokens?: number + totalOutputTokens?: number + cachedInputTokens?: number + reasoningTokens?: number + inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } + outputTokenDetails?: { reasoningTokens?: number } details?: { cachedInputTokens?: number reasoningTokens?: number @@ -355,11 +362,13 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio const inputTokens = usage.inputTokens || 0 const outputTokens = usage.outputTokens || 0 - const cacheReadTokens = usage.details?.cachedInputTokens ?? 0 + const cacheReadTokens = + usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens ?? usage.details?.cachedInputTokens ?? 0 // The OpenAI Responses API does not report cache write tokens separately; // only cached (read) tokens are available via usage.details.cachedInputTokens. - const cacheWriteTokens = 0 - const reasoningTokens = usage.details?.reasoningTokens + const cacheWriteTokens = usage.inputTokenDetails?.cacheWriteTokens ?? 0 + const reasoningTokens = + usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens const effectiveTier = this.lastServiceTier || (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined @@ -381,6 +390,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio cacheReadTokens: cacheReadTokens || undefined, ...(typeof reasoningTokens === "number" ? { reasoningTokens } : {}), totalCost, + // OpenAI: inputTokens is already total + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, } } @@ -434,6 +446,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) const taskId = metadata?.taskId const userAgent = `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}` diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index a6483ad6aa..c6b3ec489c 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -24,6 +24,7 @@ import { handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" +import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -110,6 +111,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) let effectiveSystemPrompt: string | undefined = systemPrompt let effectiveTemperature: number | undefined = @@ -141,7 +143,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl if (deepseekReasoner) { effectiveSystemPrompt = undefined - aiSdkMessages.unshift({ role: "user", content: systemPrompt }) + if (systemPrompt) { + aiSdkMessages.unshift({ role: "user", content: systemPrompt }) + } } if (this.options.openAiStreamingEnabled ?? true) { @@ -181,7 +185,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl ): ApiStream { const result = streamText({ model: languageModel, - system: systemPrompt, + system: systemPrompt || undefined, messages, temperature, maxOutputTokens: this.getMaxOutputTokens(), @@ -253,7 +257,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl try { const { text, toolCalls, usage, providerMetadata } = await generateText({ model: languageModel, - system: systemPrompt, + system: systemPrompt || undefined, messages, temperature, maxOutputTokens: this.getMaxOutputTokens(), @@ -290,6 +294,12 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl usage: { inputTokens?: number outputTokens?: number + totalInputTokens?: number + totalOutputTokens?: number + cachedInputTokens?: number + reasoningTokens?: number + inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } + outputTokenDetails?: { reasoningTokens?: number } details?: { cachedInputTokens?: number reasoningTokens?: number @@ -304,16 +314,28 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl }, ): ApiStreamUsageChunk { // Extract cache and reasoning metrics from OpenAI's providerMetadata when available, - // falling back to usage.details for standard AI SDK fields. - const cacheReadTokens = providerMetadata?.openai?.cachedPromptTokens ?? usage.details?.cachedInputTokens - const reasoningTokens = providerMetadata?.openai?.reasoningTokens ?? usage.details?.reasoningTokens + // then v6 fields, then legacy usage.details. + const cacheReadTokens = + providerMetadata?.openai?.cachedPromptTokens ?? + usage.cachedInputTokens ?? + usage.inputTokenDetails?.cacheReadTokens ?? + usage.details?.cachedInputTokens + const reasoningTokens = + providerMetadata?.openai?.reasoningTokens ?? + usage.reasoningTokens ?? + usage.outputTokenDetails?.reasoningTokens ?? + usage.details?.reasoningTokens + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 return { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, + inputTokens, + outputTokens, cacheReadTokens, reasoningTokens, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, } } diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 8462a4ac03..f9a1c685a3 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -23,6 +23,7 @@ import { processAiSdkStreamPart, yieldResponseMessage, } from "../transform/ai-sdk" +import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" import { BaseProvider } from "./base-provider" import { getModels, getModelsFromCache } from "./fetchers/modelCache" @@ -125,6 +126,9 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH ...(cacheReadTokens > 0 ? { cacheReadTokens } : {}), ...(typeof reasoningTokens === "number" && reasoningTokens > 0 ? { reasoningTokens } : {}), totalCost, + // OpenRouter uses OpenAI convention: inputTokens is already total + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, } } @@ -153,6 +157,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH const openrouter = this.createOpenRouterProvider({ reasoning, headers }) const tools = convertToolsForAiSdk(metadata?.tools) + applyToolCacheOptions(tools as Parameters[0], metadata?.toolProviderOptions) const providerOptions: | { @@ -174,10 +179,18 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } : undefined + // Breakpoint 1: System prompt caching — inject as cached system message + // OpenRouter routes to Anthropic models that benefit from cache annotations + const effectiveSystemPrompt = applySystemPromptCaching( + systemPrompt, + aiSdkMessages, + metadata?.systemProviderOptions, + ) + try { const result = streamText({ model: openrouter.chat(modelId), - system: systemPrompt, + system: effectiveSystemPrompt, messages: aiSdkMessages, maxOutputTokens: maxTokens && maxTokens > 0 ? maxTokens : undefined, temperature, diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 29a43acb9b..4bc05751aa 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -14,6 +14,7 @@ import { mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" +import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -140,6 +141,12 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan usage: { inputTokens?: number outputTokens?: number + totalInputTokens?: number + totalOutputTokens?: number + cachedInputTokens?: number + reasoningTokens?: number + inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } + outputTokenDetails?: { reasoningTokens?: number } details?: { cachedInputTokens?: number reasoningTokens?: number @@ -150,8 +157,14 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan ): ApiStreamUsageChunk { const inputTokens = usage.inputTokens || 0 const outputTokens = usage.outputTokens || 0 - const cacheWriteTokens = providerMetadata?.requesty?.usage?.cachingTokens ?? 0 - const cacheReadTokens = providerMetadata?.requesty?.usage?.cachedTokens ?? usage.details?.cachedInputTokens ?? 0 + const cacheWriteTokens = + providerMetadata?.requesty?.usage?.cachingTokens ?? usage.inputTokenDetails?.cacheWriteTokens ?? 0 + const cacheReadTokens = + providerMetadata?.requesty?.usage?.cachedTokens ?? + usage.cachedInputTokens ?? + usage.inputTokenDetails?.cacheReadTokens ?? + usage.details?.cachedInputTokens ?? + 0 const { totalCost } = modelInfo ? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) @@ -163,8 +176,11 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan outputTokens, cacheWriteTokens, cacheReadTokens, - reasoningTokens: usage.details?.reasoningTokens, + reasoningTokens: + usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens, totalCost, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, } } @@ -183,12 +199,21 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) const requestyOptions = this.getRequestyProviderOptions(metadata) + // Breakpoint 1: System prompt caching — inject as cached system message + // Requesty routes to Anthropic models that benefit from cache annotations + const effectiveSystemPrompt = applySystemPromptCaching( + systemPrompt, + aiSdkMessages, + metadata?.systemProviderOptions, + ) + const requestOptions: Parameters[0] = { model: languageModel, - system: systemPrompt, + system: effectiveSystemPrompt, messages: aiSdkMessages, temperature: this.options.modelTemperature ?? temperature ?? 0, maxOutputTokens: this.getMaxOutputTokens(), diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index 9e52de109e..e426213a62 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -17,6 +17,7 @@ import { mapToolChoice, yieldResponseMessage, } from "../transform/ai-sdk" +import { applyToolCacheOptions } from "../transform/cache-breakpoints" import type { RooReasoningParams } from "../transform/reasoning" import { getRooReasoning } from "../transform/reasoning" @@ -27,6 +28,40 @@ import { generateImageWithProvider, generateImageWithImagesApi, ImageGenerationR import { t } from "../../i18n" import type { RooMessage } from "../../core/task-persistence/rooMessage" +type RooProviderMetadata = { + cost?: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number + cached_tokens?: number +} + +type AnthropicProviderMetadata = { + cacheCreationInputTokens?: number + cacheReadInputTokens?: number + usage?: { + cache_read_input_tokens?: number + } +} + +type GatewayProviderMetadata = { + cost?: number + cache_creation_input_tokens?: number + cached_tokens?: number +} + +type UsageWithCache = { + inputTokens?: number + outputTokens?: number + cachedInputTokens?: number + inputTokenDetails?: { + cacheReadTokens?: number + cacheWriteTokens?: number + } + details?: { + cachedInputTokens?: number + } +} + function getSessionToken(): string { const token = CloudService.hasInstance() ? CloudService.instance.authService?.getSessionToken() : undefined return token ?? "unauthenticated" @@ -93,6 +128,8 @@ export class RooHandler extends BaseProvider implements SingleCompletionHandler messages: RooMessage[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { + const firstNumber = (...values: Array) => values.find((value) => typeof value === "number") + const model = this.getModel() const { id: modelId, info } = model @@ -122,13 +159,14 @@ export class RooHandler extends BaseProvider implements SingleCompletionHandler // RooMessage[] is already AI SDK-compatible, cast directly const aiSdkMessages = messages as ModelMessage[] const tools = convertToolsForAiSdk(this.convertToolsForOpenAI(metadata?.tools)) + applyToolCacheOptions(tools as Parameters[0], metadata?.toolProviderOptions) let lastStreamError: string | undefined try { const result = streamText({ model: provider(modelId), - system: systemPrompt, + system: systemPrompt || undefined, messages: aiSdkMessages, maxOutputTokens: maxTokens && maxTokens > 0 ? maxTokens : undefined, temperature, @@ -146,18 +184,40 @@ export class RooHandler extends BaseProvider implements SingleCompletionHandler } // Check provider metadata for usage details - const providerMetadata = - (await result.providerMetadata) ?? (await (result as any).experimental_providerMetadata) - const rooMeta = providerMetadata?.roo as Record | undefined + const providerMetadata = (await result.providerMetadata) ?? undefined + const experimentalProviderMetadata = await ( + result as { experimental_providerMetadata?: Promise | undefined> } + ).experimental_providerMetadata + const metadataWithFallback = providerMetadata ?? experimentalProviderMetadata + const rooMeta = metadataWithFallback?.roo as RooProviderMetadata | undefined + const anthropicMeta = metadataWithFallback?.anthropic as AnthropicProviderMetadata | undefined + const gatewayMeta = metadataWithFallback?.gateway as GatewayProviderMetadata | undefined // Process usage with protocol-aware normalization - const usage = await result.usage + const usage = (await result.usage) as UsageWithCache const promptTokens = usage.inputTokens ?? 0 const completionTokens = usage.outputTokens ?? 0 - // Extract cache tokens from provider metadata - const cacheCreation = (rooMeta?.cache_creation_input_tokens as number) ?? 0 - const cacheRead = (rooMeta?.cache_read_input_tokens as number) ?? (rooMeta?.cached_tokens as number) ?? 0 + // Extract cache tokens with priority chain (no double counting): + // Roo metadata -> Anthropic metadata -> Gateway metadata -> AI SDK usage -> legacy usage.details -> 0 + const cacheCreation = + firstNumber( + rooMeta?.cache_creation_input_tokens, + anthropicMeta?.cacheCreationInputTokens, + gatewayMeta?.cache_creation_input_tokens, + usage.inputTokenDetails?.cacheWriteTokens, + ) ?? 0 + const cacheRead = + firstNumber( + rooMeta?.cache_read_input_tokens, + rooMeta?.cached_tokens, + anthropicMeta?.cacheReadInputTokens, + anthropicMeta?.usage?.cache_read_input_tokens, + gatewayMeta?.cached_tokens, + usage.cachedInputTokens, + usage.inputTokenDetails?.cacheReadTokens, + usage.details?.cachedInputTokens, + ) ?? 0 // Protocol-aware token normalization: // - OpenAI protocol expects TOTAL input tokens (cached + non-cached) @@ -168,7 +228,7 @@ export class RooHandler extends BaseProvider implements SingleCompletionHandler // Cost: prefer server-side cost, fall back to client-side calculation const isFreeModel = info.isFree === true - const serverCost = rooMeta?.cost as number | undefined + const serverCost = firstNumber(rooMeta?.cost, gatewayMeta?.cost) const { totalCost: calculatedCost } = calculateApiCostOpenAI( info, promptTokens, @@ -185,6 +245,9 @@ export class RooHandler extends BaseProvider implements SingleCompletionHandler cacheWriteTokens: cacheCreation, cacheReadTokens: cacheRead, totalCost, + // Roo: promptTokens is always the server-reported total regardless of protocol normalization + totalInputTokens: promptTokens, + totalOutputTokens: completionTokens, } yield* yieldResponseMessage(result) diff --git a/src/api/providers/sambanova.ts b/src/api/providers/sambanova.ts index 8f12d757ca..456d2b6775 100644 --- a/src/api/providers/sambanova.ts +++ b/src/api/providers/sambanova.ts @@ -14,6 +14,7 @@ import { handleAiSdkError, flattenAiSdkMessagesToStringContent, } from "../transform/ai-sdk" +import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -72,6 +73,12 @@ export class SambaNovaHandler extends BaseProvider implements SingleCompletionHa usage: { inputTokens?: number outputTokens?: number + totalInputTokens?: number + totalOutputTokens?: number + cachedInputTokens?: number + reasoningTokens?: number + inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } + outputTokenDetails?: { reasoningTokens?: number } details?: { cachedInputTokens?: number reasoningTokens?: number @@ -84,17 +91,27 @@ export class SambaNovaHandler extends BaseProvider implements SingleCompletionHa } }, ): ApiStreamUsageChunk { - // Extract cache metrics from SambaNova's providerMetadata if available - const cacheReadTokens = providerMetadata?.sambanova?.promptCacheHitTokens ?? usage.details?.cachedInputTokens - const cacheWriteTokens = providerMetadata?.sambanova?.promptCacheMissTokens + // Extract cache metrics from SambaNova's providerMetadata, then v6 fields, then legacy + const cacheReadTokens = + providerMetadata?.sambanova?.promptCacheHitTokens ?? + usage.cachedInputTokens ?? + usage.inputTokenDetails?.cacheReadTokens ?? + usage.details?.cachedInputTokens + const cacheWriteTokens = + providerMetadata?.sambanova?.promptCacheMissTokens ?? usage.inputTokenDetails?.cacheWriteTokens + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 return { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, + inputTokens, + outputTokens, cacheReadTokens, cacheWriteTokens, - reasoningTokens: usage.details?.reasoningTokens, + reasoningTokens: + usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, } } @@ -125,11 +142,12 @@ export class SambaNovaHandler extends BaseProvider implements SingleCompletionHa // Convert tools to OpenAI format first, then to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) // Build the request options const requestOptions: Parameters[0] = { model: languageModel, - system: systemPrompt, + system: systemPrompt || undefined, messages: aiSdkMessages, temperature: this.options.modelTemperature ?? temperature ?? SAMBANOVA_DEFAULT_TEMPERATURE, maxOutputTokens: this.getMaxOutputTokens(), diff --git a/src/api/providers/vercel-ai-gateway.ts b/src/api/providers/vercel-ai-gateway.ts index ace066f962..4c9726860f 100644 --- a/src/api/providers/vercel-ai-gateway.ts +++ b/src/api/providers/vercel-ai-gateway.ts @@ -19,6 +19,7 @@ import { handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" +import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { DEFAULT_HEADERS } from "./constants" @@ -85,6 +86,12 @@ export class VercelAiGatewayHandler extends BaseProvider implements SingleComple usage: { inputTokens?: number outputTokens?: number + totalInputTokens?: number + totalOutputTokens?: number + cachedInputTokens?: number + reasoningTokens?: number + inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } + outputTokenDetails?: { reasoningTokens?: number } details?: { cachedInputTokens?: number reasoningTokens?: number @@ -94,17 +101,29 @@ export class VercelAiGatewayHandler extends BaseProvider implements SingleComple ): ApiStreamUsageChunk { const gatewayMeta = providerMetadata?.gateway as Record | undefined - const cacheWriteTokens = (gatewayMeta?.cache_creation_input_tokens as number) ?? undefined - const cacheReadTokens = usage.details?.cachedInputTokens ?? (gatewayMeta?.cached_tokens as number) ?? undefined + const cacheWriteTokens = + (gatewayMeta?.cache_creation_input_tokens as number) ?? + usage.inputTokenDetails?.cacheWriteTokens ?? + undefined + const cacheReadTokens = + usage.cachedInputTokens ?? + usage.inputTokenDetails?.cacheReadTokens ?? + usage.details?.cachedInputTokens ?? + (gatewayMeta?.cached_tokens as number) ?? + undefined const totalCost = (gatewayMeta?.cost as number) ?? 0 + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 return { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, + inputTokens, + outputTokens, cacheWriteTokens, cacheReadTokens, totalCost, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, } } @@ -120,6 +139,7 @@ export class VercelAiGatewayHandler extends BaseProvider implements SingleComple const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) const temperature = this.supportsTemperature(modelId) ? (this.options.modelTemperature ?? VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE) @@ -127,7 +147,7 @@ export class VercelAiGatewayHandler extends BaseProvider implements SingleComple const result = streamText({ model: languageModel, - system: systemPrompt, + system: systemPrompt || undefined, messages: aiSdkMessages, temperature, maxOutputTokens: info.maxTokens ?? undefined, diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index 4fb011bd68..c46b43ecb8 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -21,6 +21,7 @@ import { handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" +import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { t } from "i18next" import type { ApiStream, ApiStreamUsageChunk, GroundingSource } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -117,6 +118,7 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl } const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) // Build tool choice - use 'required' when allowedFunctionNames restricts available tools const toolChoice = @@ -127,7 +129,7 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl // Build the request options const requestOptions: Parameters[0] = { model: this.provider(modelId), - system: systemInstruction, + system: systemInstruction || undefined, messages: aiSdkMessages, temperature: temperatureConfig, maxOutputTokens, @@ -227,6 +229,12 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl usage: { inputTokens?: number outputTokens?: number + totalInputTokens?: number + totalOutputTokens?: number + cachedInputTokens?: number + reasoningTokens?: number + inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } + outputTokenDetails?: { reasoningTokens?: number } details?: { cachedInputTokens?: number reasoningTokens?: number @@ -237,8 +245,10 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl ): ApiStreamUsageChunk { const inputTokens = usage.inputTokens || 0 const outputTokens = usage.outputTokens || 0 - const cacheReadTokens = usage.details?.cachedInputTokens - const reasoningTokens = usage.details?.reasoningTokens + const cacheReadTokens = + usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens ?? usage.details?.cachedInputTokens + const reasoningTokens = + usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens return { type: "usage", @@ -253,6 +263,9 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl cacheReadTokens, reasoningTokens, }), + // Vertex: inputTokens is already total + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, } } diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index 448da873d3..50e8a47f5b 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -475,6 +475,8 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan type: "usage", inputTokens: totalInputTokens, outputTokens: totalOutputTokens, + totalInputTokens, + totalOutputTokens, } } catch (error: unknown) { this.ensureCleanState() diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index 88b17a9044..057fbecdb2 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -13,6 +13,7 @@ import { mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" +import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -81,6 +82,12 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler usage: { inputTokens?: number outputTokens?: number + totalInputTokens?: number + totalOutputTokens?: number + cachedInputTokens?: number + reasoningTokens?: number + inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } + outputTokenDetails?: { reasoningTokens?: number } details?: { cachedInputTokens?: number reasoningTokens?: number @@ -92,17 +99,25 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler } }, ): ApiStreamUsageChunk { - // Extract cache metrics from xAI's providerMetadata if available - // xAI supports prompt caching through prompt_tokens_details.cached_tokens - const cacheReadTokens = providerMetadata?.xai?.cachedPromptTokens ?? usage.details?.cachedInputTokens + // Extract cache metrics from xAI's providerMetadata, then v6 fields, then legacy + const cacheReadTokens = + providerMetadata?.xai?.cachedPromptTokens ?? + usage.cachedInputTokens ?? + usage.inputTokenDetails?.cacheReadTokens ?? + usage.details?.cachedInputTokens + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 return { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, + inputTokens, + outputTokens, cacheReadTokens, - cacheWriteTokens: undefined, // xAI doesn't report cache write tokens separately - reasoningTokens: usage.details?.reasoningTokens, + cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens, // xAI doesn't typically report cache write tokens + reasoningTokens: + usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, } } @@ -131,11 +146,12 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler // Convert tools to OpenAI format first, then to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) // Build the request options const requestOptions: Parameters[0] = { model: languageModel, - system: systemPrompt, + system: systemPrompt || undefined, messages: aiSdkMessages, temperature: this.options.modelTemperature ?? temperature ?? XAI_DEFAULT_TEMPERATURE, maxOutputTokens: this.getMaxOutputTokens(), diff --git a/src/api/providers/zai.ts b/src/api/providers/zai.ts index 6c7abb6c16..d052de842d 100644 --- a/src/api/providers/zai.ts +++ b/src/api/providers/zai.ts @@ -21,6 +21,7 @@ import { mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" +import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -102,10 +103,11 @@ export class ZAiHandler extends BaseProvider implements SingleCompletionHandler const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + applyToolCacheOptions(aiSdkTools as Parameters[0], metadata?.toolProviderOptions) const requestOptions: Parameters[0] = { model: languageModel, - system: systemPrompt, + system: systemPrompt || undefined, messages: aiSdkMessages, temperature: this.options.modelTemperature ?? temperature ?? ZAI_DEFAULT_TEMPERATURE, maxOutputTokens: this.getMaxOutputTokens(), @@ -113,8 +115,8 @@ export class ZAiHandler extends BaseProvider implements SingleCompletionHandler toolChoice: mapToolChoice(metadata?.tool_choice), } - // GLM-4.7 thinking mode: pass thinking parameter via providerOptions - const isThinkingModel = modelId === "glm-4.7" && Array.isArray(info.supportsReasoningEffort) + // Thinking mode: pass thinking parameter via providerOptions for models that support it (e.g. GLM-4.7, GLM-5) + const isThinkingModel = Array.isArray(info.supportsReasoningEffort) if (isThinkingModel) { const useReasoning = shouldUseReasoningEffort({ model: info, settings: this.options }) diff --git a/src/api/transform/__tests__/cache-breakpoints.spec.ts b/src/api/transform/__tests__/cache-breakpoints.spec.ts new file mode 100644 index 0000000000..c1b6c20701 --- /dev/null +++ b/src/api/transform/__tests__/cache-breakpoints.spec.ts @@ -0,0 +1,274 @@ +import { + applyCacheBreakpoints, + applyToolCacheOptions, + applySystemPromptCaching, + UNIVERSAL_CACHE_OPTIONS, +} from "../cache-breakpoints" + +type TestMessage = { role: string; providerOptions?: Record> } + +describe("UNIVERSAL_CACHE_OPTIONS", () => { + it("includes anthropic namespace with ephemeral cacheControl", () => { + expect(UNIVERSAL_CACHE_OPTIONS.anthropic).toEqual({ cacheControl: { type: "ephemeral" } }) + }) + + it("includes bedrock namespace with default cachePoint", () => { + expect(UNIVERSAL_CACHE_OPTIONS.bedrock).toEqual({ cachePoint: { type: "default" } }) + }) +}) + +describe("applyCacheBreakpoints", () => { + it("is a no-op for empty message array", () => { + const messages: TestMessage[] = [] + applyCacheBreakpoints(messages) + expect(messages).toEqual([]) + }) + + it("is a no-op when all messages are assistant or system", () => { + const messages: TestMessage[] = [{ role: "system" }, { role: "assistant" }, { role: "assistant" }] + applyCacheBreakpoints(messages) + expect(messages[0].providerOptions).toBeUndefined() + expect(messages[1].providerOptions).toBeUndefined() + expect(messages[2].providerOptions).toBeUndefined() + }) + + it("places 1 breakpoint on a single user message", () => { + const messages: TestMessage[] = [{ role: "user" }] + applyCacheBreakpoints(messages) + expect(messages[0].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + }) + + it("places 1 breakpoint on a single tool message", () => { + const messages: TestMessage[] = [{ role: "tool" }] + applyCacheBreakpoints(messages) + expect(messages[0].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + }) + + it("places 2 breakpoints on 2 user messages", () => { + const messages: TestMessage[] = [{ role: "user" }, { role: "user" }] + applyCacheBreakpoints(messages) + expect(messages[0].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + expect(messages[1].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + }) + + it("places 2 breakpoints on 2 tool messages", () => { + const messages: TestMessage[] = [{ role: "tool" }, { role: "tool" }] + applyCacheBreakpoints(messages) + expect(messages[0].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + expect(messages[1].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + }) + + it("targets last 2 non-assistant messages in a mixed conversation", () => { + const messages: TestMessage[] = [ + { role: "user" }, + { role: "assistant" }, + { role: "user" }, + { role: "assistant" }, + { role: "tool" }, + ] + applyCacheBreakpoints(messages) + // Last 2 non-assistant: index 2 (user) and index 4 (tool) + expect(messages[0].providerOptions).toBeUndefined() + expect(messages[1].providerOptions).toBeUndefined() + expect(messages[2].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + expect(messages[3].providerOptions).toBeUndefined() + expect(messages[4].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + }) + + it("targets indices 3 and 5 in [user, assistant, tool, user, assistant, tool]", () => { + const messages: TestMessage[] = [ + { role: "user" }, + { role: "assistant" }, + { role: "tool" }, + { role: "user" }, + { role: "assistant" }, + { role: "tool" }, + ] + applyCacheBreakpoints(messages) + expect(messages[0].providerOptions).toBeUndefined() + expect(messages[1].providerOptions).toBeUndefined() + expect(messages[2].providerOptions).toBeUndefined() + expect(messages[3].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + expect(messages[4].providerOptions).toBeUndefined() + expect(messages[5].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + }) + + it("never targets system messages", () => { + const messages: TestMessage[] = [{ role: "system" }, { role: "user" }, { role: "assistant" }, { role: "user" }] + applyCacheBreakpoints(messages) + expect(messages[0].providerOptions).toBeUndefined() + expect(messages[1].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + expect(messages[3].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + }) + + it("never targets assistant messages", () => { + const messages: TestMessage[] = [ + { role: "user" }, + { role: "assistant" }, + { role: "assistant" }, + { role: "user" }, + ] + applyCacheBreakpoints(messages) + expect(messages[1].providerOptions).toBeUndefined() + expect(messages[2].providerOptions).toBeUndefined() + }) + + it("preserves existing providerOptions via spread", () => { + const messages: TestMessage[] = [ + { + role: "user", + providerOptions: { + openai: { customField: "keep-me" }, + }, + }, + ] + applyCacheBreakpoints(messages) + expect(messages[0].providerOptions).toEqual({ + openai: { customField: "keep-me" }, + ...UNIVERSAL_CACHE_OPTIONS, + }) + }) + + it("adds anchor breakpoint at ~1/3 with useAnchor and enough messages", () => { + // 6 non-assistant messages (indices 0-5 in nonAssistantIndices) + // Anchor at floor(6/3) = index 2 in nonAssistantIndices -> messages index 4 + // Last 2: indices 10 and 8 + const messages: TestMessage[] = [ + { role: "user" }, // 0 - nonAssistant[0] + { role: "assistant" }, // 1 + { role: "user" }, // 2 - nonAssistant[1] + { role: "assistant" }, // 3 + { role: "user" }, // 4 - nonAssistant[2] <- anchor (floor(6/3)=2) + { role: "assistant" }, // 5 + { role: "user" }, // 6 - nonAssistant[3] + { role: "assistant" }, // 7 + { role: "user" }, // 8 - nonAssistant[4] <- last 2 + { role: "assistant" }, // 9 + { role: "user" }, // 10 - nonAssistant[5] <- last 2 + ] + applyCacheBreakpoints(messages, { useAnchor: true }) + + // Should have 3 breakpoints: indices 4, 8, 10 + expect(messages[4].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + expect(messages[8].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + expect(messages[10].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + + // Others should NOT have breakpoints + expect(messages[0].providerOptions).toBeUndefined() + expect(messages[2].providerOptions).toBeUndefined() + expect(messages[6].providerOptions).toBeUndefined() + }) + + it("does not add anchor when below anchorThreshold", () => { + const messages: TestMessage[] = [ + { role: "user" }, + { role: "assistant" }, + { role: "user" }, + { role: "assistant" }, + { role: "user" }, + ] + // 3 non-assistant messages, below default threshold of 5 + applyCacheBreakpoints(messages, { useAnchor: true }) + + // Last 2 only: indices 2 and 4 + expect(messages[0].providerOptions).toBeUndefined() + expect(messages[2].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + expect(messages[4].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + }) + + it("universal options include both anthropic and bedrock namespaces", () => { + const messages: TestMessage[] = [{ role: "user" }] + applyCacheBreakpoints(messages) + expect(messages[0].providerOptions).toHaveProperty("anthropic") + expect(messages[0].providerOptions).toHaveProperty("bedrock") + }) +}) + +describe("applyToolCacheOptions", () => { + it("should apply cache options only to the last tool to conserve breakpoints", () => { + const tools: Record< + string, + { providerOptions?: Record>; [key: string]: unknown } + > = { + tool1: { description: "test", parameters: {} }, + tool2: { description: "test2", parameters: {}, providerOptions: { existing: { key: "value" } } }, + } + const cacheOptions = { anthropic: { cacheControl: { type: "ephemeral" } } } + applyToolCacheOptions(tools, cacheOptions) + // Only the last tool (tool2) should receive cache options + expect(tools.tool1.providerOptions).toBeUndefined() + expect(tools.tool2.providerOptions).toEqual({ + existing: { key: "value" }, + anthropic: { cacheControl: { type: "ephemeral" } }, + }) + }) + + it("should handle undefined tools", () => { + expect(() => + applyToolCacheOptions(undefined, { anthropic: { cacheControl: { type: "ephemeral" } } }), + ).not.toThrow() + }) + + it("should handle undefined cacheOptions", () => { + const tools: Record< + string, + { providerOptions?: Record>; [key: string]: unknown } + > = { + tool1: { description: "test", parameters: {} }, + } + applyToolCacheOptions(tools, undefined) + expect(tools.tool1.providerOptions).toBeUndefined() + }) + + it("should handle empty tools object", () => { + const tools: Record< + string, + { providerOptions?: Record>; [key: string]: unknown } + > = {} + applyToolCacheOptions(tools, { anthropic: { cacheControl: { type: "ephemeral" } } }) + expect(tools).toEqual({}) + }) +}) + +describe("applySystemPromptCaching", () => { + it("injects system prompt as cached system message and returns undefined", () => { + const messages: TestMessage[] = [{ role: "user" }] + const result = applySystemPromptCaching("You are helpful", messages, UNIVERSAL_CACHE_OPTIONS) + expect(result).toBeUndefined() + expect(messages).toHaveLength(2) + expect(messages[0]).toEqual({ + role: "system", + content: "You are helpful", + providerOptions: UNIVERSAL_CACHE_OPTIONS, + }) + }) + + it("returns undefined (no system prompt) when systemPrompt is empty string", () => { + const messages: TestMessage[] = [{ role: "user" }] + const result = applySystemPromptCaching("", messages, UNIVERSAL_CACHE_OPTIONS) + expect(result).toBeUndefined() + expect(messages).toHaveLength(1) // no message injected + }) + + it("returns undefined when systemPrompt is undefined", () => { + const messages: TestMessage[] = [{ role: "user" }] + const result = applySystemPromptCaching(undefined, messages, UNIVERSAL_CACHE_OPTIONS) + expect(result).toBeUndefined() + expect(messages).toHaveLength(1) // no message injected + }) + + it("returns systemPrompt unchanged when cacheOptions is undefined", () => { + const messages: TestMessage[] = [{ role: "user" }] + const result = applySystemPromptCaching("You are helpful", messages, undefined) + expect(result).toBe("You are helpful") + expect(messages).toHaveLength(1) // no message injected + }) + + it("prepends system message before existing messages", () => { + const messages: TestMessage[] = [{ role: "user" }, { role: "assistant" }, { role: "user" }] + applySystemPromptCaching("System prompt", messages, UNIVERSAL_CACHE_OPTIONS) + expect(messages).toHaveLength(4) + expect(messages[0].role).toBe("system") + expect(messages[1].role).toBe("user") + }) +}) diff --git a/src/api/transform/cache-breakpoints.ts b/src/api/transform/cache-breakpoints.ts new file mode 100644 index 0000000000..9bb912f3f5 --- /dev/null +++ b/src/api/transform/cache-breakpoints.ts @@ -0,0 +1,126 @@ +/** + * Universal cache breakpoint options — contains ALL provider namespaces. + * AI SDK's `providerOptions` are namespaced: each provider ignores keys + * that don't match its namespace, so it's safe to include all of them. + */ +export const UNIVERSAL_CACHE_OPTIONS: Record> = { + anthropic: { cacheControl: { type: "ephemeral" } }, + bedrock: { cachePoint: { type: "default" } }, +} + +/** + * Optional targeting configuration for cache breakpoint placement. + */ +export interface CacheBreakpointTargeting { + /** Maximum number of message breakpoints to place. Default: 2 */ + maxBreakpoints?: number + /** Whether to add an anchor breakpoint at ~1/3 through the conversation. Default: false */ + useAnchor?: boolean + /** Minimum number of non-assistant messages before placing an anchor. Default: 5 */ + anchorThreshold?: number +} + +/** + * Apply cache breakpoints to AI SDK messages with ALL provider namespaces. + * + * 4-breakpoint strategy: + * 1. System prompt — passed as first message in messages[] with providerOptions + * 2. Tool definitions — handled externally via `toolProviderOptions` in `streamText()` + * 3-4. Last 2 non-assistant messages — this function handles these + * + * @param messages - The AI SDK message array (mutated in place) + * @param targeting - Optional targeting options (defaults: 2 breakpoints, no anchor) + */ +export function applyCacheBreakpoints( + messages: { role: string; providerOptions?: Record> }[], + targeting: CacheBreakpointTargeting = {}, +): void { + const { maxBreakpoints = 2, useAnchor = false, anchorThreshold = 5 } = targeting + + // 1. Collect non-assistant message indices (user | tool roles) + const nonAssistantIndices: number[] = [] + for (let i = 0; i < messages.length; i++) { + if (messages[i].role !== "assistant" && messages[i].role !== "system") { + nonAssistantIndices.push(i) + } + } + + if (nonAssistantIndices.length === 0) return + + // 2. Target last N non-assistant messages + const targetIndices = new Set() + for (let j = 0; j < maxBreakpoints && j < nonAssistantIndices.length; j++) { + targetIndices.add(nonAssistantIndices[nonAssistantIndices.length - 1 - j]) + } + + // 3. Optional anchor at ~1/3 point + if (useAnchor && nonAssistantIndices.length >= anchorThreshold) { + const anchorIdx = Math.floor(nonAssistantIndices.length / 3) + targetIndices.add(nonAssistantIndices[anchorIdx]) + } + + // 4. Apply UNIVERSAL cache options to targeted messages + for (const idx of targetIndices) { + if (idx >= 0 && idx < messages.length) { + messages[idx].providerOptions = { + ...messages[idx].providerOptions, + ...UNIVERSAL_CACHE_OPTIONS, + } + } + } +} + +/** + * Apply system prompt caching by injecting the system prompt as a cached + * system message at the front of the messages array. + * + * AI SDK v6 does not support `providerOptions` on the `system` string + * parameter. Cache-aware providers call this helper to convert the system + * prompt into a system message with `providerOptions` for cache control. + * + * Returns the effective system prompt to pass to `streamText()`: + * - `undefined` when caching was applied (prompt is now in messages[0]) + * - the original `systemPrompt` when no caching options were provided + * + * @param systemPrompt - The system prompt string + * @param messages - The AI SDK message array (mutated in place) + * @param cacheOptions - Provider-specific cache options (e.g. UNIVERSAL_CACHE_OPTIONS) + */ +export function applySystemPromptCaching( + systemPrompt: string | undefined, + messages: { role: string; content?: unknown; providerOptions?: Record> }[], + cacheOptions: Record> | undefined, +): string | undefined { + if (!systemPrompt || !cacheOptions) { + return systemPrompt || undefined + } + + messages.unshift({ + role: "system", + content: systemPrompt, + providerOptions: cacheOptions, + }) + + // Tell the caller not to also pass the system prompt via the `system:` parameter + return undefined +} + +/** + * Apply provider-specific cache options to AI SDK tool definitions. + * Breakpoint 2 of 4: tool definitions. + */ +export function applyToolCacheOptions( + tools: + | Record>; [key: string]: unknown }> + | undefined, + cacheOptions: Record> | undefined, +): void { + if (!tools || !cacheOptions) return + const keys = Object.keys(tools) + if (keys.length === 0) return + // Only stamp the LAST tool to conserve cache breakpoints (max 4 shared across + // messages and tools). Stamping every tool wastes breakpoints — the provider + // silently drops all but the first few. + const lastKey = keys[keys.length - 1] + tools[lastKey].providerOptions = { ...tools[lastKey].providerOptions, ...cacheOptions } +} diff --git a/src/api/transform/stream.ts b/src/api/transform/stream.ts index bae4f5164e..74e3ca582f 100644 --- a/src/api/transform/stream.ts +++ b/src/api/transform/stream.ts @@ -66,6 +66,10 @@ export interface ApiStreamUsageChunk { cacheReadTokens?: number reasoningTokens?: number totalCost?: number + /** Total input tokens including cache read/write tokens. Each provider computes this directly. */ + totalInputTokens?: number + /** Total output tokens. Each provider computes this directly. */ + totalOutputTokens?: number } export interface ApiStreamGroundingChunk { diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index c8b96e35e3..e0ea1383f1 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -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 - } - break - case "codebase_search": if (args.query !== undefined) { nativeArgs = { diff --git a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts index db0dc00de4..2c15e12069 100644 --- a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts +++ b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts @@ -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") } }) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts index 4440a340fb..6675f18ce8 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts @@ -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: { diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts index a12ea7a12c..7e224844c5 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts @@ -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 }), diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts index 29b4133f81..dc31c179c2 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts @@ -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: { diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 56f6288b23..98b4e37db3 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -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" @@ -61,7 +60,7 @@ import { sanitizeToolUseId } from "../../utils/tool-id" */ export async function presentAssistantMessage(cline: Task) { - if (cline.abort) { + if (cline.abort || cline.abandoned) { throw new Error(`[Task#presentAssistantMessage] task ${cline.taskId}.${cline.instanceId} aborted`) } @@ -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, @@ -978,6 +938,15 @@ export async function presentAssistantMessage(cline: Task) { // locked. cline.presentAssistantMessageLocked = false + // Early exit if task was aborted/abandoned during tool execution (e.g., new_task delegation). + // Prevents unhandled promise rejections from recursive calls hitting the abort check. + if (cline.abort || cline.abandoned) { + if (cline.didCompleteReadingStream) { + cline.userMessageContentReady = true + } + return + } + // NOTE: When tool is rejected, iterator stream is interrupted and it waits // for `userMessageContentReady` to be true. Future calls to present will // skip execution since `didRejectTool` and iterate until `contentIndex` is @@ -1005,7 +974,11 @@ export async function presentAssistantMessage(cline: Task) { if (cline.currentStreamingContentIndex < cline.assistantMessageContent.length) { // There are already more content blocks to stream, so we'll call // this function ourselves. - presentAssistantMessage(cline) + presentAssistantMessage(cline).catch((err) => { + if (!cline.abort) { + console.error("[presentAssistantMessage] Unhandled error:", err) + } + }) return } else { // CRITICAL FIX: If we're out of bounds and the stream is complete, set userMessageContentReady @@ -1018,7 +991,11 @@ export async function presentAssistantMessage(cline: Task) { // Block is partial, but the read stream may have finished. if (cline.presentAssistantMessageHasPendingUpdates) { - presentAssistantMessage(cline) + presentAssistantMessage(cline).catch((err) => { + if (!cline.abort) { + console.error("[presentAssistantMessage] Unhandled error:", err) + } + }) } } diff --git a/src/core/auto-approval/index.ts b/src/core/auto-approval/index.ts index 558d4476a8..c8293c2a79 100644 --- a/src/core/auto-approval/index.ts +++ b/src/core/auto-approval/index.ts @@ -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" } diff --git a/src/core/config/__tests__/CustomModesManager.yamlEdgeCases.spec.ts b/src/core/config/__tests__/CustomModesManager.yamlEdgeCases.spec.ts index 251a33d211..cad28ef94c 100644 --- a/src/core/config/__tests__/CustomModesManager.yamlEdgeCases.spec.ts +++ b/src/core/config/__tests__/CustomModesManager.yamlEdgeCases.spec.ts @@ -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", - }, - ]) }) }) }) diff --git a/src/core/config/__tests__/CustomModesSettings.spec.ts b/src/core/config/__tests__/CustomModesSettings.spec.ts index 32e7ed9cf4..186ef5aeba 100644 --- a/src/core/config/__tests__/CustomModesSettings.spec.ts +++ b/src/core/config/__tests__/CustomModesSettings.spec.ts @@ -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"]) + }) + }) }) diff --git a/src/core/config/__tests__/ModeConfig.spec.ts b/src/core/config/__tests__/ModeConfig.spec.ts index dbdd1a0f03..74cbc0c437 100644 --- a/src/core/config/__tests__/ModeConfig.spec.ts +++ b/src/core/config/__tests__/ModeConfig.spec.ts @@ -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) + }) + }) }) diff --git a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts index 74e000d36a..f05a5066fb 100644 --- a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts +++ b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts @@ -117,10 +117,6 @@ describe("getEnvironmentDetails", () => { deref: vi.fn().mockReturnValue(mockProvider), [Symbol.toStringTag]: "WeakRef", } as unknown as WeakRef, - 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.") - }) }) diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index 4de2e20e37..99b3951cd1 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -226,35 +226,6 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo details += `${modeDetails.name}\n` details += `${modelId}\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")) diff --git a/src/core/mentions/__tests__/index.spec.ts b/src/core/mentions/__tests__/index.spec.ts index 8f229c28b8..fa96a396dc 100644 --- a/src/core/mentions/__tests__/index.spec.ts +++ b/src/core/mentions/__tests__/index.spec.ts @@ -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('') - expect(result.text).toContain("# Example Content\n\nThis is the content.") - expect(result.text).toContain("") - }) - - 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('') - expect(result.text).toContain("# First Site") - expect(result.text).toContain('') - 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'") }) }) diff --git a/src/core/mentions/__tests__/processUserContentMentions.spec.ts b/src/core/mentions/__tests__/processUserContentMentions.spec.ts index 018a4f2a7e..0541c7d941 100644 --- a/src/core/mentions/__tests__/processUserContentMentions.spec.ts +++ b/src/core/mentions/__tests__/processUserContentMentions.spec.ts @@ -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: "Tool feedback", + }, + ] + + 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: Tool feedback", + }, + ], + }) + 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: "Array task", + }, + { + 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: Array task", + }, + { + 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( "Test default", "/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( "Test explicit false", "/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: "Tool output", + }, + ] + + 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: "Array item", + }, + ], + }, + ] + + 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", + }, + ], + }) + }) }) }) diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts index faa7236e67..d71317d649 100644 --- a/src/core/mentions/index.ts +++ b/src/core/mentions/index.ts @@ -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 { 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\n${result}\n` - } 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, diff --git a/src/core/mentions/processUserContentMentions.ts b/src/core/mentions/processUserContentMentions.ts index 92c4693e82..860bec53c0 100644 --- a/src/core/mentions/processUserContentMentions.ts +++ b/src/core/mentions/processUserContentMentions.ts @@ -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 + content: Array 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 + userContent: Array 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, mode: commandMode } + return { content: content as Array, mode: commandMode } } diff --git a/src/core/prompts/__tests__/add-custom-instructions.spec.ts b/src/core/prompts/__tests__/add-custom-instructions.spec.ts index f10a8bade5..640136de63 100644 --- a/src/core/prompts/__tests__/add-custom-instructions.spec.ts +++ b/src/core/prompts/__tests__/add-custom-instructions.spec.ts @@ -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, diff --git a/src/core/prompts/__tests__/system-prompt.spec.ts b/src/core/prompts/__tests__/system-prompt.spec.ts index 612783b3db..f555daba06 100644 --- a/src/core/prompts/__tests__/system-prompt.spec.ts +++ b/src/core/prompts/__tests__/system-prompt.spec.ts @@ -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 diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 0a187a9e2e..0d6071644a 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -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, diff --git a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts index acef6508f0..0b776a2bad 100644 --- a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts +++ b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts @@ -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") }) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 085a8af3e2..fdd41e7e33 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -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) diff --git a/src/core/prompts/tools/native-tools/browser_action.ts b/src/core/prompts/tools/native-tools/browser_action.ts deleted file mode 100644 index 0068373313..0000000000 --- a/src/core/prompts/tools/native-tools/browser_action.ts +++ /dev/null @@ -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 diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 48f1071e1b..758914d2d6 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -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, diff --git a/src/core/prompts/types.ts b/src/core/prompts/types.ts index ca10dc1277..a4c17c3a6e 100644 --- a/src/core/prompts/types.ts +++ b/src/core/prompts/types.ts @@ -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 diff --git a/src/core/task-persistence/__tests__/delegationMeta.spec.ts b/src/core/task-persistence/__tests__/delegationMeta.spec.ts new file mode 100644 index 0000000000..5f46c87f87 --- /dev/null +++ b/src/core/task-persistence/__tests__/delegationMeta.spec.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import * as os from "os" +import * as path from "path" +import * as fs from "fs/promises" + +// Mocks (use hoisted to avoid initialization ordering issues) +const hoisted = vi.hoisted(() => ({ + safeWriteJsonMock: vi.fn().mockResolvedValue(undefined), +})) +vi.mock("../../../utils/safeWriteJson", () => ({ + safeWriteJson: hoisted.safeWriteJsonMock, +})) + +// Import after mocks +import { readDelegationMeta, saveDelegationMeta } from "../delegationMeta" +import type { DelegationMeta } from "../delegationMeta" + +let tmpBaseDir: string + +beforeEach(async () => { + hoisted.safeWriteJsonMock.mockClear() + tmpBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), "roo-test-delegation-")) +}) + +describe("delegationMeta.readDelegationMeta", () => { + it("returns null when file does not exist (backward compat)", async () => { + const result = await readDelegationMeta({ + taskId: "task-no-file", + globalStoragePath: tmpBaseDir, + }) + + expect(result).toBeNull() + }) + + it("returns parsed delegation metadata from file", async () => { + const taskId = "task-with-meta" + const taskDir = path.join(tmpBaseDir, "tasks", taskId) + await fs.mkdir(taskDir, { recursive: true }) + const filePath = path.join(taskDir, "delegation_metadata.json") + + const meta: DelegationMeta = { + status: "delegated", + delegatedToId: "child-1", + awaitingChildId: "child-1", + childIds: ["child-1"], + } + await fs.writeFile(filePath, JSON.stringify(meta), "utf8") + + const result = await readDelegationMeta({ + taskId, + globalStoragePath: tmpBaseDir, + }) + + expect(result).toEqual(meta) + }) + + it("filters out unknown keys from parsed data", async () => { + const taskId = "task-extra-keys" + const taskDir = path.join(tmpBaseDir, "tasks", taskId) + await fs.mkdir(taskDir, { recursive: true }) + const filePath = path.join(taskDir, "delegation_metadata.json") + + await fs.writeFile( + filePath, + JSON.stringify({ + status: "active", + unknownField: "should-be-filtered", + childIds: ["c1"], + }), + "utf8", + ) + + const result = await readDelegationMeta({ + taskId, + globalStoragePath: tmpBaseDir, + }) + + expect(result).toEqual({ status: "active", childIds: ["c1"] }) + expect(result).not.toHaveProperty("unknownField") + }) + + it("returns null when file contains invalid JSON", async () => { + const taskId = "task-corrupt" + const taskDir = path.join(tmpBaseDir, "tasks", taskId) + await fs.mkdir(taskDir, { recursive: true }) + const filePath = path.join(taskDir, "delegation_metadata.json") + await fs.writeFile(filePath, "{not valid json!!!", "utf8") + + const result = await readDelegationMeta({ + taskId, + globalStoragePath: tmpBaseDir, + }) + + expect(result).toBeNull() + }) + + it("returns null when file contains a non-object value", async () => { + const taskId = "task-non-object" + const taskDir = path.join(tmpBaseDir, "tasks", taskId) + await fs.mkdir(taskDir, { recursive: true }) + const filePath = path.join(taskDir, "delegation_metadata.json") + await fs.writeFile(filePath, JSON.stringify("hello"), "utf8") + + const result = await readDelegationMeta({ + taskId, + globalStoragePath: tmpBaseDir, + }) + + expect(result).toBeNull() + }) + + it("returns null when file contains an array", async () => { + const taskId = "task-array" + const taskDir = path.join(tmpBaseDir, "tasks", taskId) + await fs.mkdir(taskDir, { recursive: true }) + const filePath = path.join(taskDir, "delegation_metadata.json") + await fs.writeFile(filePath, JSON.stringify([1, 2, 3]), "utf8") + + const result = await readDelegationMeta({ + taskId, + globalStoragePath: tmpBaseDir, + }) + + expect(result).toBeNull() + }) +}) + +describe("delegationMeta.saveDelegationMeta", () => { + it("writes delegation metadata via safeWriteJson", async () => { + const meta: DelegationMeta = { + status: "delegated", + delegatedToId: "child-1", + awaitingChildId: "child-1", + childIds: ["child-1"], + } + + await saveDelegationMeta({ + taskId: "task-1", + globalStoragePath: tmpBaseDir, + meta, + }) + + expect(hoisted.safeWriteJsonMock).toHaveBeenCalledTimes(1) + const [, persisted] = hoisted.safeWriteJsonMock.mock.calls[0] + expect(persisted).toEqual(meta) + }) + + it("filters out unknown keys before writing", async () => { + const meta = { + status: "active", + unknownField: "should-be-filtered", + childIds: ["c1"], + } as any + + await saveDelegationMeta({ + taskId: "task-2", + globalStoragePath: tmpBaseDir, + meta, + }) + + expect(hoisted.safeWriteJsonMock).toHaveBeenCalledTimes(1) + const [, persisted] = hoisted.safeWriteJsonMock.mock.calls[0] + expect(persisted).toEqual({ status: "active", childIds: ["c1"] }) + expect(persisted).not.toHaveProperty("unknownField") + }) + + it("writes to the correct file path", async () => { + await saveDelegationMeta({ + taskId: "task-path-check", + globalStoragePath: tmpBaseDir, + meta: { status: "completed" }, + }) + + expect(hoisted.safeWriteJsonMock).toHaveBeenCalledTimes(1) + const [filePath] = hoisted.safeWriteJsonMock.mock.calls[0] + expect(filePath).toContain(path.join("tasks", "task-path-check", "delegation_metadata.json")) + }) +}) diff --git a/src/core/task-persistence/delegationMeta.ts b/src/core/task-persistence/delegationMeta.ts new file mode 100644 index 0000000000..8a5dfac7d8 --- /dev/null +++ b/src/core/task-persistence/delegationMeta.ts @@ -0,0 +1,138 @@ +import { safeWriteJson } from "../../utils/safeWriteJson" +import * as path from "path" +import * as fs from "fs/promises" + +import { fileExistsAtPath } from "../../utils/fs" + +import { GlobalFileNames } from "../../shared/globalFileNames" +import { getTaskDirectoryPath } from "../../utils/storage" + +/** + * Delegation metadata stored per-task on disk. + * This is the cross-process-safe source of truth for delegation fields, + * complementing the monolithic globalState taskHistory array. + */ +export interface DelegationMeta { + status?: "active" | "delegated" | "completed" + delegatedToId?: string | null + awaitingChildId?: string | null + childIds?: string[] + completedByChildId?: string | null + completionResultSummary?: string | null +} + +/** + * Compile-time safeguard: this record must list exactly the keys of DelegationMeta. + * Adding or removing a key in the interface without updating this record + * will produce a TypeScript error, preventing silent drift. + */ +const _delegationMetaKeyRecord: Record, true> = { + status: true, + delegatedToId: true, + awaitingChildId: true, + childIds: true, + completedByChildId: true, + completionResultSummary: true, +} + +/** Known keys that may appear in a DelegationMeta object. */ +const DELEGATION_META_KEYS: ReadonlySet = new Set(Object.keys(_delegationMetaKeyRecord)) + +export type ReadDelegationMetaOptions = { + taskId: string + globalStoragePath: string +} + +/** + * Read delegation metadata from the per-task file. + * Returns `null` if the file doesn't exist (backward compat for old tasks). + */ +export async function readDelegationMeta({ + taskId, + globalStoragePath, +}: ReadDelegationMetaOptions): Promise { + const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId) + const filePath = path.join(taskDir, GlobalFileNames.delegationMetadata) + const fileExists = await fileExistsAtPath(filePath) + + if (!fileExists) { + return null + } + + try { + const raw = JSON.parse(await fs.readFile(filePath, "utf8")) + + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + console.warn( + `[readDelegationMeta] Parsed data is not an object (got ${typeof raw}), returning null. TaskId: ${taskId}, Path: ${filePath}`, + ) + return null + } + + // Only extract known delegation keys to prevent pollution + const meta: DelegationMeta = {} + + for (const key of Object.keys(raw)) { + if (DELEGATION_META_KEYS.has(key)) { + ;(meta as Record)[key] = raw[key] + } + } + + // Validate value types to prevent corrupted files from propagating invalid data + if ( + meta.status !== undefined && + meta.status !== null && + !["active", "delegated", "completed"].includes(meta.status as string) + ) { + delete (meta as Record).status + } + if (meta.childIds !== undefined && meta.childIds !== null && !Array.isArray(meta.childIds)) { + delete (meta as Record).childIds + } + for (const key of [ + "delegatedToId", + "awaitingChildId", + "completedByChildId", + "completionResultSummary", + ] as const) { + const val = meta[key] + if (val !== undefined && val !== null && typeof val !== "string") { + delete (meta as Record)[key] + } + } + + return meta + } catch (error) { + console.warn( + `[readDelegationMeta] Failed to parse ${filePath} for task ${taskId}, returning null: ${error instanceof Error ? error.message : String(error)}`, + ) + return null + } +} + +export type SaveDelegationMetaOptions = { + taskId: string + globalStoragePath: string + meta: DelegationMeta +} + +/** + * Save delegation metadata to the per-task file. + * Writes the complete delegation state (not a merge) via safeWriteJson + * which uses proper-lockfile for cross-process safety. + */ +export async function saveDelegationMeta({ taskId, globalStoragePath, meta }: SaveDelegationMetaOptions) { + const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId) + const filePath = path.join(taskDir, GlobalFileNames.delegationMetadata) + + // Only write known delegation keys to prevent pollution + const sanitized: DelegationMeta = {} + + for (const key of Object.keys(meta)) { + if (DELEGATION_META_KEYS.has(key)) { + ;(sanitized as Record)[key] = (meta as Record)[key] + } + } + + await safeWriteJson(filePath, sanitized) +} diff --git a/src/core/task-persistence/index.ts b/src/core/task-persistence/index.ts index 65e0cc1dc8..a26fb30de5 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -36,3 +36,4 @@ export { } from "./rooMessage" export { convertAnthropicToRooMessages } from "./converters/anthropicToRoo" export { flattenModelMessagesToStringContent } from "./messageUtils" +export { type DelegationMeta, readDelegationMeta, saveDelegationMeta } from "./delegationMeta" diff --git a/src/core/task-persistence/rooMessage.ts b/src/core/task-persistence/rooMessage.ts index f71d9d3998..4328ef7b92 100644 --- a/src/core/task-persistence/rooMessage.ts +++ b/src/core/task-persistence/rooMessage.ts @@ -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 & + 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 } diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index 4b77126971..bd4b6023c4 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -23,8 +23,6 @@ export type TaskMetadataOptions = { mode?: string /** Provider profile name for the task (sticky profile feature) */ apiConfigName?: string - /** Initial status for the task (e.g., "active" for child tasks) */ - initialStatus?: "active" | "delegated" | "completed" } export async function taskMetadata({ @@ -37,7 +35,6 @@ export async function taskMetadata({ workspace, mode, apiConfigName, - initialStatus, }: TaskMetadataOptions) { const taskDir = await getTaskDirectoryPath(globalStoragePath, id) @@ -90,9 +87,6 @@ export async function taskMetadata({ } // Create historyItem once with pre-calculated values. - // initialStatus is included when provided (e.g., "active" for child tasks) - // to ensure the status is set from the very first save, avoiding race conditions - // where attempt_completion might run before a separate status update. const historyItem: HistoryItem = { id, rootTaskId, @@ -111,7 +105,6 @@ export async function taskMetadata({ workspace, mode, ...(typeof apiConfigName === "string" && apiConfigName.length > 0 ? { apiConfigName } : {}), - ...(initialStatus && { status: initialStatus }), } return { historyItem, tokenUsage } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index ee9f15e4e3..0f0930c794 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -63,6 +63,7 @@ import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from ".. import type { AssistantModelMessage } from "ai" import { ApiStream, GroundingSource } from "../../api/transform/stream" import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" +import { applyCacheBreakpoints, UNIVERSAL_CACHE_OPTIONS } from "../../api/transform/cache-breakpoints" // shared import { findLastIndex } from "../../shared/array" @@ -71,13 +72,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" @@ -90,7 +89,6 @@ import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor" // utils -import { calculateApiCostAnthropic, calculateApiCostOpenAI } from "../../shared/cost" import { getWorkspacePath } from "../../utils/path" import { sanitizeToolUseId } from "../../utils/tool-id" import { getTaskDirectoryPath } from "../../utils/storage" @@ -183,8 +181,6 @@ export interface TaskOptions extends CreateTaskOptions { onCreated?: (task: Task) => void initialTodos?: TodoItem[] workspacePath?: string - /** Initial status for the task's history item (e.g., "active" for child tasks) */ - initialStatus?: "active" | "delegated" | "completed" } export class Task extends EventEmitter implements TaskLike { @@ -326,12 +322,8 @@ export class Task extends EventEmitter implements TaskLike { rooIgnoreController?: RooIgnoreController rooProtectedController?: RooProtectedController fileContextTracker: FileContextTracker - urlContentFetcher: UrlContentFetcher terminalProcess?: RooTerminalProcess - // Computer User - browserSession: BrowserSession - // Editing diffViewProvider: DiffViewProvider diffStrategy?: DiffStrategy @@ -472,7 +464,11 @@ export class Task extends EventEmitter implements TaskLike { // Add to content and present this.assistantMessageContent.push(partialToolUse) this.userMessageContentReady = false - presentAssistantMessage(this) + presentAssistantMessage(this).catch((err) => { + if (!this.abort) { + console.error("[presentAssistantMessage] Unhandled error:", err) + } + }) } else if (event.type === "tool_call_delta") { // Process chunk using streaming JSON parser const partialToolUse = NativeToolCallParser.processStreamingChunk(event.id, event.delta) @@ -488,7 +484,11 @@ export class Task extends EventEmitter implements TaskLike { this.assistantMessageContent[toolUseIndex] = partialToolUse // Present updated tool use - presentAssistantMessage(this) + presentAssistantMessage(this).catch((err) => { + if (!this.abort) { + console.error("[presentAssistantMessage] Unhandled error:", err) + } + }) } } } else if (event.type === "tool_call_end") { @@ -514,7 +514,11 @@ export class Task extends EventEmitter implements TaskLike { this.userMessageContentReady = false // Present the finalized tool call - presentAssistantMessage(this) + presentAssistantMessage(this).catch((err) => { + if (!this.abort) { + console.error("[presentAssistantMessage] Unhandled error:", err) + } + }) } else if (toolUseIndex !== undefined) { // finalizeStreamingToolCall returned null (malformed JSON or missing args) // Mark the tool as non-partial so it's presented as complete, but execution @@ -533,7 +537,11 @@ export class Task extends EventEmitter implements TaskLike { this.userMessageContentReady = false // Present the tool call - validation will handle missing params - presentAssistantMessage(this) + presentAssistantMessage(this).catch((err) => { + if (!this.abort) { + console.error("[presentAssistantMessage] Unhandled error:", err) + } + }) } } } @@ -568,9 +576,6 @@ export class Task extends EventEmitter implements TaskLike { // Cloud Sync Tracking private cloudSyncedMessageTimestamps: Set = new Set() - // Initial status for the task's history item (set at creation time to avoid race conditions) - private readonly initialStatus?: "active" | "delegated" | "completed" - // MessageManager for high-level message operations (lazy initialized) private _messageManager?: MessageManager @@ -592,7 +597,6 @@ export class Task extends EventEmitter implements TaskLike { onCreated, initialTodos, workspacePath, - initialStatus, }: TaskOptions) { super() @@ -644,29 +648,6 @@ export class Task extends EventEmitter 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 @@ -677,7 +658,6 @@ export class Task extends EventEmitter implements TaskLike { this.parentTask = parentTask this.taskNumber = taskNumber - this.initialStatus = initialStatus // Store the task's mode and API config name when it's created. // For history items, use the stored values; for new tasks, we'll set them @@ -749,15 +729,49 @@ export class Task extends EventEmitter implements TaskLike { if (startTask) { this._started = true if (task || images) { - this.startTask(task, images) + this.runLifecycleTaskInBackground(this.startTask(task, images), "startTask") } else if (historyItem) { - this.resumeTaskFromHistory() + this.runLifecycleTaskInBackground(this.resumeTaskFromHistory(), "resumeTaskFromHistory") } else { throw new Error("Either historyItem or task/images must be provided") } } } + private runLifecycleTaskInBackground(taskPromise: Promise, operation: "startTask" | "resumeTaskFromHistory") { + void taskPromise.catch((error) => { + if (this.shouldIgnoreBackgroundLifecycleError(error)) { + return + } + + console.error( + `[Task#${operation}] task ${this.taskId}.${this.instanceId} failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + }) + } + + private shouldIgnoreBackgroundLifecycleError(error: unknown): boolean { + if (error instanceof AskIgnoredError) { + return true + } + + if (this.abandoned === true || this.abort === true || this.abortReason === "user_cancelled") { + return true + } + + if (!(error instanceof Error)) { + return false + } + + const abortedByCurrentTask = + error.message.includes(`[RooCode#ask] task ${this.taskId}.${this.instanceId} aborted`) || + error.message.includes(`[RooCode#say] task ${this.taskId}.${this.instanceId} aborted`) + + return abortedByCurrentTask + } + /** * Initialize the task mode from the provider state. * This method handles async initialization with proper error handling. @@ -1369,7 +1383,6 @@ export class Task extends EventEmitter implements TaskLike { workspace: this.cwd, mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode. apiConfigName: this._taskApiConfigName, // Use the task's own provider profile, not the current provider profile. - initialStatus: this.initialStatus, }) // Emit token/tool usage updates using debounced function @@ -1382,7 +1395,10 @@ export class Task extends EventEmitter implements TaskLike { await this.providerRef.deref()?.updateTaskHistory(historyItem) return true } catch (error) { - console.error("Failed to save Roo messages:", error) + console.error( + `[Task#saveClineMessages] Failed to save Roo messages for task ${this.taskId}: ${error instanceof Error ? error.message : String(error)}`, + error, + ) return false } } @@ -1567,12 +1583,7 @@ export class Task extends EventEmitter 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) @@ -1599,12 +1610,7 @@ export class Task extends EventEmitter 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) @@ -1802,7 +1808,6 @@ export class Task extends EventEmitter implements TaskLike { customModes: state?.customModes, experiments: state?.experiments, apiConfiguration, - browserToolEnabled: state?.browserToolEnabled ?? true, disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: false, @@ -1996,11 +2001,6 @@ export class Task extends EventEmitter 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) { @@ -2058,7 +2058,7 @@ export class Task extends EventEmitter implements TaskLike { * child task begins writing its own history (avoiding a read-modify-write * race on globalState). */ - public start(): void { + public start(): Promise | void { if (this._started) { return } @@ -2067,7 +2067,7 @@ export class Task extends EventEmitter implements TaskLike { const { task, images } = this.metadata if (task || images) { - this.startTask(task ?? undefined, images ?? undefined) + return this.startTask(task ?? undefined, images ?? undefined) } } @@ -2099,6 +2099,27 @@ export class Task extends EventEmitter implements TaskLike { await this.say("text", task, images) + // Verify initial persistence succeeded — if saveClineMessages failed silently, + // this task will be invisible to globalState and delegation will break. + try { + const provider = this.providerRef.deref() + if (provider) { + const history = + ((provider as any).getGlobalState("taskHistory") as Array<{ id: string }> | undefined) ?? [] + if (!history.find((h) => h.id === this.taskId)) { + console.error( + `[Task#startTask] CRITICAL: Task ${this.taskId} not found in globalState after initial say(). ` + + `saveClineMessages may have failed silently. Retrying persistence...`, + ) + await this.saveClineMessages() + } + } + } catch (verifyErr) { + console.error( + `[Task#startTask] Failed to verify initial persistence: ${verifyErr instanceof Error ? verifyErr.message : String(verifyErr)}`, + ) + } + // Check for too many MCP tools and warn the user const { enabledToolCount, enabledServerCount } = await this.getEnabledMcpToolsCount() if (enabledToolCount > MAX_MCP_TOOLS_THRESHOLD) { @@ -2478,6 +2499,13 @@ export class Task extends EventEmitter implements TaskLike { console.error("Error cancelling current request:", error) } + // Cancel debounced token usage emitter to prevent zombie callbacks + try { + this.debouncedEmitTokenUsage.cancel() + } catch (error) { + console.error("Error cancelling debounced token usage emitter:", error) + } + // Remove provider profile change listener try { if (this.providerProfileChangeListener) { @@ -2538,28 +2566,6 @@ export class Task extends EventEmitter 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() @@ -2812,7 +2818,6 @@ export class Task extends EventEmitter implements TaskLike { const { content: parsedUserContent, mode: slashCommandMode } = await processUserContentMentions({ userContent: currentUserContent as Array, cwd: this.cwd, - urlContentFetcher: this.urlContentFetcher, fileContextTracker: this.fileContextTracker, rooIgnoreController: this.rooIgnoreController, showRooIgnoredFiles, @@ -2888,6 +2893,8 @@ export class Task extends EventEmitter implements TaskLike { let inputTokens = 0 let outputTokens = 0 let totalCost: number | undefined + let totalInputTokensAccum = 0 + let totalOutputTokensAccum = 0 // We can't use `api_req_finished` anymore since it's a unique case // where it could come after a streaming message (i.e. in the middle @@ -2903,38 +2910,14 @@ export class Task extends EventEmitter implements TaskLike { const existingData = JSON.parse(this.clineMessages[lastApiReqIndex].text || "{}") - // Calculate total tokens and cost using provider-aware function - const modelId = getModelId(this.apiConfiguration) - const apiProvider = this.apiConfiguration.apiProvider - const apiProtocol = getApiProtocol( - apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined, - modelId, - ) - - const costResult = - apiProtocol === "anthropic" - ? calculateApiCostAnthropic( - streamModelInfo, - inputTokens, - outputTokens, - cacheWriteTokens, - cacheReadTokens, - ) - : calculateApiCostOpenAI( - streamModelInfo, - inputTokens, - outputTokens, - cacheWriteTokens, - cacheReadTokens, - ) - + // Use provider-computed totals when available, falling back to raw token counts this.clineMessages[lastApiReqIndex].text = JSON.stringify({ ...existingData, - tokensIn: costResult.totalInputTokens, - tokensOut: costResult.totalOutputTokens, + tokensIn: totalInputTokensAccum || inputTokens, + tokensOut: totalOutputTokensAccum || outputTokens, cacheWrites: cacheWriteTokens, cacheReads: cacheReadTokens, - cost: totalCost ?? costResult.totalCost, + cost: totalCost ?? existingData.cost, cancelReason, streamingFailedMessage, } satisfies ClineApiReqInfo) @@ -3063,7 +3046,9 @@ export class Task extends EventEmitter implements TaskLike { outputTokens += chunk.outputTokens cacheWriteTokens += chunk.cacheWriteTokens ?? 0 cacheReadTokens += chunk.cacheReadTokens ?? 0 - totalCost = chunk.totalCost + totalCost = chunk.totalCost ?? totalCost + totalInputTokensAccum += chunk.totalInputTokens ?? 0 + totalOutputTokensAccum += chunk.totalOutputTokens ?? 0 break case "grounding": // Handle grounding sources separately from regular content @@ -3122,7 +3107,11 @@ export class Task extends EventEmitter implements TaskLike { // Present the tool call to user - presentAssistantMessage will execute // tools sequentially and accumulate all results in userMessageContent - presentAssistantMessage(this) + presentAssistantMessage(this).catch((err) => { + if (!this.abort) { + console.error("[presentAssistantMessage] Unhandled error:", err) + } + }) break } case "text": { @@ -3141,7 +3130,11 @@ export class Task extends EventEmitter implements TaskLike { }) this.userMessageContentReady = false } - presentAssistantMessage(this) + presentAssistantMessage(this).catch((err) => { + if (!this.abort) { + console.error("[presentAssistantMessage] Unhandled error:", err) + } + }) break } case "response_message": @@ -3188,6 +3181,8 @@ export class Task extends EventEmitter implements TaskLike { cacheWrite: cacheWriteTokens, cacheRead: cacheReadTokens, total: totalCost, + totalIn: totalInputTokensAccum, + totalOut: totalOutputTokensAccum, } const drainStreamInBackgroundToFindAllUsage = async (apiReqIndex: number) => { @@ -3201,6 +3196,8 @@ export class Task extends EventEmitter implements TaskLike { let bgCacheWriteTokens = currentTokens.cacheWrite let bgCacheReadTokens = currentTokens.cacheRead let bgTotalCost = currentTokens.total + let bgTotalInputTokens = currentTokens.totalIn + let bgTotalOutputTokens = currentTokens.totalOut // Helper function to capture telemetry and update messages const captureUsageData = async ( @@ -3210,6 +3207,8 @@ export class Task extends EventEmitter implements TaskLike { cacheWrite: number cacheRead: number total?: number + totalIn: number + totalOut: number }, messageIndex: number = apiReqIndex, ) => { @@ -3224,7 +3223,9 @@ export class Task extends EventEmitter implements TaskLike { outputTokens = tokens.output cacheWriteTokens = tokens.cacheWrite cacheReadTokens = tokens.cacheRead - totalCost = tokens.total + totalCost = tokens.total ?? totalCost + totalInputTokensAccum = tokens.totalIn + totalOutputTokensAccum = tokens.totalOut // Update the API request message with the latest usage data updateApiReqMsg() @@ -3236,38 +3237,20 @@ export class Task extends EventEmitter implements TaskLike { await this.updateClineMessage(apiReqMessage) } - // Capture telemetry with provider-aware cost calculation - const modelId = getModelId(this.apiConfiguration) - const apiProvider = this.apiConfiguration.apiProvider - const apiProtocol = getApiProtocol( - apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined, - modelId, - ) - - // Use the appropriate cost function based on the API protocol - const costResult = - apiProtocol === "anthropic" - ? calculateApiCostAnthropic( - streamModelInfo, - tokens.input, - tokens.output, - tokens.cacheWrite, - tokens.cacheRead, - ) - : calculateApiCostOpenAI( - streamModelInfo, - tokens.input, - tokens.output, - tokens.cacheWrite, - tokens.cacheRead, - ) + const messageData = JSON.parse( + this.clineMessages[messageIndex]?.text || "{}", + ) as ClineApiReqInfo + const telemetryCost = + tokens.total ?? + (typeof messageData.cost === "number" ? messageData.cost : undefined) + // Use provider-computed totals for telemetry, falling back to raw counts TelemetryService.instance.captureLlmCompletion(this.taskId, { - inputTokens: costResult.totalInputTokens, - outputTokens: costResult.totalOutputTokens, + inputTokens: tokens.totalIn || tokens.input, + outputTokens: tokens.totalOut || tokens.output, cacheWriteTokens: tokens.cacheWrite, cacheReadTokens: tokens.cacheRead, - cost: tokens.total ?? costResult.totalCost, + cost: telemetryCost, }) } } @@ -3301,7 +3284,9 @@ export class Task extends EventEmitter implements TaskLike { bgOutputTokens += chunk.outputTokens bgCacheWriteTokens += chunk.cacheWriteTokens ?? 0 bgCacheReadTokens += chunk.cacheReadTokens ?? 0 - bgTotalCost = chunk.totalCost + bgTotalCost = chunk.totalCost ?? bgTotalCost + bgTotalInputTokens += chunk.totalInputTokens ?? 0 + bgTotalOutputTokens += chunk.totalOutputTokens ?? 0 } } @@ -3320,6 +3305,8 @@ export class Task extends EventEmitter implements TaskLike { cacheWrite: bgCacheWriteTokens, cacheRead: bgCacheReadTokens, total: bgTotalCost, + totalIn: bgTotalInputTokens, + totalOut: bgTotalOutputTokens, }, lastApiReqIndex, ) @@ -3344,6 +3331,8 @@ export class Task extends EventEmitter implements TaskLike { cacheWrite: bgCacheWriteTokens, cacheRead: bgCacheReadTokens, total: bgTotalCost, + totalIn: bgTotalInputTokens, + totalOut: bgTotalOutputTokens, }, lastApiReqIndex, ) @@ -3468,7 +3457,11 @@ export class Task extends EventEmitter implements TaskLike { this.userMessageContentReady = false // Present the finalized tool call - presentAssistantMessage(this) + presentAssistantMessage(this).catch((err) => { + if (!this.abort) { + console.error("[presentAssistantMessage] Unhandled error:", err) + } + }) } else if (toolUseIndex !== undefined) { // finalizeStreamingToolCall returned null (malformed JSON or missing args) // We still need to mark the tool as non-partial so it gets executed @@ -3487,7 +3480,11 @@ export class Task extends EventEmitter implements TaskLike { this.userMessageContentReady = false // Present the tool call - validation will handle missing params - presentAssistantMessage(this) + presentAssistantMessage(this).catch((err) => { + if (!this.abort) { + console.error("[presentAssistantMessage] Unhandled error:", err) + } + }) } } } @@ -3721,7 +3718,11 @@ export class Task extends EventEmitter implements TaskLike { // If there is content to update then it will complete and // update `this.userMessageContentReady` to true, which we // `pWaitFor` before making the next request. - presentAssistantMessage(this) + presentAssistantMessage(this).catch((err) => { + if (!this.abort) { + console.error("[presentAssistantMessage] Unhandled error:", err) + } + }) } if (hasTextContent || hasToolUses) { @@ -3741,7 +3742,11 @@ export class Task extends EventEmitter implements TaskLike { // this.userMessageContentReady = true // } - await pWaitFor(() => this.userMessageContentReady) + await pWaitFor(() => this.userMessageContentReady || this.abort) + + if (this.abort) { + return false + } // If the model did not tool use, then we need to tell it to // either use a tool or attempt_completion. @@ -3951,13 +3956,11 @@ export class Task extends EventEmitter implements TaskLike { const state = await this.providerRef.deref()?.getState() const { - browserViewportSize, mode, customModes, customModePrompts, customInstructions, experiments, - browserToolEnabled, language, apiConfiguration, enableSubfolderRules, @@ -3970,24 +3973,14 @@ export class Task extends EventEmitter 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, @@ -3997,7 +3990,6 @@ export class Task extends EventEmitter implements TaskLike { rooIgnoreInstructions, { todoListEnabled: apiConfiguration?.todoListEnabled ?? true, - browserToolEnabled: browserToolEnabled ?? true, useAgentRules: vscode.workspace.getConfiguration(Package.name).get("useAgentRules") ?? true, enableSubfolderRules: enableSubfolderRules ?? false, @@ -4058,7 +4050,6 @@ export class Task extends EventEmitter implements TaskLike { customModes: state?.customModes, experiments: state?.experiments, apiConfiguration, - browserToolEnabled: state?.browserToolEnabled ?? true, disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: false, @@ -4273,7 +4264,6 @@ export class Task extends EventEmitter implements TaskLike { customModes: state?.customModes, experiments: state?.experiments, apiConfiguration, - browserToolEnabled: state?.browserToolEnabled ?? true, disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: false, @@ -4396,6 +4386,9 @@ export class Task extends EventEmitter implements TaskLike { const messagesWithoutImages = maybeRemoveImageBlocks(mergedForApi, this.api) const cleanConversationHistory = this.buildCleanConversationHistory(messagesWithoutImages) + // Breakpoints 3-4: Apply cache breakpoints to the last 2 non-assistant messages + applyCacheBreakpoints(cleanConversationHistory.filter(isRooRoleMessage)) + // Check auto-approval limits const approvalResult = await this.autoApprovalHandler.checkAutoApprovalLimits( state, @@ -4438,7 +4431,6 @@ export class Task extends EventEmitter implements TaskLike { customModes: state?.customModes, experiments: state?.experiments, apiConfiguration, - browserToolEnabled: state?.browserToolEnabled ?? true, disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: supportsAllowedFunctionNames, @@ -4453,6 +4445,12 @@ export class Task extends EventEmitter implements TaskLike { mode: mode, taskId: this.taskId, suppressPreviousResponseId: this.skipPrevResponseIdOnce, + toolProviderOptions: UNIVERSAL_CACHE_OPTIONS, + // Breakpoint 1: System prompt caching — cache-aware providers use this + // to inject the system prompt as a cached system message via + // applySystemPromptCaching(), since AI SDK v6 does not support + // providerOptions on the `system` string parameter. + systemProviderOptions: UNIVERSAL_CACHE_OPTIONS, // Include tools whenever they are present. ...(shouldIncludeTools ? { @@ -4910,41 +4908,6 @@ export class Task extends EventEmitter 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, diff --git a/src/core/task/__tests__/Task.dispose.test.ts b/src/core/task/__tests__/Task.dispose.test.ts index 24aee183ac..16bf3c91c2 100644 --- a/src/core/task/__tests__/Task.dispose.test.ts +++ b/src/core/task/__tests__/Task.dispose.test.ts @@ -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", () => ({ diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index f412525f6a..135d691de9 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -394,6 +394,82 @@ describe("Cline", () => { new Task({ provider: mockProvider, apiConfiguration: mockApiConfig }) }).toThrow("Either historyItem or task/images must be provided") }) + + it("should ignore cancelled background resumeTaskFromHistory errors", async () => { + const resumeSpy = vi + .spyOn(Task.prototype as any, "resumeTaskFromHistory") + .mockImplementationOnce(async function (this: Task) { + this.abort = true + throw new Error("resume aborted") + }) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "history-task-id", + number: 1, + ts: Date.now(), + task: "historical task", + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + } as any, + startTask: true, + }) + + await Promise.resolve() + await Promise.resolve() + + const lifecycleErrors = consoleErrorSpy.mock.calls.filter( + ([message]) => typeof message === "string" && message.includes("[Task#resumeTaskFromHistory]"), + ) + expect(lifecycleErrors).toHaveLength(0) + + resumeSpy.mockRestore() + consoleErrorSpy.mockRestore() + }) + + it("should log unexpected background resumeTaskFromHistory errors", async () => { + const resumeSpy = vi + .spyOn(Task.prototype as any, "resumeTaskFromHistory") + .mockRejectedValueOnce(new Error("unexpected resume failure")) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "history-task-id", + number: 1, + ts: Date.now(), + task: "historical task", + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + } as any, + startTask: true, + }) + + await Promise.resolve() + await Promise.resolve() + + const lifecycleErrors = consoleErrorSpy.mock.calls.filter( + ([message]) => + typeof message === "string" && + message.includes("[Task#resumeTaskFromHistory]") && + message.includes("unexpected resume failure"), + ) + expect(lifecycleErrors).toHaveLength(1) + + resumeSpy.mockRestore() + consoleErrorSpy.mockRestore() + }) }) describe("getEnvironmentDetails", () => { @@ -909,7 +985,6 @@ describe("Cline", () => { const { content: processedContent } = await processUserContentMentions({ userContent, cwd: cline.cwd, - urlContentFetcher: cline.urlContentFetcher, fileContextTracker: cline.fileContextTracker, }) diff --git a/src/core/task/__tests__/Task.throttle.test.ts b/src/core/task/__tests__/Task.throttle.test.ts index 904bc46b55..c9d78dc291 100644 --- a/src/core/task/__tests__/Task.throttle.test.ts +++ b/src/core/task/__tests__/Task.throttle.test.ts @@ -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", () => ({ diff --git a/src/core/task/__tests__/native-tools-filtering.spec.ts b/src/core/task/__tests__/native-tools-filtering.spec.ts index c9cd6a3060..1c393456ab 100644 --- a/src/core/task/__tests__/native-tools-filtering.spec.ts +++ b/src/core/task/__tests__/native-tools-filtering.spec.ts @@ -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 diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index ab74f9443c..c32d8f6f9b 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -22,7 +22,6 @@ interface BuildToolsOptions { customModes: ModeConfig[] | undefined experiments: Record | 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, } diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index a406a15c8b..17bf7369e5 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -5,6 +5,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" +import type { DelegationMeta } from "../task-persistence/delegationMeta" import { Package } from "../../shared/package" import type { ToolUse } from "../../shared/tools" import { t } from "../../i18n" @@ -31,6 +32,9 @@ interface DelegationProvider { childTaskId: string completionResultSummary: string }): Promise + updateTaskHistory(item: HistoryItem, options?: { broadcast?: boolean }): Promise + persistDelegationMeta(taskId: string, meta: DelegationMeta): Promise + readDelegationMeta(taskId: string): Promise } export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { @@ -124,10 +128,8 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { // Fall through to normal completion ask flow } } catch (err) { - // If we can't get the history, log error and skip delegation console.error( - `[AttemptCompletionTool] Failed to get history for task ${task.taskId}: ${(err as Error)?.message ?? String(err)}. ` + - `Skipping delegation.`, + `[AttemptCompletionTool] Delegation failed for task ${task.taskId}: ${err instanceof Error ? err.message : String(err)}. Falling through to standalone completion.`, ) // Fall through to normal completion ask flow } @@ -168,15 +170,89 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { return true } - pushToolResult("") + const parentTaskId = task.parentTaskId! + const childTaskId = task.taskId - await provider.reopenParentFromDelegation({ - parentTaskId: task.parentTaskId!, - childTaskId: task.taskId, - completionResultSummary: result, - }) + const attemptDelegation = async (): Promise => { + await provider.reopenParentFromDelegation({ + parentTaskId, + childTaskId, + completionResultSummary: result, + }) + } - return true + try { + await attemptDelegation() + pushToolResult("") + return true + } catch (error) { + // Delegation failed — repair parent status so it doesn't stay "delegated" + console.error( + `[AttemptCompletionTool] Delegation failed for task ${childTaskId}: ${ + error instanceof Error ? error.message : String(error) + }. Repairing parent and falling through to standalone completion.`, + ) + + try { + const { historyItem: parentHistory } = await provider.getTaskWithId(parentTaskId) + const childIds = Array.from(new Set([...(parentHistory.childIds ?? []), childTaskId])) + await provider.updateTaskHistory({ + ...parentHistory, + status: "active", + awaitingChildId: undefined, + childIds, + }) + await provider.persistDelegationMeta(parentTaskId, { + status: "active", + awaitingChildId: null, + delegatedToId: parentHistory.delegatedToId, + childIds, + completedByChildId: parentHistory.completedByChildId ?? null, + completionResultSummary: parentHistory.completionResultSummary ?? null, + }) + } catch (repairError) { + // Disk-only fallback when parent is missing from globalState. + // Uses read-merge-write to preserve fields like completedByChildId + // and completionResultSummary that may exist from prior delegations. + if (repairError instanceof Error && repairError.message === "Task not found") { + try { + const existingMeta = await provider.readDelegationMeta(parentTaskId) + await provider.persistDelegationMeta(parentTaskId, { + status: "active", + awaitingChildId: null, + delegatedToId: existingMeta?.delegatedToId ?? null, + childIds: existingMeta?.childIds + ? Array.from(new Set([...existingMeta.childIds, childTaskId])) + : [childTaskId], + completedByChildId: existingMeta?.completedByChildId ?? null, + completionResultSummary: existingMeta?.completionResultSummary ?? null, + }) + console.warn( + `[AttemptCompletionTool] Repaired parent ${parentTaskId} via disk fallback (not in globalState)`, + ) + } catch (diskErr) { + console.error( + `[AttemptCompletionTool] Disk fallback repair also failed for ${parentTaskId}: ${ + diskErr instanceof Error ? diskErr.message : String(diskErr) + }`, + ) + } + } else { + console.error( + `[AttemptCompletionTool] Failed to repair parent ${parentTaskId} after delegation failure: ${ + repairError instanceof Error ? repairError.message : String(repairError) + }`, + ) + } + } + + pushToolResult( + formatResponse.toolError( + `Delegation to parent task failed: ${error instanceof Error ? error.message : String(error)}. Completing as standalone task.`, + ), + ) + return true + } } override async handlePartial(task: Task, block: ToolUse<"attempt_completion">): Promise { diff --git a/src/core/tools/BrowserActionTool.ts b/src/core/tools/BrowserActionTool.ts deleted file mode 100644 index 3bd584e0cb..0000000000 --- a/src/core/tools/BrowserActionTool.ts +++ /dev/null @@ -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: x,y@WIDTHxHEIGHT 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) -> 500,300@1000x625` - - // 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 - } -} diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index f36d8e1e37..cd7d798686 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -110,15 +110,15 @@ export class NewTaskTool extends BaseTool<"new_task"> { } // Delegate parent and open child as sole active task - const child = await (provider as any).delegateParentAndOpenChild({ + await (provider as any).delegateParentAndOpenChild({ parentTaskId: task.taskId, message: unescapedMessage, initialTodos: todoItems, mode, }) - - // Reflect delegation in tool result (no pause/unpause, no wait) - pushToolResult(`Delegated to child task ${child.taskId}`) + // No pushToolResult needed — reopenParentFromDelegation injects the tool_result + // when the child completes. Any result pushed here would be orphaned because + // flushPendingToolResultsToHistory() already persisted userMessageContent. return } catch (error) { await handleError("creating new task", error) diff --git a/src/core/tools/ToolRepetitionDetector.ts b/src/core/tools/ToolRepetitionDetector.ts index 9e70bb41a0..27592c5210 100644 --- a/src/core/tools/ToolRepetitionDetector.ts +++ b/src/core/tools/ToolRepetitionDetector.ts @@ -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 * diff --git a/src/core/tools/__tests__/BrowserActionTool.coordinateScaling.spec.ts b/src/core/tools/__tests__/BrowserActionTool.coordinateScaling.spec.ts deleted file mode 100644 index 4294fff4d3..0000000000 --- a/src/core/tools/__tests__/BrowserActionTool.coordinateScaling.spec.ts +++ /dev/null @@ -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") - }) - }) -}) diff --git a/src/core/tools/__tests__/BrowserActionTool.screenshot.spec.ts b/src/core/tools/__tests__/BrowserActionTool.screenshot.spec.ts deleted file mode 100644 index 5f3dd271b2..0000000000 --- a/src/core/tools/__tests__/BrowserActionTool.screenshot.spec.ts +++ /dev/null @@ -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) - }) - }) -}) diff --git a/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts b/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts index bda80d711f..5fe4de8a33 100644 --- a/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts +++ b/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts @@ -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", () => { diff --git a/src/core/tools/__tests__/attemptCompletionDelegation.spec.ts b/src/core/tools/__tests__/attemptCompletionDelegation.spec.ts new file mode 100644 index 0000000000..d3577af6d8 --- /dev/null +++ b/src/core/tools/__tests__/attemptCompletionDelegation.spec.ts @@ -0,0 +1,260 @@ +// npx vitest core/tools/__tests__/attemptCompletionDelegation.spec.ts + +import type { HistoryItem } from "@roo-code/types" +import type { DelegationMeta } from "../../task-persistence/delegationMeta" + +// Mock vscode +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn(() => ({ + get: vi.fn(), + })), + }, + env: { + uriScheme: "vscode", + }, +})) + +vi.mock("../../../shared/package", () => ({ + Package: { + name: "roo-cline", + publisher: "RooVeterinaryInc", + version: "1.0.0", + outputChannel: "Roo-Code", + }, +})) + +vi.mock("../../prompts/responses", () => ({ + formatResponse: { + toolError: vi.fn((msg: string) => `Tool Error: ${msg}`), + toolResult: vi.fn((text: string) => text), + toolDenied: vi.fn(() => "The user denied this operation."), + }, +})) + +vi.mock("../../../i18n", () => ({ + t: vi.fn((key: string) => key), +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureTaskCompleted: vi.fn(), + }, + }, +})) + +import { AttemptCompletionTool } from "../AttemptCompletionTool" + +/** Helper: minimal HistoryItem for a child task. */ +function childHistoryItem(overrides: Partial = {}): HistoryItem { + return { + id: "child-1", + number: 2, + ts: Date.now(), + task: "child task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "active", + ...overrides, + } +} + +/** Helper: minimal HistoryItem for a parent task. */ +function parentHistoryItem(overrides: Partial = {}): HistoryItem { + return { + id: "parent-1", + number: 1, + ts: Date.now(), + task: "parent task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "delegated", + awaitingChildId: "child-1", + delegatedToId: "mode-1", + childIds: ["child-1"], + completedByChildId: "old-child-0", + completionResultSummary: "old result summary", + ...overrides, + } +} + +function createMockProvider() { + return { + getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentHistoryItem() }), + reopenParentFromDelegation: vi.fn().mockResolvedValue(undefined), + updateTaskHistory: vi.fn().mockResolvedValue([]), + persistDelegationMeta: vi.fn().mockResolvedValue(undefined), + readDelegationMeta: vi.fn().mockResolvedValue(null), + } +} + +function createMockTask(provider: ReturnType) { + return { + taskId: "child-1", + parentTaskId: "parent-1", + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + say: vi.fn().mockResolvedValue(undefined), + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }), + emitFinalTokenUsageUpdate: vi.fn(), + emit: vi.fn(), + getTokenUsage: vi.fn().mockReturnValue({}), + toolUsage: {}, + clineMessages: [], + providerRef: { deref: () => provider }, + sayAndCreateMissingParamError: vi.fn().mockResolvedValue("missing param"), + } +} + +function createCallbacks(overrides: Record = {}) { + return { + askApproval: vi.fn(), + handleError: vi.fn(), + pushToolResult: vi.fn(), + askFinishSubTaskApproval: vi.fn().mockResolvedValue(true), + toolDescription: () => "attempt_completion tool", + ...overrides, + } +} + +describe("AttemptCompletionTool delegation and parent-repair", () => { + let tool: AttemptCompletionTool + + beforeEach(() => { + tool = new AttemptCompletionTool() + }) + + it("should repair parent to active when delegation attempt fails", async () => { + const provider = createMockProvider() + const task = createMockTask(provider) + const callbacks = createCallbacks() + + // Delegation call fails + provider.reopenParentFromDelegation.mockRejectedValueOnce(new Error("delegation failure")) + + // First call: child status lookup; second call: parent lookup for repair + provider.getTaskWithId + .mockResolvedValueOnce({ historyItem: childHistoryItem() }) + .mockResolvedValueOnce({ historyItem: parentHistoryItem() }) + + await tool.execute({ result: "completed work" }, task as any, callbacks as any) + + // Single attempt was made (no retry) + expect(provider.reopenParentFromDelegation).toHaveBeenCalledTimes(1) + + // Parent was repaired: updateTaskHistory called with status: "active" + expect(provider.updateTaskHistory).toHaveBeenCalledWith( + expect.objectContaining({ + id: "parent-1", + status: "active", + awaitingChildId: undefined, + childIds: ["child-1"], + }), + ) + + // persistDelegationMeta called with status: "active" and null awaitingChildId + expect(provider.persistDelegationMeta).toHaveBeenCalledWith("parent-1", { + status: "active", + awaitingChildId: null, + delegatedToId: "mode-1", + childIds: ["child-1"], + completedByChildId: "old-child-0", + completionResultSummary: "old result summary", + }) + + // Error tool result was pushed + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Delegation to parent task failed"), + ) + }) + + it("should use read-merge-write disk fallback when parent not in globalState", async () => { + const provider = createMockProvider() + const task = createMockTask(provider) + const callbacks = createCallbacks() + + // Delegation attempt fails + provider.reopenParentFromDelegation.mockRejectedValueOnce(new Error("delegation failure")) + + // Child status lookup succeeds, parent lookup fails (not in globalState) + provider.getTaskWithId + .mockResolvedValueOnce({ historyItem: childHistoryItem() }) + .mockRejectedValueOnce(new Error("Task not found")) + + // Existing delegation meta on disk + const existingMeta: DelegationMeta = { + status: "delegated", + delegatedToId: "mode-1", + awaitingChildId: "child-1", + childIds: ["child-1"], + completedByChildId: "prev-child", + completionResultSummary: "previous result", + } + provider.readDelegationMeta.mockResolvedValue(existingMeta) + + await tool.execute({ result: "completed work" }, task as any, callbacks as any) + + // readDelegationMeta was called for the parent + expect(provider.readDelegationMeta).toHaveBeenCalledWith("parent-1") + + // persistDelegationMeta preserves existing fields via read-merge-write + expect(provider.persistDelegationMeta).toHaveBeenCalledWith("parent-1", { + status: "active", + awaitingChildId: null, + delegatedToId: "mode-1", + childIds: ["child-1"], + completedByChildId: "prev-child", + completionResultSummary: "previous result", + }) + }) + + it("should use defaults when disk fallback has no existing meta", async () => { + const provider = createMockProvider() + const task = createMockTask(provider) + const callbacks = createCallbacks() + + // Delegation attempt fails + provider.reopenParentFromDelegation.mockRejectedValueOnce(new Error("delegation failure")) + + // Child status lookup succeeds, parent lookup fails + provider.getTaskWithId + .mockResolvedValueOnce({ historyItem: childHistoryItem() }) + .mockRejectedValueOnce(new Error("Task not found")) + + // No existing delegation meta on disk + provider.readDelegationMeta.mockResolvedValue(null) + + await tool.execute({ result: "completed work" }, task as any, callbacks as any) + + // persistDelegationMeta uses null defaults when no existing meta + expect(provider.persistDelegationMeta).toHaveBeenCalledWith("parent-1", { + status: "active", + awaitingChildId: null, + delegatedToId: null, + childIds: ["child-1"], + completedByChildId: null, + completionResultSummary: null, + }) + }) + + it("should not attempt delegation when approval is denied", async () => { + const provider = createMockProvider() + const task = createMockTask(provider) + const callbacks = createCallbacks({ + askFinishSubTaskApproval: vi.fn().mockResolvedValue(false), + }) + + // Child task status is "active" + provider.getTaskWithId.mockResolvedValue({ + historyItem: childHistoryItem(), + }) + + await tool.execute({ result: "completed work" }, task as any, callbacks as any) + + // No delegation attempted + expect(provider.reopenParentFromDelegation).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/tools/__tests__/attemptCompletionTool.spec.ts b/src/core/tools/__tests__/attemptCompletionTool.spec.ts index 9aac6296c6..4bbbdcf069 100644 --- a/src/core/tools/__tests__/attemptCompletionTool.spec.ts +++ b/src/core/tools/__tests__/attemptCompletionTool.spec.ts @@ -25,6 +25,15 @@ vi.mock("../../../shared/package", () => ({ }, })) +// Mock TelemetryService +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureTaskCompleted: vi.fn(), + }, + }, +})) + import { attemptCompletionTool, AttemptCompletionCallbacks } from "../AttemptCompletionTool" import { Task } from "../../task/Task" import * as vscode from "vscode" @@ -469,4 +478,120 @@ describe("attemptCompletionTool", () => { }) }) }) + + describe("delegation flow", () => { + let mockProvider: { + getTaskWithId: ReturnType + reopenParentFromDelegation: ReturnType + updateTaskHistory: ReturnType + persistDelegationMeta: ReturnType + readDelegationMeta: ReturnType + } + + let block: AttemptCompletionToolUse + let callbacks: AttemptCompletionCallbacks + + beforeEach(() => { + mockProvider = { + getTaskWithId: vi.fn(), + reopenParentFromDelegation: vi.fn(), + updateTaskHistory: vi.fn(), + persistDelegationMeta: vi.fn(), + readDelegationMeta: vi.fn().mockResolvedValue(null), + } + + Object.defineProperty(mockTask, "parentTaskId", { value: "parent-123", writable: true, configurable: true }) + Object.defineProperty(mockTask, "providerRef", { + value: new WeakRef(mockProvider), + writable: true, + configurable: true, + }) + + // Default: child task is active (triggers delegation path) + mockProvider.getTaskWithId.mockResolvedValue({ + historyItem: { status: "active", childIds: [] }, + }) + + mockAskFinishSubTaskApproval.mockResolvedValue(true) + + block = { + type: "tool_use", + name: "attempt_completion", + params: { result: "Task completed successfully" }, + nativeArgs: { result: "Task completed successfully" }, + partial: false, + } + + callbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + } + }) + + it("repairs parent to active when both delegation attempts fail", async () => { + mockProvider.reopenParentFromDelegation.mockRejectedValue(new Error("persistent error")) + + // First call: child task check in execute(); second call: parent repair in delegateToParent() + mockProvider.getTaskWithId + .mockResolvedValueOnce({ historyItem: { status: "active", childIds: [] } }) + .mockResolvedValueOnce({ + historyItem: { status: "delegated", childIds: [], delegatedToId: "child-task" }, + }) + + mockProvider.updateTaskHistory.mockResolvedValue([]) + mockProvider.persistDelegationMeta.mockResolvedValue(undefined) + + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) + + expect(mockProvider.updateTaskHistory).toHaveBeenCalledWith( + expect.objectContaining({ status: "active", awaitingChildId: undefined }), + ) + expect(mockProvider.persistDelegationMeta).toHaveBeenCalledWith( + "parent-123", + expect.objectContaining({ status: "active", awaitingChildId: null }), + ) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegation to parent task failed")) + }) + + it("handles repair failure gracefully when both delegation and repair fail", async () => { + mockProvider.reopenParentFromDelegation.mockRejectedValue(new Error("persistent error")) + + // First call: child task check; second call: parent repair (will succeed but updateTaskHistory throws) + mockProvider.getTaskWithId + .mockResolvedValueOnce({ historyItem: { status: "active", childIds: [] } }) + .mockResolvedValueOnce({ historyItem: { status: "delegated", childIds: [] } }) + + mockProvider.updateTaskHistory.mockRejectedValue(new Error("repair failed")) + + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) // should NOT throw — repair error is caught internally + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegation to parent task failed")) + }) + + it("repairs parent via disk fallback when getTaskWithId throws Task not found", async () => { + mockProvider.reopenParentFromDelegation.mockRejectedValue(new Error("persistent error")) + + // First call: child task check in execute(); second call: parent repair throws "Task not found" + mockProvider.getTaskWithId + .mockResolvedValueOnce({ historyItem: { status: "active", childIds: [] } }) + .mockRejectedValueOnce(new Error("Task not found")) + + mockProvider.persistDelegationMeta.mockResolvedValue(undefined) + + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) + + expect(mockProvider.persistDelegationMeta).toHaveBeenCalledWith("parent-123", { + status: "active", + awaitingChildId: null, + delegatedToId: null, + childIds: ["task_1"], + completedByChildId: null, + completionResultSummary: null, + }) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegation to parent task failed")) + }) + }) }) diff --git a/src/core/tools/__tests__/newTaskTool.spec.ts b/src/core/tools/__tests__/newTaskTool.spec.ts index fc383c13ee..735a9139f3 100644 --- a/src/core/tools/__tests__/newTaskTool.spec.ts +++ b/src/core/tools/__tests__/newTaskTool.spec.ts @@ -175,7 +175,7 @@ describe("newTaskTool", () => { ) // Verify side effects - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task")) + expect(mockPushToolResult).not.toHaveBeenCalled() }) it("should not un-escape single escaped \@", async () => { @@ -280,7 +280,7 @@ describe("newTaskTool", () => { expect(mockStartSubtask).toHaveBeenCalledWith("Test message", [], "code") // Should complete successfully - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task")) + expect(mockPushToolResult).not.toHaveBeenCalled() }) it("should work with todos parameter when provided", async () => { @@ -311,7 +311,7 @@ describe("newTaskTool", () => { "code", ) - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task")) + expect(mockPushToolResult).not.toHaveBeenCalled() }) it("should error when mode parameter is missing", async () => { @@ -423,7 +423,7 @@ describe("newTaskTool", () => { expect(mockStartSubtask).toHaveBeenCalledWith("Test message", [], "code") // Should complete successfully - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task")) + expect(mockPushToolResult).not.toHaveBeenCalled() }) it("should REQUIRE todos when VSCode setting is enabled", async () => { @@ -501,7 +501,7 @@ describe("newTaskTool", () => { ) // Should complete successfully - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task")) + expect(mockPushToolResult).not.toHaveBeenCalled() }) it("should work with empty todos string when VSCode setting is enabled", async () => { @@ -536,7 +536,7 @@ describe("newTaskTool", () => { expect(mockStartSubtask).toHaveBeenCalledWith("Test message", [], "code") // Should complete successfully - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task")) + expect(mockPushToolResult).not.toHaveBeenCalled() }) it("should check VSCode setting with Package.name configuration key", async () => { @@ -672,6 +672,6 @@ describe("newTaskTool delegation flow", () => { expect(pauseEvents.length).toBe(0) // Assert: tool result reflects delegation - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task child-1")) + expect(mockPushToolResult).not.toHaveBeenCalled() }) }) diff --git a/src/core/tools/__tests__/validateToolUse.spec.ts b/src/core/tools/__tests__/validateToolUse.spec.ts index b4622096ab..29455e3688 100644 --- a/src/core/tools/__tests__/validateToolUse.spec.ts +++ b/src/core/tools/__tests__/validateToolUse.spec.ts @@ -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, 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.', ) }) diff --git a/src/core/webview/BrowserSessionPanelManager.ts b/src/core/webview/BrowserSessionPanelManager.ts deleted file mode 100644 index 514c1315f7..0000000000 --- a/src/core/webview/BrowserSessionPanelManager.ts +++ /dev/null @@ -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 = 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 { - 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 { - // 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 { - 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 { - 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 { - 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 { - 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 = ` - - ` - - 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 ` - - - - - - - - - Browser Session - - -
- ${reactRefresh} - - - - ` - } - - 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 ` - - - - - - - - - Browser Session - - -
- - - - ` - } -} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 0f42d6c5fb..c64f99b983 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -110,6 +110,7 @@ import { getToolCallName, getToolResultCallId, } from "../task-persistence" +import { type DelegationMeta, readDelegationMeta, saveDelegationMeta } from "../task-persistence/delegationMeta" import { readTaskMessages } from "../task-persistence/taskMessages" import { getNonce } from "./getNonce" import { getUri } from "./getUri" @@ -160,6 +161,7 @@ export class ClineProvider private taskEventListeners: WeakMap void>> = new WeakMap() private currentWorkspacePath: string | undefined private _disposed = false + private delegationInProgress = false private recentTasksCache?: string[] private taskHistoryWriteLock: Promise = Promise.resolve() @@ -177,6 +179,7 @@ export class ClineProvider private clineMessagesSeq = 0 public isViewLaunched = false + public isTaskCreationInProgress = false public settingsImportedAt?: number public readonly latestAnnouncementId = "feb-2026-v3.47.0-opus-4.6-gpt-5.3-codex" // v3.47.0 Claude Opus 4.6 & GPT-5.3-Codex public readonly providerSettingsManager: ProviderSettingsManager @@ -528,17 +531,63 @@ export class ClineProvider status: "active", awaitingChildId: undefined, }) + const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + await saveDelegationMeta({ + taskId: parentTaskId, + globalStoragePath, + meta: { + status: "active", + awaitingChildId: null, + delegatedToId: parentHistory.delegatedToId, + childIds: parentHistory.childIds, + completedByChildId: parentHistory.completedByChildId, + completionResultSummary: parentHistory.completionResultSummary, + }, + }) this.log( `[ClineProvider#removeClineFromStack] Repaired parent ${parentTaskId} metadata: delegated → active (child ${childTaskId} removed)`, ) } } catch (err) { - // Non-fatal: log but do not block the pop operation. - this.log( - `[ClineProvider#removeClineFromStack] Failed to repair parent metadata for ${parentTaskId} (non-fatal): ${ - err instanceof Error ? err.message : String(err) - }`, - ) + // Disk-only fallback when parent is missing from globalState + if (err instanceof Error && err.message === "Task not found") { + try { + const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + const delegationMeta = await readDelegationMeta({ taskId: parentTaskId, globalStoragePath }) + if ( + delegationMeta?.status === "delegated" && + delegationMeta?.awaitingChildId === childTaskId + ) { + await saveDelegationMeta({ + taskId: parentTaskId, + globalStoragePath, + meta: { + status: "active", + awaitingChildId: null, + delegatedToId: delegationMeta.delegatedToId, + childIds: delegationMeta.childIds, + completedByChildId: delegationMeta.completedByChildId, + completionResultSummary: delegationMeta.completionResultSummary, + }, + }) + this.log( + `[ClineProvider#removeClineFromStack] Repaired parent ${parentTaskId} via disk fallback (not in globalState)`, + ) + } + } catch (diskErr) { + this.log( + `[ClineProvider#removeClineFromStack] Disk fallback repair also failed for ${parentTaskId}: ${ + diskErr instanceof Error ? diskErr.message : String(diskErr) + }`, + ) + } + } else { + this.log( + `[ClineProvider#removeClineFromStack] Failed to repair parent metadata for ${parentTaskId} (non-fatal): ${ + err instanceof Error ? err.message : String(err) + }`, + ) + } } } } @@ -1045,8 +1094,6 @@ export class ClineProvider onCreated: this.taskCreationCallback, startTask: options?.startTask ?? true, enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, taskSyncEnabled), - // Preserve the status from the history item to avoid overwriting it when the task saves messages - initialStatus: historyItem.status, }) if (isRehydratingCurrentTask) { @@ -1742,8 +1789,28 @@ export class ClineProvider throw new Error("Task not found") } - const { getTaskDirectoryPath } = await import("../../utils/storage") + // Hoist globalStoragePath so the delegation-meta merge and the file-path + // resolution below share one computation. const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + + // Merge per-task delegation metadata (source of truth for delegation fields). + // Old tasks without a file are unchanged (null → no merge). + try { + const delegationMeta = await readDelegationMeta({ taskId: id, globalStoragePath }) + + if (delegationMeta) { + for (const [key, value] of Object.entries(delegationMeta)) { + ;(historyItem as Record)[key] = value === null ? undefined : value + } + } + } catch (err) { + // Non-fatal: fall back to globalState values + console.warn( + `[getTaskWithId] Failed to read delegation metadata for task ${id} (non-fatal): ${err instanceof Error ? err.message : String(err)}`, + ) + } + + const { getTaskDirectoryPath } = await import("../../utils/storage") const taskDirPath = await getTaskDirectoryPath(globalStoragePath, id) const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory) const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages) @@ -1782,6 +1849,11 @@ export class ClineProvider } async showTaskWithId(id: string) { + if (this.delegationInProgress) { + this.log("[showTaskWithId] Skipped: delegation in progress") + vscode.window.showInformationMessage("Task delegation in progress, please wait...") + return + } if (id !== this.getCurrentTask()?.taskId) { // Non-current task. const { historyItem } = await this.getTaskWithId(id) @@ -1825,6 +1897,11 @@ export class ClineProvider // If the task has subtasks (childIds), they will also be deleted recursively async deleteTaskWithId(id: string, cascadeSubtasks: boolean = true) { try { + if (this.delegationInProgress) { + this.log("[deleteTaskWithId] Skipped: delegation in progress") + vscode.window.showInformationMessage("Task delegation in progress, please wait...") + return + } // get the task directory full path and history item const { taskDirPath, historyItem } = await this.getTaskWithId(id) @@ -2091,7 +2168,6 @@ export class ClineProvider alwaysAllowExecute, allowedCommands, deniedCommands, - alwaysAllowBrowser, alwaysAllowMcp, alwaysAllowModeSwitch, alwaysAllowSubtasks, @@ -2106,11 +2182,6 @@ export class ClineProvider checkpointTimeout, taskHistory, soundVolume, - browserViewportSize, - screenshotQuality, - remoteBrowserHost, - remoteBrowserEnabled, - cachedChromeHostUrl, writeDelayMs, terminalShellIntegrationTimeout, terminalShellIntegrationDisabled, @@ -2133,7 +2204,6 @@ export class ClineProvider experiments, maxOpenTabsContext, maxWorkspaceFiles, - browserToolEnabled, disabledTools, telemetrySetting, showRooIgnoredFiles, @@ -2168,7 +2238,6 @@ export class ClineProvider openRouterImageApiKey, openRouterImageGenerationSelectedModel, featureRoomoteControlEnabled, - isBrowserSessionActive, lockApiConfigAcrossModes, } = await this.getState() @@ -2210,11 +2279,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 +2306,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 +2330,6 @@ export class ClineProvider maxOpenTabsContext: maxOpenTabsContext ?? 20, maxWorkspaceFiles: maxWorkspaceFiles ?? 200, cwd, - browserToolEnabled: browserToolEnabled ?? true, disabledTools, telemetrySetting, telemetryKey, @@ -2442,9 +2503,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 +2515,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 +2534,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 +2560,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, @@ -2636,6 +2686,28 @@ export class ClineProvider }) } + /** + * Convenience wrapper around the standalone saveDelegationMeta function, + * injecting globalStoragePath from this provider's context. + * Exposed so tools (e.g. AttemptCompletionTool) can persist delegation + * metadata through the DelegationProvider interface. + */ + async persistDelegationMeta(taskId: string, meta: DelegationMeta): Promise { + const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + await saveDelegationMeta({ taskId, globalStoragePath, meta }) + } + + /** + * Convenience wrapper around the standalone readDelegationMeta function, + * injecting globalStoragePath from this provider's context. + * Exposed so tools (e.g. AttemptCompletionTool) can read existing delegation + * metadata for read-merge-write patterns through the DelegationProvider interface. + */ + async readDelegationMeta(taskId: string): Promise { + const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + return readDelegationMeta({ taskId, globalStoragePath }) + } + /** * Broadcasts a task history update to the webview. * This sends a lightweight message with just the task history, rather than the full state. @@ -2950,6 +3022,12 @@ export class ClineProvider options: CreateTaskOptions = {}, configuration: RooCodeSettings = {}, ): Promise { + if (this.delegationInProgress && !parentTask) { + this.log("[createTask] Blocked: delegation in progress") + vscode.window.showInformationMessage("Task delegation in progress, please wait...") + throw new Error("Cannot create task while delegation is in progress") + } + if (configuration) { await this.setValues(configuration) @@ -3031,6 +3109,11 @@ export class ClineProvider } public async cancelTask(): Promise { + if (this.delegationInProgress) { + this.log("[cancelTask] Skipped: delegation in progress") + vscode.window.showInformationMessage("Task delegation in progress, please wait...") + return + } const task = this.getCurrentTask() if (!task) { @@ -3269,128 +3352,231 @@ export class ClineProvider }): Promise { const { parentTaskId, message, initialTodos, mode } = params - // Metadata-driven delegation is always enabled + if (this.delegationInProgress) { + throw new Error("[delegateParentAndOpenChild] Delegation already in progress") + } + this.delegationInProgress = true + + const globalStoragePath = this.contextProxy.globalStorageUri.fsPath - // 1) Get parent (must be current task) - const parent = this.getCurrentTask() - if (!parent) { - throw new Error("[delegateParentAndOpenChild] No current task") - } - if (parent.taskId !== parentTaskId) { - throw new Error( - `[delegateParentAndOpenChild] Parent mismatch: expected ${parentTaskId}, current ${parent.taskId}`, - ) - } - // 2) Flush pending tool results to API history BEFORE disposing the parent. - // This is critical: when tools are called before new_task, - // their tool_result blocks are in userMessageContent but not yet saved to API history. - // If we don't flush them, the parent's API conversation will be incomplete and - // cause 400 errors when resumed (missing tool_result for tool_use blocks). - // - // NOTE: We do NOT pass the assistant message here because the assistant message - // is already added to apiConversationHistory by the normal flow in - // recursivelyMakeClineRequests BEFORE tools start executing. We only need to - // flush the pending user message with tool_results. try { - const flushSuccess = await parent.flushPendingToolResultsToHistory() + // Metadata-driven delegation is always enabled - if (!flushSuccess) { - console.warn(`[delegateParentAndOpenChild] Flush failed for parent ${parentTaskId}, retrying...`) - const retrySuccess = await parent.retrySaveApiConversationHistory() - - if (!retrySuccess) { - console.error( - `[delegateParentAndOpenChild] CRITICAL: Parent ${parentTaskId} API history not persisted to disk. Child return may produce stale state.`, - ) - vscode.window.showWarningMessage( - "Warning: Parent task state could not be saved. The parent task may lose recent context when resumed.", - ) - } + // 1) Get parent (must be current task) + const parent = this.getCurrentTask() + if (!parent) { + throw new Error("[delegateParentAndOpenChild] No current task") } - } catch (error) { - this.log( - `[delegateParentAndOpenChild] Error flushing pending tool results (non-fatal): ${ - error instanceof Error ? error.message : String(error) - }`, + if (parent.taskId !== parentTaskId) { + throw new Error( + `[delegateParentAndOpenChild] Parent mismatch: expected ${parentTaskId}, current ${parent.taskId}`, + ) + } + // Capture parent metadata before the parent is removed from the stack (Change A) + const parentMetadata = { task: parent?.metadata?.task, taskNumber: parent?.taskNumber } + + // 2) Flush pending tool results to API history BEFORE disposing the parent. + // This is critical: when tools are called before new_task, + // their tool_result blocks are in userMessageContent but not yet saved to API history. + // If we don't flush them, the parent's API conversation will be incomplete and + // cause 400 errors when resumed (missing tool_result for tool_use blocks). + // + // NOTE: We do NOT pass the assistant message here because the assistant message + // is already added to apiConversationHistory by the normal flow in + // recursivelyMakeClineRequests BEFORE tools start executing. We only need to + // flush the pending user message with tool_results. + try { + const flushSuccess = await parent.flushPendingToolResultsToHistory() + + if (!flushSuccess) { + console.warn(`[delegateParentAndOpenChild] Flush failed for parent ${parentTaskId}, retrying...`) + const retrySuccess = await parent.retrySaveApiConversationHistory() + + if (!retrySuccess) { + console.error( + `[delegateParentAndOpenChild] CRITICAL: Parent ${parentTaskId} API history not persisted to disk. Child return may produce stale state.`, + ) + vscode.window.showWarningMessage( + "Warning: Parent task state could not be saved. The parent task may lose recent context when resumed.", + ) + } + } + } catch (error) { + this.log( + `[delegateParentAndOpenChild] Error flushing pending tool results (non-fatal): ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + + // 3) Enforce single-open invariant by closing/disposing the parent first + // This ensures we never have >1 tasks open at any time during delegation. + // Await abort completion to ensure clean disposal and prevent unhandled rejections. + try { + await this.removeClineFromStack({ skipDelegationRepair: true }) + } catch (error) { + this.log( + `[delegateParentAndOpenChild] Error during parent disposal (non-fatal): ${ + error instanceof Error ? error.message : String(error) + }`, + ) + // Non-fatal: proceed with child creation even if parent cleanup had issues + } + + // 3) Switch provider mode to child's requested mode BEFORE creating the child task + // This ensures the child's system prompt and configuration are based on the correct mode. + // The mode switch must happen before createTask() because the Task constructor + // initializes its mode from provider.getState() during initializeTaskMode(). + try { + await this.handleModeSwitch(mode as any) + } catch (e) { + this.log( + `[delegateParentAndOpenChild] handleModeSwitch failed for mode '${mode}': ${ + (e as Error)?.message ?? String(e) + }`, + ) + } + + // 4) Create child as sole active (parent reference preserved for lineage) + // + // Pass startTask: false to prevent the child from beginning its task loop + // (and writing to globalState via saveClineMessages → updateTaskHistory) + // before we persist the parent's delegation metadata in step 5. + // Without this, the child's fire-and-forget startTask() races with step 5, + // and the last writer to globalState overwrites the other's changes— + // causing the parent's delegation fields to be lost. + const child = await this.createTask(message, undefined, parent as any, { + initialTodos, + startTask: false, + }) + + // 4b) Persist child's initial status in globalState (saveClineMessages no longer writes status) + // Build the history item directly from the child Task object instead of getTaskWithId, + // because the child isn't in globalState yet at this point. + await this.updateTaskHistory( + { + id: child.taskId, + ts: Date.now(), + task: message, + number: child.taskNumber, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "active", + parentTaskId: parentTaskId, + rootTaskId: child.rootTaskId, + workspace: this.cwd, + } as HistoryItem, + { broadcast: false }, ) - } - // 3) Enforce single-open invariant by closing/disposing the parent first - // This ensures we never have >1 tasks open at any time during delegation. - // Await abort completion to ensure clean disposal and prevent unhandled rejections. - try { - await this.removeClineFromStack({ skipDelegationRepair: true }) - } catch (error) { - this.log( - `[delegateParentAndOpenChild] Error during parent disposal (non-fatal): ${ - error instanceof Error ? error.message : String(error) - }`, - ) - // Non-fatal: proceed with child creation even if parent cleanup had issues - } - - // 3) Switch provider mode to child's requested mode BEFORE creating the child task - // This ensures the child's system prompt and configuration are based on the correct mode. - // The mode switch must happen before createTask() because the Task constructor - // initializes its mode from provider.getState() during initializeTaskMode(). - try { - await this.handleModeSwitch(mode as any) - } catch (e) { - this.log( - `[delegateParentAndOpenChild] handleModeSwitch failed for mode '${mode}': ${ - (e as Error)?.message ?? String(e) - }`, - ) - } - - // 4) Create child as sole active (parent reference preserved for lineage) - // Pass initialStatus: "active" to ensure the child task's historyItem is created - // with status from the start, avoiding race conditions where the task might - // call attempt_completion before status is persisted separately. - // - // Pass startTask: false to prevent the child from beginning its task loop - // (and writing to globalState via saveClineMessages → updateTaskHistory) - // before we persist the parent's delegation metadata in step 5. - // Without this, the child's fire-and-forget startTask() races with step 5, - // and the last writer to globalState overwrites the other's changes— - // causing the parent's delegation fields to be lost. - const child = await this.createTask(message, undefined, parent as any, { - initialTodos, - initialStatus: "active", - startTask: false, - }) - - // 5) Persist parent delegation metadata BEFORE the child starts writing. - try { - const { historyItem } = await this.getTaskWithId(parentTaskId) - const childIds = Array.from(new Set([...(historyItem.childIds ?? []), child.taskId])) - const updatedHistory: typeof historyItem = { - ...historyItem, - status: "delegated", + // 5) Persist parent delegation metadata BEFORE the child starts writing. + // updateTaskHistory (globalState) is critical — without it, parent won't show as delegated + let parentHistory: HistoryItem + try { + const result = await this.getTaskWithId(parentTaskId) + parentHistory = result.historyItem + } catch (err) { + console.error( + `[delegateParentAndOpenChild] Parent ${parentTaskId} not in globalState, using in-memory fallback: ${ + err instanceof Error ? err.message : String(err) + }`, + ) + parentHistory = { + id: parentTaskId, + ts: Date.now(), + task: parentMetadata.task ?? "", + number: parentMetadata.taskNumber ?? 0, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + workspace: this.cwd, + } as HistoryItem + } + const childIds = Array.from(new Set([...(parentHistory.childIds ?? []), child.taskId])) + const updatedHistory = { + ...parentHistory, + status: "delegated" as const, delegatedToId: child.taskId, awaitingChildId: child.taskId, childIds, } await this.updateTaskHistory(updatedHistory) - } catch (err) { - this.log( - `[delegateParentAndOpenChild] Failed to persist parent metadata for ${parentTaskId} -> ${child.taskId}: ${ - (err as Error)?.message ?? String(err) - }`, - ) + + // Per-task file backup is non-critical — globalState is the primary source + try { + await saveDelegationMeta({ + taskId: parentTaskId, + globalStoragePath, + meta: { + status: "delegated", + delegatedToId: child.taskId, + awaitingChildId: child.taskId, + childIds, + completedByChildId: parentHistory.completedByChildId ?? null, + completionResultSummary: parentHistory.completionResultSummary ?? null, + }, + }) + await saveDelegationMeta({ + taskId: child.taskId, + globalStoragePath, + meta: { status: "active" }, + }) + } catch (err) { + this.log( + `[delegateParentAndOpenChild] Non-critical: Failed to write delegation metadata files for ${parentTaskId} -> ${child.taskId}: ${(err as Error)?.message ?? String(err)}`, + ) + vscode.window.showWarningMessage( + "Delegation metadata could not be saved. Task delegation may be in a degraded state.", + ) + } + + // 6) Start the child task now that parent metadata is safely persisted. + const startPromise = child.start() + if (startPromise) { + startPromise.catch(async (err) => { + this.log( + `[delegateParentAndOpenChild] child.start() failed for ${child.taskId}: ${ + (err as Error)?.message ?? String(err) + }`, + ) + // Repair parent status back to active + try { + const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) + await this.updateTaskHistory({ ...parentHistory, status: "active", awaitingChildId: undefined }) + await saveDelegationMeta({ + taskId: parentTaskId, + globalStoragePath, + meta: { + status: "active", + awaitingChildId: null, + delegatedToId: parentHistory.delegatedToId, + childIds: parentHistory.childIds, + completedByChildId: parentHistory.completedByChildId ?? null, + completionResultSummary: parentHistory.completionResultSummary ?? null, + }, + }) + } catch (repairErr) { + this.log( + `[delegateParentAndOpenChild] Failed to repair parent after child.start() failure: ${ + (repairErr as Error)?.message ?? String(repairErr) + }`, + ) + } + }) + } + + // 7) Emit TaskDelegated (provider-level) + try { + this.emit(RooCodeEventName.TaskDelegated, parentTaskId, child.taskId) + } catch { + // non-fatal + } + + return child + } finally { + this.delegationInProgress = false } - - // 6) Start the child task now that parent metadata is safely persisted. - child.start() - - // 7) Emit TaskDelegated (provider-level) - try { - this.emit(RooCodeEventName.TaskDelegated, parentTaskId, child.taskId) - } catch { - // non-fatal - } - - return child } /** @@ -3404,211 +3590,254 @@ export class ClineProvider const { parentTaskId, childTaskId, completionResultSummary } = params const globalStoragePath = this.contextProxy.globalStorageUri.fsPath - // 1) Load parent from history and current persisted messages - const { historyItem } = await this.getTaskWithId(parentTaskId) + if (this.delegationInProgress) { + throw new Error("[reopenParentFromDelegation] Delegation already in progress") + } + this.delegationInProgress = true - let parentClineMessages: ClineMessage[] = [] try { - parentClineMessages = await readTaskMessages({ - taskId: parentTaskId, - globalStoragePath, - }) - } catch { - parentClineMessages = [] - } + // 1) Load parent from history and current persisted messages + const { historyItem } = await this.getTaskWithId(parentTaskId) - let parentApiMessages: RooMessage[] = [] - try { - parentApiMessages = await readRooMessages({ - taskId: parentTaskId, - globalStoragePath, - }) - } catch { - parentApiMessages = [] - } - - // 2) Inject synthetic records: UI subtask_result and update API tool_result - const ts = Date.now() - - // Defensive: ensure arrays - if (!Array.isArray(parentClineMessages)) parentClineMessages = [] - if (!Array.isArray(parentApiMessages)) parentApiMessages = [] - - const subtaskUiMessage: ClineMessage = { - type: "say", - say: "subtask_result", - text: completionResultSummary, - ts, - } - parentClineMessages.push(subtaskUiMessage) - await saveTaskMessages({ messages: parentClineMessages, taskId: parentTaskId, globalStoragePath }) - - // Find the tool call ID from the last assistant message's new_task tool call - let toolUseId: string | undefined - for (let i = parentApiMessages.length - 1; i >= 0; i--) { - const msg = parentApiMessages[i] - if (isRooAssistantMessage(msg) && Array.isArray(msg.content)) { - for (const block of msg.content) { - const typedBlock = block as unknown as { type: string } - if (isAnyToolCallBlock(typedBlock) && getToolCallName(typedBlock) === "new_task") { - toolUseId = getToolCallId(typedBlock) - break - } - } - if (toolUseId) break + let parentClineMessages: ClineMessage[] = [] + try { + parentClineMessages = await readTaskMessages({ + taskId: parentTaskId, + globalStoragePath, + }) + } catch { + parentClineMessages = [] } - } - // Preferred: if the parent history contains a new_task tool call, - // inject a matching tool result for the model message contract. - if (toolUseId) { - // Check if the last message already contains a tool result for this tool call ID - // (in case this is a retry or the history was already updated) - const lastMsg = parentApiMessages[parentApiMessages.length - 1] - let alreadyHasToolResult = false - if (lastMsg && "role" in lastMsg && Array.isArray(lastMsg.content)) { - for (const block of lastMsg.content) { - const typedBlock = block as unknown as { type: string } - if (isAnyToolResultBlock(typedBlock) && getToolResultCallId(typedBlock) === toolUseId) { - const updatedText = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` - if (typedBlock.type === "tool-result") { - ;(typedBlock as { output: { type: "text"; value: string } }).output = { - type: "text", - value: updatedText, - } - } else { - ;(typedBlock as { content: string }).content = updatedText + let parentApiMessages: RooMessage[] = [] + try { + parentApiMessages = await readRooMessages({ + taskId: parentTaskId, + globalStoragePath, + }) + } catch { + parentApiMessages = [] + } + + // 2) Inject synthetic records: UI subtask_result and update API tool_result + const ts = Date.now() + + // Defensive: ensure arrays + if (!Array.isArray(parentClineMessages)) parentClineMessages = [] + if (!Array.isArray(parentApiMessages)) parentApiMessages = [] + + const subtaskUiMessage: ClineMessage = { + type: "say", + say: "subtask_result", + text: completionResultSummary, + ts, + } + parentClineMessages.push(subtaskUiMessage) + await saveTaskMessages({ messages: parentClineMessages, taskId: parentTaskId, globalStoragePath }) + + // Find the tool call ID from the last assistant message's new_task tool call + let toolUseId: string | undefined + for (let i = parentApiMessages.length - 1; i >= 0; i--) { + const msg = parentApiMessages[i] + if (isRooAssistantMessage(msg) && Array.isArray(msg.content)) { + for (const block of msg.content) { + const typedBlock = block as unknown as { type: string } + if (isAnyToolCallBlock(typedBlock) && getToolCallName(typedBlock) === "new_task") { + toolUseId = getToolCallId(typedBlock) + break } - alreadyHasToolResult = true - break } + if (toolUseId) break } } - // If no existing tool result found, create a NEW tool message with the tool result - if (!alreadyHasToolResult) { + // Preferred: if the parent history contains a new_task tool call, + // inject a matching tool result for the model message contract. + if (toolUseId) { + // Check if the last message already contains a tool result for this tool call ID + // (in case this is a retry or the history was already updated) + const lastMsg = parentApiMessages[parentApiMessages.length - 1] + let alreadyHasToolResult = false + if (lastMsg && "role" in lastMsg && Array.isArray(lastMsg.content)) { + for (const block of lastMsg.content) { + const typedBlock = block as unknown as { type: string } + if (isAnyToolResultBlock(typedBlock) && getToolResultCallId(typedBlock) === toolUseId) { + const updatedText = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` + if (typedBlock.type === "tool-result") { + ;(typedBlock as { output: { type: "text"; value: string } }).output = { + type: "text", + value: updatedText, + } + } else { + ;(typedBlock as { content: string }).content = updatedText + } + alreadyHasToolResult = true + break + } + } + } + + // If no existing tool result found, create a NEW tool message with the tool result + if (!alreadyHasToolResult) { + parentApiMessages.push({ + role: "tool", + content: [ + { + type: "tool-result" as const, + toolCallId: toolUseId, + toolName: "new_task", + output: { + type: "text" as const, + value: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, + }, + }, + ], + ts, + }) + } + + // Validate the newly injected/updated tool result against the preceding assistant message. + const lastMessage = parentApiMessages[parentApiMessages.length - 1] + if ( + lastMessage && + (isRooToolMessage(lastMessage) || ("role" in lastMessage && lastMessage.role === "user")) + ) { + const validatedMessage = validateAndFixToolResultIds(lastMessage, parentApiMessages.slice(0, -1)) + parentApiMessages[parentApiMessages.length - 1] = validatedMessage as RooMessage + } + } else { + // If there is no corresponding tool call in the parent API history, we cannot emit a + // tool result. Fall back to a plain user text note so the parent can still resume. parentApiMessages.push({ - role: "tool", + role: "user", content: [ { - type: "tool-result" as const, - toolCallId: toolUseId, - toolName: "new_task", - output: { - type: "text" as const, - value: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, - }, + type: "text" as const, + text: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, }, ], ts, }) } - // Validate the newly injected/updated tool result against the preceding assistant message. - const lastMessage = parentApiMessages[parentApiMessages.length - 1] - if ( - lastMessage && - (isRooToolMessage(lastMessage) || ("role" in lastMessage && lastMessage.role === "user")) - ) { - const validatedMessage = validateAndFixToolResultIds(lastMessage, parentApiMessages.slice(0, -1)) - parentApiMessages[parentApiMessages.length - 1] = validatedMessage as RooMessage - } - } else { - // If there is no corresponding tool call in the parent API history, we cannot emit a - // tool result. Fall back to a plain user text note so the parent can still resume. - parentApiMessages.push({ - role: "user", - content: [ - { - type: "text" as const, - text: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, - }, - ], - ts, + const savedApiMessages = await saveRooMessages({ + messages: parentApiMessages, + taskId: parentTaskId, + globalStoragePath, }) - } - - const savedApiMessages = await saveRooMessages({ - messages: parentApiMessages, - taskId: parentTaskId, - globalStoragePath, - }) - if (savedApiMessages === false) { - this.log(`[reopenParentFromDelegation] Failed to save API messages for parent ${parentTaskId}`) - } - - // 3) Close child instance if still open (single-open-task invariant). - // This MUST happen BEFORE updating the child's status to "completed" because - // removeClineFromStack() → abortTask(true) → saveClineMessages() writes - // the historyItem with initialStatus (typically "active"), which would - // overwrite a "completed" status set earlier. - const current = this.getCurrentTask() - if (current?.taskId === childTaskId) { - await this.removeClineFromStack() - } - - // 4) Update child metadata to "completed" status. - // This runs after the abort so it overwrites the stale "active" status - // that saveClineMessages() may have written during step 3. - try { - const { historyItem: childHistory } = await this.getTaskWithId(childTaskId) - await this.updateTaskHistory({ - ...childHistory, - status: "completed", - }) - } catch (err) { - this.log( - `[reopenParentFromDelegation] Failed to persist child completed status for ${childTaskId}: ${ - (err as Error)?.message ?? String(err) - }`, - ) - } - - // 5) Update parent metadata and persist BEFORE emitting completion event - const childIds = Array.from(new Set([...(historyItem.childIds ?? []), childTaskId])) - const updatedHistory: typeof historyItem = { - ...historyItem, - status: "active", - completedByChildId: childTaskId, - completionResultSummary, - awaitingChildId: undefined, - childIds, - } - await this.updateTaskHistory(updatedHistory) - - // 6) Emit TaskDelegationCompleted (provider-level) - try { - this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary) - } catch { - // non-fatal - } - - // 7) Reopen the parent from history as the sole active task (restores saved mode) - // IMPORTANT: startTask=false to suppress resume-from-history ask scheduling - const parentInstance = await this.createTaskWithHistoryItem(updatedHistory, { startTask: false }) - - // 8) Inject restored histories into the in-memory instance before resuming - if (parentInstance) { - try { - await parentInstance.overwriteClineMessages(parentClineMessages) - } catch { - // non-fatal + if (savedApiMessages === false) { + this.log(`[reopenParentFromDelegation] Failed to save API messages for parent ${parentTaskId}`) } + + // 3) Close child instance if still open (single-open-task invariant). + // This MUST happen BEFORE updating the child's status to "completed" because + // removeClineFromStack() → abortTask(true) → saveClineMessages() calls + // updateTaskHistory which would overwrite a "completed" status set earlier + // (updateTaskHistory spreads incoming fields over existing ones). + const current = this.getCurrentTask() + if (current?.taskId === childTaskId) { + await this.removeClineFromStack({ skipDelegationRepair: true }) + } + + // 4) Update child metadata to "completed" status. + // This runs after the abort so it overwrites the stale "active" status + // that saveClineMessages() may have written during step 3. + try { - await parentInstance.overwriteApiConversationHistory(parentApiMessages as any) + const { historyItem: childHistory } = await this.getTaskWithId(childTaskId) + await this.updateTaskHistory({ + ...childHistory, + status: "completed", + }) + await saveDelegationMeta({ + taskId: childTaskId, + globalStoragePath, + meta: { status: "completed" }, + }) + } catch (err) { + this.log( + `[reopenParentFromDelegation] Failed to persist child completed status for ${childTaskId}: ${ + (err as Error)?.message ?? String(err) + }`, + ) + } + + // 5) Update parent metadata and persist BEFORE emitting completion event + // Re-read parent to avoid stale-read TOCTOU: historyItem was loaded at step 1 + // but many async operations (message injection, child abort) have elapsed since. + const { historyItem: freshParent } = await this.getTaskWithId(parentTaskId) + const childIds = Array.from(new Set([...(freshParent.childIds ?? []), childTaskId])) + const updatedHistory: typeof freshParent = { + ...freshParent, + status: "active", + completedByChildId: childTaskId, + completionResultSummary, + awaitingChildId: undefined, + childIds, + } + await this.updateTaskHistory(updatedHistory) + await saveDelegationMeta({ + taskId: parentTaskId, + globalStoragePath, + meta: { + status: "active", + completedByChildId: childTaskId, + completionResultSummary, + awaitingChildId: null, + childIds, + delegatedToId: freshParent.delegatedToId ?? null, + }, + }) + + // 6) Emit TaskDelegationCompleted (provider-level) + try { + this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary) } catch { // non-fatal } - // Auto-resume parent without ask("resume_task") - await parentInstance.resumeAfterDelegation() - } + // 7) Reopen the parent from history as the sole active task (restores saved mode) + // IMPORTANT: startTask=false to suppress resume-from-history ask scheduling + const parentInstance = await this.createTaskWithHistoryItem(updatedHistory, { startTask: false }) - // 9) Emit TaskDelegationResumed (provider-level) - try { - this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) - } catch { - // non-fatal + // 8) Inject restored histories into the in-memory instance before resuming + if (parentInstance) { + try { + await parentInstance.overwriteClineMessages(parentClineMessages) + } catch { + // non-fatal + } + try { + await parentInstance.overwriteApiConversationHistory(parentApiMessages as any) + } catch { + // non-fatal + } + + // Clear delegation guard BEFORE resuming parent so the parent's + // task loop can itself initiate a new delegation (new_task) without + // hitting the "delegation already in progress" guard. This early + // reset is safe because all metadata is persisted and the parent + // instance is fully reconstructed at this point. + // + // The `finally` block at the bottom of this method also sets + // `delegationInProgress = false` as a safety net for error paths — + // if an exception is thrown before reaching this line, the finally + // block ensures the guard is always released. On the happy path + // the finally reset is a harmless no-op. + this.delegationInProgress = false + + // Auto-resume parent without ask("resume_task") + await parentInstance.resumeAfterDelegation() + } + + // 9) Emit TaskDelegationResumed (provider-level) + try { + this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) + } catch { + // non-fatal + } + } finally { + this.delegationInProgress = false } } diff --git a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts index 9b5e3b16ee..1a4993b186 100644 --- a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts @@ -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", } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index f8667de5c9..c4c5b96c16 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -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", () => { @@ -3856,6 +3686,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { vi.spyOn(fsUtils, "fileExistsAtPath").mockResolvedValue(true) const fsp = await import("fs/promises") + // First readFile call is consumed by readDelegationMeta (delegation_metadata.json) + vi.mocked(fsp.readFile).mockResolvedValueOnce("null" as never) + // Second readFile call is consumed by readRooMessages (api_conversation_history.json) vi.mocked(fsp.readFile).mockResolvedValueOnce( JSON.stringify({ version: 2, diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index af674d7a5e..9e4f2fab3a 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -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", })) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts index ee63b45b25..2f29d79d0e 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -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", })) diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index aefed79744..72a6f83960 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -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), diff --git a/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts b/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts deleted file mode 100644 index 9ad2709b61..0000000000 --- a/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts +++ /dev/null @@ -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") - }) -}) diff --git a/src/core/webview/__tests__/removeClineFromStack-delegation.spec.ts b/src/core/webview/__tests__/removeClineFromStack-delegation.spec.ts new file mode 100644 index 0000000000..edf97eb0fd --- /dev/null +++ b/src/core/webview/__tests__/removeClineFromStack-delegation.spec.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import * as vscode from "vscode" + +import { ClineProvider } from "../ClineProvider" +import { Task } from "../../task/Task" +import { ContextProxy } from "../../config/ContextProxy" +import { readDelegationMeta, saveDelegationMeta } from "../../task-persistence/delegationMeta" + +// Mock dependencies +vi.mock("vscode", () => { + const mockDisposable = { dispose: vi.fn() } + return { + workspace: { + getConfiguration: vi.fn(() => ({ + get: vi.fn().mockReturnValue([]), + update: vi.fn().mockResolvedValue(undefined), + })), + workspaceFolders: [], + onDidChangeConfiguration: vi.fn(() => mockDisposable), + }, + env: { + uriScheme: "vscode", + language: "en", + }, + EventEmitter: vi.fn().mockImplementation(() => ({ + event: vi.fn(), + fire: vi.fn(), + })), + Disposable: { + from: vi.fn(), + }, + window: { + showErrorMessage: vi.fn(), + createTextEditorDecorationType: vi.fn().mockReturnValue({ + dispose: vi.fn(), + }), + onDidChangeActiveTextEditor: vi.fn(() => mockDisposable), + }, + Uri: { + file: vi.fn().mockReturnValue({ toString: () => "file://test" }), + }, + } +}) + +vi.mock("../../task/Task") +vi.mock("../../config/ContextProxy") +vi.mock("../../../services/mcp/McpServerManager", () => ({ + McpServerManager: { + getInstance: vi.fn().mockResolvedValue({ + registerClient: vi.fn(), + }), + unregisterProvider: vi.fn(), + }, +})) +vi.mock("../../../services/marketplace") +vi.mock("../../../integrations/workspace/WorkspaceTracker") +vi.mock("../../config/ProviderSettingsManager") +vi.mock("../../config/CustomModesManager") +vi.mock("../../../utils/path", () => ({ + getWorkspacePath: vi.fn().mockReturnValue("/test/workspace"), +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + setProvider: vi.fn(), + captureTaskCreated: vi.fn(), + }, + }, +})) + +vi.mock("@roo-code/cloud", () => ({ + CloudService: { + hasInstance: vi.fn().mockReturnValue(false), + instance: { + isAuthenticated: vi.fn().mockReturnValue(false), + }, + }, + BridgeOrchestrator: { + isEnabled: vi.fn().mockReturnValue(false), + }, + getRooCodeApiUrl: vi.fn().mockReturnValue("https://api.roo-code.com"), +})) + +vi.mock("../../../shared/embeddingModels", () => ({ + EMBEDDING_MODEL_PROFILES: [], +})) + +vi.mock("../../task-persistence/delegationMeta", () => ({ + readDelegationMeta: vi.fn(), + saveDelegationMeta: vi.fn(), +})) + +describe("removeClineFromStack — disk fallback delegation repair", () => { + let provider: ClineProvider + let mockContext: any + let mockOutputChannel: any + + const parentTaskId = "parent-task-100" + const childTaskId = "child-task-200" + + beforeEach(() => { + vi.clearAllMocks() + + mockContext = { + globalState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, + globalStorageUri: { fsPath: "/test/storage" }, + secrets: { + get: vi.fn().mockResolvedValue(undefined), + store: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, + extensionUri: { fsPath: "/test/extension" }, + } + + mockOutputChannel = { + appendLine: vi.fn(), + dispose: vi.fn(), + } + + const mockContextProxy = { + getValues: vi.fn().mockReturnValue({}), + getValue: vi.fn().mockReturnValue(undefined), + setValue: vi.fn().mockResolvedValue(undefined), + getProviderSettings: vi.fn().mockReturnValue({ apiProvider: "anthropic" }), + extensionUri: mockContext.extensionUri, + globalStorageUri: mockContext.globalStorageUri, + } + + provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", mockContextProxy as any) + + provider.postStateToWebview = vi.fn().mockResolvedValue(undefined) + provider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) + }) + + it("repairs parent via disk fallback when parent not in globalState", async () => { + // Mock getTaskWithId to throw "Task not found" (parent missing from globalState) + provider.getTaskWithId = vi.fn().mockRejectedValue(new Error("Task not found")) + + // Mock disk read to return delegated metadata matching the child + vi.mocked(readDelegationMeta).mockResolvedValue({ + status: "delegated", + awaitingChildId: childTaskId, + delegatedToId: null, + childIds: [childTaskId], + }) + + vi.mocked(saveDelegationMeta).mockResolvedValue(undefined) + + // Create a mock task that has parentTaskId + const mockChild: any = { + taskId: childTaskId, + instanceId: "inst-1", + parentTaskId, + emit: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + } + + // Place the child task on the stack + ;(provider as any).clineStack = [mockChild] + + await provider.removeClineFromStack() + + // Verify saveDelegationMeta was called with repaired status + expect(saveDelegationMeta).toHaveBeenCalledWith({ + taskId: parentTaskId, + globalStoragePath: "/test/storage", + meta: { + status: "active", + awaitingChildId: null, + delegatedToId: null, + childIds: [childTaskId], + }, + }) + }) + + it("handles graceful failure when both globalState and disk fallback fail", async () => { + // Mock getTaskWithId to throw "Task not found" + provider.getTaskWithId = vi.fn().mockRejectedValue(new Error("Task not found")) + + // Mock disk read to also throw + vi.mocked(readDelegationMeta).mockRejectedValue(new Error("ENOENT: file not found")) + + // Create a mock task with parentTaskId + const mockChild: any = { + taskId: childTaskId, + instanceId: "inst-2", + parentTaskId, + emit: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + } + + ;(provider as any).clineStack = [mockChild] + + // Should NOT throw — failure is non-fatal + await expect(provider.removeClineFromStack()).resolves.toBeUndefined() + + // saveDelegationMeta should NOT have been called since readDelegationMeta failed + expect(saveDelegationMeta).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index abfe36f7ac..8af2f5ff5d 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -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, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 7438056db5..041cc72983 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -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" @@ -547,10 +545,14 @@ export const webviewMessageHandler = async ( provider.isViewLaunched = true break case "newTask": - // Initializing new instance of Cline will make sure that any - // agentically running promises in old instance don't affect our new - // task. This essentially creates a fresh slate for the new task. + if (provider.isTaskCreationInProgress) { + break + } + provider.isTaskCreationInProgress = true try { + // Initializing new instance of Cline will make sure that any + // agentically running promises in old instance don't affect our new + // task. This essentially creates a fresh slate for the new task. const resolved = await resolveIncomingImages({ text: message.text, images: message.images }) await provider.createTask(resolved.text, resolved.images) // Task created successfully - notify the UI to reset @@ -562,6 +564,8 @@ export const webviewMessageHandler = async ( vscode.window.showErrorMessage( `Failed to create task: ${error instanceof Error ? error.message : String(error)}`, ) + } finally { + provider.isTaskCreationInProgress = false } break case "customInstructions": @@ -1185,69 +1189,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 +1417,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 diff --git a/src/package.json b/src/package.json index c16e36ae0c..9b37e2c640 100644 --- a/src/package.json +++ b/src/package.json @@ -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", diff --git a/src/services/browser/BrowserSession.ts b/src/services/browser/BrowserSession.ts deleted file mode 100644 index 7ab7e88cad..0000000000 --- a/src/services/browser/BrowserSession.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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): Promise { - 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 { - 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 { - 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 { - 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 { - 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, - ): Promise { - 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 { - return this.doAction(async (page) => { - await this.handleMouseInteraction(page, coordinate, async (x, y) => { - await page.mouse.click(x, y) - }) - }) - } - - async type(text: string): Promise { - return this.doAction(async (page) => { - await page.keyboard.type(text) - }) - } - - async press(key: string): Promise { - 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 = { - 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 { - 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 { - return this.doAction(async (page) => { - await this.scrollPage(page, "down") - }) - } - - async scrollUp(): Promise { - return this.doAction(async (page) => { - await this.scrollPage(page, "up") - }) - } - - async hover(coordinate: string): Promise { - 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 { - 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 { - // 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 { - 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 = ` - - - - - ` - 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 { - 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 } - } -} diff --git a/src/services/browser/UrlContentFetcher.ts b/src/services/browser/UrlContentFetcher.ts deleted file mode 100644 index 2d8e4a3de8..0000000000 --- a/src/services/browser/UrlContentFetcher.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 - } -} diff --git a/src/services/browser/__tests__/BrowserSession.spec.ts b/src/services/browser/__tests__/BrowserSession.spec.ts deleted file mode 100644 index 2291fade42..0000000000 --- a/src/services/browser/__tests__/BrowserSession.spec.ts +++ /dev/null @@ -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 }) - }) - }) -}) diff --git a/src/services/browser/__tests__/UrlContentFetcher.spec.ts b/src/services/browser/__tests__/UrlContentFetcher.spec.ts deleted file mode 100644 index b21456e379..0000000000 --- a/src/services/browser/__tests__/UrlContentFetcher.spec.ts +++ /dev/null @@ -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("Test content") - 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("Test content"), - 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("Test content"), - 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() - }) - }) -}) diff --git a/src/services/browser/browserDiscovery.ts b/src/services/browser/browserDiscovery.ts deleted file mode 100644 index ecfd1c868a..0000000000 --- a/src/services/browser/browserDiscovery.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 => { - // 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 { - // 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 -} diff --git a/src/shared/__tests__/modes.spec.ts b/src/shared/__tests__/modes.spec.ts index e1d6612a14..ceb3cacb4d 100644 --- a/src/shared/__tests__/modes.spec.ts +++ b/src/shared/__tests__/modes.spec.ts @@ -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.", diff --git a/src/shared/browserUtils.ts b/src/shared/browserUtils.ts deleted file mode 100644 index 4e071121c1..0000000000 --- a/src/shared/browserUtils.ts +++ /dev/null @@ -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 = { - 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 - } -} diff --git a/src/shared/globalFileNames.ts b/src/shared/globalFileNames.ts index 98b48485f0..75ffc18fde 100644 --- a/src/shared/globalFileNames.ts +++ b/src/shared/globalFileNames.ts @@ -4,4 +4,5 @@ export const GlobalFileNames = { mcpSettings: "mcp_settings.json", customModes: "custom_modes.yaml", taskMetadata: "task_metadata.json", + delegationMetadata: "delegation_metadata.json", } diff --git a/src/shared/tools.ts b/src/shared/tools.ts index a7298e946a..06e3d6c48a 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -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 @@ -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, "path" | "recursive">> } -export interface BrowserActionToolUse extends ToolUse<"browser_action"> { - name: "browser_action" - params: Partial, "action" | "url" | "coordinate" | "text" | "size" | "path">> -} - export interface UseMcpToolToolUse extends ToolUse<"use_mcp_tool"> { name: "use_mcp_tool" params: Partial, "server_name" | "tool_name" | "arguments">> @@ -290,7 +277,6 @@ export const TOOL_DISPLAY_NAMES: Record = { 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 = { 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"], }, diff --git a/webview-ui/browser-panel.html b/webview-ui/browser-panel.html deleted file mode 100644 index 92943abfe3..0000000000 --- a/webview-ui/browser-panel.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Browser Session - - -
- - - \ No newline at end of file diff --git a/webview-ui/src/browser-panel.tsx b/webview-ui/src/browser-panel.tsx deleted file mode 100644 index a7f5af891e..0000000000 --- a/webview-ui/src/browser-panel.tsx +++ /dev/null @@ -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( - - - , -) diff --git a/webview-ui/src/components/browser-session/BrowserPanelStateProvider.tsx b/webview-ui/src/components/browser-session/BrowserPanelStateProvider.tsx deleted file mode 100644 index 8430c772aa..0000000000 --- a/webview-ui/src/components/browser-session/BrowserPanelStateProvider.tsx +++ /dev/null @@ -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(undefined) - -export const BrowserPanelStateProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [state, setState] = useState({ - 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 {children} -} - -export const useBrowserPanelState = () => { - const context = useContext(BrowserPanelStateContext) - if (context === undefined) { - throw new Error("useBrowserPanelState must be used within a BrowserPanelStateProvider") - } - return context -} diff --git a/webview-ui/src/components/browser-session/BrowserSessionPanel.tsx b/webview-ui/src/components/browser-session/BrowserSessionPanel.tsx deleted file mode 100644 index d9667c56f1..0000000000 --- a/webview-ui/src/components/browser-session/BrowserSessionPanel.tsx +++ /dev/null @@ -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({ - messages: [], - }) - // Target page index to navigate BrowserSessionRow to - const [navigateToStepIndex, setNavigateToStepIndex] = useState(undefined) - - const [expandedRows, setExpandedRows] = useState>({}) - - 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 ( -
- expandedRows[messageTs] ?? false} - onToggleExpand={(messageTs: number) => { - setExpandedRows((prev: Record) => ({ - ...prev, - [messageTs]: !prev[messageTs], - })) - }} - fullScreen={true} - browserViewportSizeProp={browserViewportSize} - isBrowserSessionActiveProp={isBrowserSessionActive} - navigateToPageIndex={navigateToStepIndex} - /> -
- ) -} - -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 ( - - - - - - - - - - - - ) -} - -export default BrowserSessionPanel diff --git a/webview-ui/src/components/chat/AutoApproveDropdown.tsx b/webview-ui/src/components/chat/AutoApproveDropdown.tsx index 857eb5cfb1..8a5b8adfd6 100644 --- a/webview-ui/src/components/chat/AutoApproveDropdown.tsx +++ b/webview-ui/src/components/chat/AutoApproveDropdown.tsx @@ -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, diff --git a/webview-ui/src/components/chat/BrowserActionRow.tsx b/webview-ui/src/components/chat/BrowserActionRow.tsx deleted file mode 100644 index abc0983280..0000000000 --- a/webview-ui/src/components/chat/BrowserActionRow.tsx +++ /dev/null @@ -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 - case "type": - case "press": - return - case "scroll_down": - return - case "scroll_up": - return - case "launch": - return - case "close": - return - case "resize": - return - case "screenshot": - return - case "hover": - default: - return - } -} - -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(() => { - 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 ( -
- {/* Header with action description - clicking opens Browser Session panel at this step */} -
{ - const idx = typeof actionIndex === "number" ? Math.max(0, actionIndex - 1) : 0 - vscode.postMessage({ type: "showBrowserSessionPanelAtStep", stepIndex: idx, forceShow: true }) - }}> - - {t("chat:browser.actions.title")} - {actionIndex !== undefined && totalActions !== undefined && ( - - {" "} - - {actionIndex}/{totalActions} -{" "} - - )} - {browserAction && ( - <> - {getActionIcon(browserAction.action)} - {actionText} - - )} -
-
- ) -}) - -BrowserActionRow.displayName = "BrowserActionRow" - -export default BrowserActionRow diff --git a/webview-ui/src/components/chat/BrowserSessionRow.tsx b/webview-ui/src/components/chat/BrowserSessionRow.tsx deleted file mode 100644 index cf67abdc58..0000000000 --- a/webview-ui/src/components/chat/BrowserSessionRow.tsx +++ /dev/null @@ -1,1137 +0,0 @@ -import React, { memo, useEffect, useMemo, useRef, useState } from "react" -import deepEqual from "fast-deep-equal" -import { useTranslation } from "react-i18next" -import type { TFunction } from "i18next" - -import type { ClineMessage, BrowserAction, BrowserActionResult, ClineSayBrowserAction } from "@roo-code/types" - -import { vscode } from "@src/utils/vscode" -import { useExtensionState } from "@src/context/ExtensionStateContext" - -import CodeBlock from "../common/CodeBlock" -import { ProgressIndicator } from "./ProgressIndicator" -import { Button, StandardTooltip } from "@src/components/ui" -import { getViewportCoordinate as getViewportCoordinateShared, prettyKey } from "@roo/browserUtils" -import { - Globe, - Pointer, - SquareTerminal, - MousePointer as MousePointerIcon, - Keyboard, - ArrowDown, - ArrowUp, - Play, - Check, - Maximize2, - OctagonX, - ArrowLeft, - ArrowRight, - ChevronsLeft, - ChevronsRight, - ExternalLink, - Copy, - Camera, -} from "lucide-react" - -const getBrowserActionText = ( - t: TFunction, - action: BrowserAction, - executedCoordinate?: string, - coordinate?: string, - text?: string, - size?: string, - viewportWidth?: number, - viewportHeight?: number, -) => { - // Helper to scale coordinates from screenshot dimensions to viewport dimensions - // Matches the backend's scaleCoordinate function logic - const getViewportCoordinate = (coord?: string): string => - getViewportCoordinateShared(coord, viewportWidth ?? 0, viewportHeight ?? 0) - - switch (action) { - case "launch": - return t("chat:browser.actions.launched") - case "click": - return t("chat:browser.actions.clicked", { - coordinate: executedCoordinate || getViewportCoordinate(coordinate), - }) - case "type": - return t("chat:browser.actions.typed", { text }) - case "press": - return t("chat:browser.actions.pressed", { key: prettyKey(text) }) - case "scroll_down": - return t("chat:browser.actions.scrolledDown") - case "scroll_up": - return t("chat:browser.actions.scrolledUp") - case "hover": - return t("chat:browser.actions.hovered", { - coordinate: executedCoordinate || getViewportCoordinate(coordinate), - }) - case "resize": - return t("chat:browser.actions.resized", { size: size?.split(/[x,]/).join(" x ") }) - case "screenshot": - return t("chat:browser.actions.screenshotSaved") - case "close": - return t("chat:browser.actions.closed") - default: - return action - } -} - -const getActionIcon = (action: BrowserAction) => { - switch (action) { - case "click": - return - case "type": - case "press": - return - case "scroll_down": - return - case "scroll_up": - return - case "launch": - return - case "close": - return - case "resize": - return - case "screenshot": - return - case "hover": - default: - return - } -} - -interface BrowserSessionRowProps { - messages: ClineMessage[] - isExpanded: (messageTs: number) => boolean - onToggleExpand: (messageTs: number) => void - lastModifiedMessage?: ClineMessage - isLast: boolean - onHeightChange?: (isTaller: boolean) => void - isStreaming: boolean - onExpandChange?: (expanded: boolean) => void - fullScreen?: boolean - // Optional props for standalone panel (when not using ExtensionStateContext) - browserViewportSizeProp?: string - isBrowserSessionActiveProp?: boolean - // Optional: navigate to a specific page index (used by Browser Session panel) - navigateToPageIndex?: number -} - -const BrowserSessionRow = memo((props: BrowserSessionRowProps) => { - const { messages, isLast, onHeightChange, lastModifiedMessage, onExpandChange, fullScreen } = props - const { t } = useTranslation() - const prevHeightRef = useRef(0) - const [consoleLogsExpanded, setConsoleLogsExpanded] = useState(false) - const [nextActionsExpanded, setNextActionsExpanded] = useState(false) - const [logFilter, setLogFilter] = useState<"all" | "debug" | "info" | "warn" | "error" | "log">("all") - // Track screenshot container size for precise cursor positioning with object-fit: contain - const screenshotRef = useRef(null) - const [sW, setSW] = useState(0) - const [sH, setSH] = useState(0) - - // Auto-expand drawer when in fullScreen takeover mode so content is visible immediately - useEffect(() => { - if (fullScreen) { - setNextActionsExpanded(true) - } - }, [fullScreen]) - - // Observe screenshot container size to align cursor correctly with letterboxing - useEffect(() => { - const el = screenshotRef.current - if (!el) return - const update = () => { - const r = el.getBoundingClientRect() - setSW(r.width) - setSH(r.height) - } - update() - const ro = - typeof window !== "undefined" && "ResizeObserver" in window ? new ResizeObserver(() => update()) : null - if (ro) ro.observe(el) - return () => { - if (ro) ro.disconnect() - } - }, []) - - // Try to use ExtensionStateContext if available, otherwise use props - let browserViewportSize = props.browserViewportSizeProp || "900x600" - let isBrowserSessionActive = props.isBrowserSessionActiveProp || false - - try { - const extensionState = useExtensionState() - browserViewportSize = extensionState.browserViewportSize || "900x600" - isBrowserSessionActive = extensionState.isBrowserSessionActive || false - } catch (_e) { - // Not in ExtensionStateContext, use props - } - - const [viewportWidth, viewportHeight] = browserViewportSize.split("x").map(Number) - const defaultMousePosition = `${Math.round(viewportWidth / 2)},${Math.round(viewportHeight / 2)}` - - const isLastApiReqInterrupted = useMemo(() => { - // Check if last api_req_started is cancelled - const lastApiReqStarted = [...messages].reverse().find((m) => m.say === "api_req_started") - if (lastApiReqStarted?.text) { - const info = JSON.parse(lastApiReqStarted.text) as { cancelReason: string | null } - if (info && info.cancelReason !== null) { - return true - } - } - const lastApiReqFailed = isLast && lastModifiedMessage?.ask === "api_req_failed" - if (lastApiReqFailed) { - return true - } - return false - }, [messages, lastModifiedMessage, isLast]) - - const isBrowsing = useMemo(() => { - return isLast && messages.some((m) => m.say === "browser_action_result") && !isLastApiReqInterrupted // after user approves, browser_action_result with "" is sent to indicate that the session has started - }, [isLast, messages, isLastApiReqInterrupted]) - - // Organize messages into pages based on ALL browser actions (including those without screenshots) - const pages = useMemo(() => { - const result: { - url?: string - screenshot?: string - mousePosition?: string - consoleLogs?: string - action?: ClineSayBrowserAction - size?: string - viewportWidth?: number - viewportHeight?: number - }[] = [] - - // Build pages from browser_action messages and pair with results - messages.forEach((message) => { - if (message.say === "browser_action") { - try { - const action = JSON.parse(message.text || "{}") as ClineSayBrowserAction - // Find the corresponding result message - const resultMessage = messages.find( - (m) => m.say === "browser_action_result" && m.ts > message.ts && m.text !== "", - ) - - if (resultMessage) { - const resultData = JSON.parse(resultMessage.text || "{}") as BrowserActionResult - result.push({ - url: resultData.currentUrl, - screenshot: resultData.screenshot, - mousePosition: resultData.currentMousePosition, - consoleLogs: resultData.logs, - action, - size: action.size, - viewportWidth: resultData.viewportWidth, - viewportHeight: resultData.viewportHeight, - }) - } else { - // For actions without results (like close), add a page without screenshot - result.push({ action, size: action.size }) - } - } catch { - // ignore parse errors - } - } - }) - - // Add placeholder page if no actions yet - if (result.length === 0) { - result.push({}) - } - - return result - }, [messages]) - - // Page index + user navigation guard (don't auto-jump while exploring history) - const [currentPageIndex, setCurrentPageIndex] = useState(0) - const hasUserNavigatedRef = useRef(false) - const didInitIndexRef = useRef(false) - const prevPagesLengthRef = useRef(0) - - useEffect(() => { - // Initialize to last page on mount - if (!didInitIndexRef.current && pages.length > 0) { - didInitIndexRef.current = true - setCurrentPageIndex(pages.length - 1) - prevPagesLengthRef.current = pages.length - return - } - - // Auto-advance if user is on the most recent step and a new step arrives - if (pages.length > prevPagesLengthRef.current) { - const wasOnLastPage = currentPageIndex === prevPagesLengthRef.current - 1 - if (wasOnLastPage && !hasUserNavigatedRef.current) { - // User was on the most recent step, auto-advance to the new step - setCurrentPageIndex(pages.length - 1) - } - prevPagesLengthRef.current = pages.length - } - }, [pages.length, currentPageIndex]) - - // External navigation request (from panel host) - // Only navigate when navigateToPageIndex actually changes, not when pages.length changes - const prevNavigateToPageIndexRef = useRef() - useEffect(() => { - if ( - typeof props.navigateToPageIndex === "number" && - props.navigateToPageIndex !== prevNavigateToPageIndexRef.current && - pages.length > 0 - ) { - const idx = Math.max(0, Math.min(pages.length - 1, props.navigateToPageIndex)) - setCurrentPageIndex(idx) - // Only reset manual navigation guard if navigating to the last page - // This allows auto-advance to work when clicking to the most recent step - // but prevents unwanted auto-advance when viewing historical steps - if (idx === pages.length - 1) { - hasUserNavigatedRef.current = false - } - prevNavigateToPageIndexRef.current = props.navigateToPageIndex - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.navigateToPageIndex]) - - // Get initial URL from launch message - const initialUrl = useMemo(() => { - const launchMessage = messages.find((m) => m.ask === "browser_action_launch") - return launchMessage?.text || "" - }, [messages]) - - const currentPage = pages[currentPageIndex] - - // Use actual viewport dimensions from result if available, otherwise fall back to settings - - // Find the last available screenshot and its associated data to use as placeholders - const lastPageWithScreenshot = useMemo(() => { - for (let i = pages.length - 1; i >= 0; i--) { - if (pages[i].screenshot) { - return pages[i] - } - } - return undefined - }, [pages]) - - // Find last mouse position up to current page (not from future pages) - const lastPageWithMousePositionUpToCurrent = useMemo(() => { - for (let i = currentPageIndex; i >= 0; i--) { - if (pages[i].mousePosition) { - return pages[i] - } - } - return undefined - }, [pages, currentPageIndex]) - - // Display state from current page, with smart fallbacks - const displayState = { - url: currentPage?.url || initialUrl, - mousePosition: - currentPage?.mousePosition || lastPageWithMousePositionUpToCurrent?.mousePosition || defaultMousePosition, - consoleLogs: currentPage?.consoleLogs, - screenshot: currentPage?.screenshot || lastPageWithScreenshot?.screenshot, - } - - // Parse logs for counts and filtering - const parsedLogs = useMemo(() => { - const counts = { debug: 0, info: 0, warn: 0, error: 0, log: 0 } - const byType: Record<"debug" | "info" | "warn" | "error" | "log", string[]> = { - debug: [], - info: [], - warn: [], - error: [], - log: [], - } - const raw = displayState.consoleLogs || "" - raw.split(/\r?\n/).forEach((line) => { - const trimmed = line.trim() - if (!trimmed) return - const m = /^\[([^\]]+)\]\s*/i.exec(trimmed) - let type = (m?.[1] || "").toLowerCase() - if (type === "warning") type = "warn" - if (!["debug", "info", "warn", "error", "log"].includes(type)) type = "log" - counts[type as keyof typeof counts]++ - byType[type as keyof typeof byType].push(line) - }) - return { counts, byType } - }, [displayState.consoleLogs]) - - const logsToShow = useMemo(() => { - if (!displayState.consoleLogs) return t("chat:browser.noNewLogs") as string - if (logFilter === "all") return displayState.consoleLogs - const arr = parsedLogs.byType[logFilter] - return arr.length ? arr.join("\n") : (t("chat:browser.noNewLogs") as string) - }, [displayState.consoleLogs, logFilter, parsedLogs, t]) - - // Meta for log badges (include "All" first) - const logTypeMeta = [ - { key: "all", label: "All" }, - { key: "debug", label: "Debug" }, - { key: "info", label: "Info" }, - { key: "warn", label: "Warn" }, - { key: "error", label: "Error" }, - { key: "log", label: "Log" }, - ] as const - - // Use a fixed standard aspect ratio and dimensions for the drawer to prevent flickering - // Even if viewport changes, the drawer maintains consistent size - const fixedDrawerWidth = 900 - const fixedDrawerHeight = 600 - const drawerAspectRatio = (fixedDrawerHeight / fixedDrawerWidth) * 100 - - // For cursor positioning, use the viewport dimensions from the same page as the data we're displaying - // This ensures cursor position matches the screenshot/mouse position being shown - let cursorViewportWidth: number - let cursorViewportHeight: number - - if (currentPage?.screenshot) { - // Current page has screenshot - use its dimensions - cursorViewportWidth = currentPage.viewportWidth ?? viewportWidth - cursorViewportHeight = currentPage.viewportHeight ?? viewportHeight - } else if (lastPageWithScreenshot) { - // Using placeholder screenshot - use dimensions from that page - cursorViewportWidth = lastPageWithScreenshot.viewportWidth ?? viewportWidth - cursorViewportHeight = lastPageWithScreenshot.viewportHeight ?? viewportHeight - } else { - // No screenshot available - use default settings - cursorViewportWidth = viewportWidth - cursorViewportHeight = viewportHeight - } - - // Get browser action for current page (now stored in pages array) - const currentPageAction = useMemo(() => { - return pages[currentPageIndex]?.action - }, [pages, currentPageIndex]) - - // Latest non-close browser_action for header summary (fallback) - - const lastBrowserActionOverall = useMemo(() => { - const all = messages.filter((m) => m.say === "browser_action") - return all.at(-1) - }, [messages]) - - // Use actual Playwright session state from extension (not message parsing) - const isBrowserSessionOpen = isBrowserSessionActive - - // Check if a browser action is currently in flight (for spinner) - const isActionRunning = useMemo(() => { - if (!lastBrowserActionOverall || isLastApiReqInterrupted) { - return false - } - - // Find the last browser_action_result (including empty text) to detect completion - const lastBrowserActionResult = [...messages].reverse().find((m) => m.say === "browser_action_result") - - if (!lastBrowserActionResult) { - // We have at least one action, but haven't seen any result yet - return true - } - - // If the last action happened after the last result, it's still running - return lastBrowserActionOverall.ts > lastBrowserActionResult.ts - }, [messages, lastBrowserActionOverall, isLastApiReqInterrupted]) - - // Browser session drawer never auto-expands - user must manually toggle it - - // Calculate total API cost for the browser session - const totalApiCost = useMemo(() => { - let total = 0 - messages.forEach((message) => { - if (message.say === "api_req_started" && message.text) { - try { - const data = JSON.parse(message.text) - if (data.cost && typeof data.cost === "number") { - total += data.cost - } - } catch { - // Ignore parsing errors - } - } - }) - return total - }, [messages]) - - // Local size tracking without react-use to avoid timers after unmount in tests - const containerRef = useRef(null) - const [rowHeight, setRowHeight] = useState(0) - useEffect(() => { - const el = containerRef.current - if (!el) return - let mounted = true - const setH = (h: number) => { - if (mounted) setRowHeight(h) - } - const ro = - typeof window !== "undefined" && "ResizeObserver" in window - ? new ResizeObserver((entries) => { - const entry = entries[0] - setH(entry?.contentRect?.height ?? el.getBoundingClientRect().height) - }) - : null - // initial - setH(el.getBoundingClientRect().height) - if (ro) ro.observe(el) - return () => { - mounted = false - if (ro) ro.disconnect() - } - }, []) - - const BrowserSessionHeader: React.FC = () => ( -
- {/* Globe icon - green when browser session is open */} - - setNextActionsExpanded((v) => { - const nv = !v - onExpandChange?.(nv) - return nv - }), - })} - /> - - {/* Simple text: "Browser Session" with step counter */} - - setNextActionsExpanded((v) => { - const nv = !v - onExpandChange?.(nv) - return nv - }), - })} - style={{ - flex: 1, - fontSize: 13, - fontWeight: 500, - lineHeight: "22px", - color: "var(--vscode-editor-foreground)", - cursor: fullScreen ? "default" : "pointer", - display: "flex", - alignItems: "center", - gap: 8, - }}> - {t("chat:browser.session")} - {isActionRunning && ( - - )} - {pages.length > 0 && ( - - {currentPageIndex + 1}/{pages.length} - - )} - {/* Inline action summary to the right, similar to ChatView */} - - {(() => { - const action = currentPageAction - const pageSize = pages[currentPageIndex]?.size - const pageViewportWidth = pages[currentPageIndex]?.viewportWidth - const pageViewportHeight = pages[currentPageIndex]?.viewportHeight - if (action) { - return ( - <> - {getActionIcon(action.action)} - - {getBrowserActionText( - t, - action.action, - action.executedCoordinate, - action.coordinate, - action.text, - pageSize, - pageViewportWidth, - pageViewportHeight, - )} - - - ) - } else if (initialUrl) { - return ( - <> - {getActionIcon("launch" as any)} - {getBrowserActionText(t, "launch", undefined, initialUrl, undefined)} - - ) - } - return null - })()} - - - - {/* Right side: cost badge and chevron */} - {totalApiCost > 0 && ( -
- ${totalApiCost.toFixed(4)} -
- )} - - {/* Chevron toggle hidden in fullScreen */} - {!fullScreen && ( - - setNextActionsExpanded((v) => { - const nv = !v - onExpandChange?.(nv) - return nv - }) - } - className={`codicon ${nextActionsExpanded ? "codicon-chevron-up" : "codicon-chevron-down"}`} - style={{ - fontSize: 13, - fontWeight: 500, - lineHeight: "22px", - color: "var(--vscode-editor-foreground)", - cursor: "pointer", - display: "inline-block", - transition: "transform 150ms ease", - }} - /> - )} - - {/* Kill browser button hidden from header in fullScreen; kept in toolbar */} - {isBrowserSessionOpen && !fullScreen && ( - - - - )} -
- ) - - const BrowserSessionDrawer: React.FC = () => { - if (!nextActionsExpanded) return null - - return ( -
- {/* Browser-like Toolbar */} -
- {/* Go to beginning */} - - - - - {/* Back */} - - - - - {/* Forward */} - - - - - {/* Go to end */} - - - - - {/* Address Bar */} -
- - - {displayState.url || "about:blank"} - - {/* Step counter removed */} -
- - {/* Kill (Disconnect) replaces Reload */} - - - - - {/* Open External */} - - - - - {/* Copy URL */} - - - -
- {/* Screenshot Area */} -
- {displayState.screenshot ? ( - {t("chat:browser.screenshot")} - vscode.postMessage({ - type: "openImage", - text: displayState.screenshot, - }) - } - /> - ) : ( -
- -
- )} - {displayState.mousePosition && - (() => { - // Use measured size if available; otherwise fall back to current client size so cursor remains visible - const containerW = sW || (screenshotRef.current?.clientWidth ?? 0) - const containerH = sH || (screenshotRef.current?.clientHeight ?? 0) - if (containerW <= 0 || containerH <= 0) { - // Minimal fallback to keep cursor visible before first measurement - return ( - - ) - } - - // Compute displayed image box within the container for object-fit: contain; objectPosition: top center - const imgAspect = cursorViewportWidth / cursorViewportHeight - const containerAspect = containerW / containerH - let displayW = containerW - let displayH = containerH - let offsetX = 0 - let offsetY = 0 - if (containerAspect > imgAspect) { - // Full height, letterboxed left/right; top aligned - displayH = containerH - displayW = containerH * imgAspect - offsetX = (containerW - displayW) / 2 - offsetY = 0 - } else { - // Full width, potential space below; top aligned - displayW = containerW - displayH = containerW / imgAspect - offsetX = 0 - offsetY = 0 - } - - // Parse "x,y" or "x,y@widthxheight" for original basis - const m = /^\s*(\d+)\s*,\s*(\d+)(?:\s*@\s*(\d+)\s*[x,]\s*(\d+))?\s*$/.exec( - displayState.mousePosition || "", - ) - const mx = parseInt(m?.[1] || "0", 10) - const my = parseInt(m?.[2] || "0", 10) - const baseW = m?.[3] ? parseInt(m[3], 10) : cursorViewportWidth - const baseH = m?.[4] ? parseInt(m[4], 10) : cursorViewportHeight - - const leftPx = offsetX + (baseW > 0 ? (mx / baseW) * displayW : 0) - const topPx = offsetY + (baseH > 0 ? (my / baseH) * displayH : 0) - - return ( - - ) - })()} -
- - {/* Browser Action summary moved inline to header; row removed */} - - {/* Console Logs Section (collapsible, default collapsed) */} -
-
{ - e.stopPropagation() - setConsoleLogsExpanded((v) => !v) - }} - className="text-vscode-editor-foreground/70 hover:text-vscode-editor-foreground transition-colors" - style={{ - display: "flex", - alignItems: "center", - gap: "8px", - marginBottom: consoleLogsExpanded ? "6px" : 0, - cursor: "pointer", - }}> - - - {t("chat:browser.consoleLogs")} - - - {/* Log type indicators */} -
e.stopPropagation()} - style={{ display: "flex", alignItems: "center", gap: 6, marginLeft: "auto" }}> - {logTypeMeta.map(({ key, label }) => { - const isAll = key === "all" - const count = isAll - ? (Object.values(parsedLogs.counts) as number[]).reduce((a, b) => a + b, 0) - : parsedLogs.counts[key as "debug" | "info" | "warn" | "error" | "log"] - const isActive = logFilter === (key as any) - const disabled = count === 0 - return ( - - ) - })} - setConsoleLogsExpanded((v) => !v)} - className={`codicon codicon-chevron-${consoleLogsExpanded ? "down" : "right"}`} - style={{ marginLeft: 6 }} - /> -
-
- {consoleLogsExpanded && ( -
- -
- )} -
-
- ) - } - - const browserSessionRow = ( -
- - - {/* Expanded drawer content - inline/fullscreen */} - -
- ) - - // Height change effect - useEffect(() => { - const isInitialRender = prevHeightRef.current === 0 - if (isLast && rowHeight !== 0 && rowHeight !== Infinity && rowHeight !== prevHeightRef.current) { - if (!isInitialRender) { - onHeightChange?.(rowHeight > prevHeightRef.current) - } - prevHeightRef.current = rowHeight - } - }, [rowHeight, isLast, onHeightChange]) - - return browserSessionRow -}, deepEqual) - -const BrowserCursor: React.FC<{ style?: React.CSSProperties }> = ({ style }) => { - const { t } = useTranslation() - // (can't use svgs in vsc extensions) - const cursorBase64 = - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABUAAAAYCAYAAAAVibZIAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAFaADAAQAAAABAAAAGAAAAADwi9a/AAADGElEQVQ4EZ2VbUiTURTH772be/PxZdsz3cZwC4RVaB8SAjMpxQwSWZbQG/TFkN7oW1Df+h6IRV9C+hCpKUSIZUXOfGM5tAKViijFFEyfZ7Ol29S1Pbdzl8Uw9+aBu91zzv3/nt17zt2DEZjBYOAkKrtFMXIghAWM8U2vMN/FctsxGRMpM7NbEEYNMM2CYUSInlJx3OpawO9i+XSNQYkmk2uFb9njzkcfVSr1p/GJiQKMULVaw2WuBv296UKRxWJR6wxGCmM1EAhSNppv33GBH9qI32cPTAtss9lUm6EM3N7R+RbigT+5/CeosFCZKpjEW+iorS1pb30wDUXzQfHqtD/9L3ieZ2ee1OJCmbL8QHnRs+4uj0wmW4QzrpCwvJ8zGg3JqAmhTLynuLiwv8/5KyND8Q3cEkUEDWu15oJE4KRQJt5hs1rcriGNRqP+DK4dyyWXXm/aFQ+cEpSJ8/LyDGPuEZNOmzsOroUSOqzXG/dtBU4ZysTZYKNut91sNo2Cq6cE9enz86s2g9OCMrFSqVC5hgb32u072W3jKMU90Hb1seC0oUwsB+t92bO/rKx0EFGkgFCnjjc1/gVvC8rE0L+4o63t4InjxwbAJQjTe3qD8QrLkXA4DC24fWtuajp06cLFYSBIFKGmXKPRRmAnME9sPt+yLwIWb9WN69fKoTneQz4Dh2mpPNkvfeV0jjecb9wNAkwIEVQq5VJOds4Kb+DXoAsiVquVwI1Dougpij6UyGYx+5cKroeDEFibm5lWRRMbH1+npmYrq6qhwlQHIbajZEf1fElcqGGFpGg9HMuKzpfBjhytCTMgkJ56RX09zy/ysENTBElmjIgJnmNChJqohDVQqpEfwkILE8v/o0GAnV9F1eEvofVQCbiTBEXOIPQh5PGgefDZeAcjrpGZjULBr/m3tZOnz7oEQWRAQZLjWlEU/XEJWySiILgRc5Cz1DkcAyuBFcnpfF0JiXWKpcolQXizhS5hKAqFpr0MVbgbuxJ6+5xX+P4wNpbqPPrugZfbmIbLmgQR3Aw8QSi66hUXulOFbF73GxqjE5BNXWNeAAAAAElFTkSuQmCC" - - return ( - {t("chat:browser.cursor")} - ) -} - -export default BrowserSessionRow diff --git a/webview-ui/src/components/chat/BrowserSessionStatusRow.tsx b/webview-ui/src/components/chat/BrowserSessionStatusRow.tsx deleted file mode 100644 index 862dc80a62..0000000000 --- a/webview-ui/src/components/chat/BrowserSessionStatusRow.tsx +++ /dev/null @@ -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 ( -
- - - {message.text} - -
- ) -}) - -BrowserSessionStatusRow.displayName = "BrowserSessionStatusRow" - -export default BrowserSessionStatusRow diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 502cd4d82a..63023f2651 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1558,10 +1558,6 @@ export const ChatRowContent = ({ ) - 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 diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 4c0b2bbfd0..c521388206 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -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( modeShortcutText, isEditMode = false, onCancel, - isBrowserSessionActive = false, - showBrowserDockToggle = false, isStreaming = false, onStop, onEnqueueMessage, @@ -1354,12 +1349,6 @@ export const ChatTextArea = forwardRef( )} {!isEditMode ? : null} {!isEditMode && cloudUserInfo && } - {/* keep props referenced after moving browser button */} -
diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index d013ffbe16..fbd7db0743 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -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 0)) { @@ -1179,43 +1164,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - for (let i = 0; i < messages.length; i++) { - if (messages[i].ask === "browser_action_launch") { - return i - } - } - return -1 - }, [messages]) - - const _browserSessionMessages = useMemo(() => { - 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 { + vscode.postMessage({ type: "cancelAutoApproval" }) + }, []) + const itemContent = useCallback( (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 ( - - ) - } - - // Check if this is a browser session status message - if (messageOrGroup.type === "say" && messageOrGroup.say === "browser_session_status") { - return - } - // regular message return ( { 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 = ( )} - {showBrowserGlobe && ( -
e.stopPropagation()}> - - - - {isBrowserSessionActive && ( - - {t("chat:browser.active")} - - )} -
- )} )} {/* Expanded state: Show task text and images */} diff --git a/webview-ui/src/components/chat/__tests__/BrowserSessionRow.aspect-ratio.spec.tsx b/webview-ui/src/components/chat/__tests__/BrowserSessionRow.aspect-ratio.spec.tsx deleted file mode 100644 index 8746586203..0000000000 --- a/webview-ui/src/components/chat/__tests__/BrowserSessionRow.aspect-ratio.spec.tsx +++ /dev/null @@ -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( - - - true} - onToggleExpand={() => {}} - lastModifiedMessage={undefined as any} - isLast={true} - onHeightChange={() => {}} - isStreaming={false} - /> - - , - ) - } - - 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) - }) -}) diff --git a/webview-ui/src/components/chat/__tests__/BrowserSessionRow.disconnect-button.spec.tsx b/webview-ui/src/components/chat/__tests__/BrowserSessionRow.disconnect-button.spec.tsx deleted file mode 100644 index 0c2b4762c4..0000000000 --- a/webview-ui/src/components/chat/__tests__/BrowserSessionRow.disconnect-button.spec.tsx +++ /dev/null @@ -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( - - - false} - onToggleExpand={() => {}} - lastModifiedMessage={undefined as any} - isLast={true} - onHeightChange={() => {}} - isStreaming={false} - /> - - , - ) - } - - 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() - }) -}) diff --git a/webview-ui/src/components/chat/__tests__/BrowserSessionRow.spec.tsx b/webview-ui/src/components/chat/__tests__/BrowserSessionRow.spec.tsx deleted file mode 100644 index 684145f255..0000000000 --- a/webview-ui/src/components/chat/__tests__/BrowserSessionRow.spec.tsx +++ /dev/null @@ -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: () =>
, -})) - -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() - - 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() - - 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() - - expect(screen.queryByTestId("browser-session-spinner")).toBeNull() - }) -}) diff --git a/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx index 96efb00673..78dcce08ae 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx @@ -24,10 +24,6 @@ vi.mock("use-sound", () => ({ })) // Mock components -vi.mock("../BrowserSessionRow", () => ({ - default: () => null, -})) - vi.mock("../ChatRow", () => ({ default: () => null, })) diff --git a/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx index eb3b5df76b..4c4d70f716 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx @@ -49,12 +49,6 @@ vi.mock("use-sound", () => ({ })) // Mock components that use ESM dependencies -vi.mock("../BrowserSessionRow", () => ({ - default: function MockBrowserSessionRow({ messages }: { messages: ClineMessage[] }) { - return
{JSON.stringify(messages)}
- }, -})) - vi.mock("../ChatRow", () => ({ default: function MockChatRow({ message }: { message: ClineMessage }) { return
{JSON.stringify(message)}
diff --git a/webview-ui/src/components/chat/__tests__/ChatView.preserve-images.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.preserve-images.spec.tsx index 4ed1126ded..a167c09c05 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.preserve-images.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.preserve-images.spec.tsx @@ -44,12 +44,6 @@ vi.mock("use-sound", () => ({ })) // Mock components that use ESM dependencies -vi.mock("../BrowserSessionRow", () => ({ - default: function MockBrowserSessionRow({ messages }: { messages: ClineMessage[] }) { - return
{JSON.stringify(messages)}
- }, -})) - vi.mock("../ChatRow", () => ({ default: function MockChatRow({ message }: { message: ClineMessage }) { return
{JSON.stringify(message)}
diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index 1026ac86d0..63e71c9bd1 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx @@ -45,12 +45,6 @@ vi.mock("use-sound", () => ({ })) // Mock components that use ESM dependencies -vi.mock("../BrowserSessionRow", () => ({ - default: function MockBrowserSessionRow({ messages }: { messages: ClineMessage[] }) { - return
{JSON.stringify(messages)}
- }, -})) - vi.mock("../ChatRow", () => ({ default: function MockChatRow({ message }: { message: ClineMessage }) { return
{JSON.stringify(message)}
diff --git a/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx b/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx index c2f2d56f34..e489911268 100644 --- a/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx @@ -592,4 +592,102 @@ describe("FollowUpSuggest", () => { expect(screen.getByText(/3s/)).toBeInTheDocument() }) }) + + describe("auto-approve toggle off mid-countdown", () => { + it("should call onCancelAutoApproval when autoApprovalEnabled changes to false during countdown", async () => { + const { rerender } = renderWithTestProviders( + , + defaultTestState, + ) + + // Should show countdown initially + expect(screen.getByText(/3s/)).toBeInTheDocument() + + // Advance timer partially + await act(async () => { + vi.advanceTimersByTime(1000) + }) + + // Countdown should be at 2s + expect(screen.getByText(/2s/)).toBeInTheDocument() + + // Clear mock to track calls from the toggle-off + mockOnCancelAutoApproval.mockClear() + + // User toggles auto-approve off + rerender( + + + + + , + ) + + // Countdown should disappear + expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument() + + // onCancelAutoApproval should have been called to cancel the backend timeout + expect(mockOnCancelAutoApproval).toHaveBeenCalled() + + // Advance timer past original timeout - nothing should happen + await act(async () => { + vi.advanceTimersByTime(5000) + }) + + // onSuggestionClick should NOT have been called + expect(mockOnSuggestionClick).not.toHaveBeenCalled() + }) + + it("should call onCancelAutoApproval when alwaysAllowFollowupQuestions changes to false during countdown", async () => { + const { rerender } = renderWithTestProviders( + , + defaultTestState, + ) + + // Should show countdown initially + expect(screen.getByText(/3s/)).toBeInTheDocument() + + // Clear mock to track calls from the toggle-off + mockOnCancelAutoApproval.mockClear() + + // User disables follow-up question auto-approval + rerender( + + + + + , + ) + + // Countdown should disappear + expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument() + + // onCancelAutoApproval should have been called to cancel the backend timeout + expect(mockOnCancelAutoApproval).toHaveBeenCalled() + }) + }) }) diff --git a/webview-ui/src/components/settings/AutoApproveSettings.tsx b/webview-ui/src/components/settings/AutoApproveSettings.tsx index daf3d7d64d..40e1658f5f 100644 --- a/webview-ui/src/components/settings/AutoApproveSettings.tsx +++ b/webview-ui/src/components/settings/AutoApproveSettings.tsx @@ -24,7 +24,6 @@ type AutoApproveSettingsProps = HTMLAttributes & { alwaysAllowWrite?: boolean alwaysAllowWriteOutsideWorkspace?: boolean alwaysAllowWriteProtected?: boolean - alwaysAllowBrowser?: boolean alwaysAllowMcp?: boolean alwaysAllowModeSwitch?: boolean alwaysAllowSubtasks?: boolean @@ -41,7 +40,6 @@ type AutoApproveSettingsProps = HTMLAttributes & { | "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 = ({ & { - 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 ( -
- {t("settings:sections.browser")} - -
- - setCachedStateField("browserToolEnabled", e.target.checked)}> - {t("settings:browser.enable.label")} - -
- - - {" "} - - -
-
- - {browserToolEnabled && ( -
- - - -
- {t("settings:browser.viewport.description")} -
-
- - - -
- setCachedStateField("screenshotQuality", value)} - /> - {screenshotQuality ?? 75}% -
-
- {t("settings:browser.screenshotQuality.description")} -
-
- - - { - // 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) - } - }}> - - -
- {t("settings:browser.remote.description")} -
-
- - {remoteBrowserEnabled && ( - <> -
- - setCachedStateField("remoteBrowserHost", e.target.value || undefined) - } - placeholder={t("settings:browser.remote.urlPlaceholder")} - style={{ flexGrow: 1 }} - /> - -
- {testResult && ( -
- {testResult.text} -
- )} -
- {t("settings:browser.remote.instructions")} -
- - )} -
- )} -
-
- ) -} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index eb57b4e000..47e087615e 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -11,7 +11,6 @@ import React, { } from "react" import { CheckCheck, - SquareMousePointer, GitBranch, Bell, Database, @@ -67,7 +66,6 @@ import { SectionHeader } from "./SectionHeader" import ApiConfigManager from "./ApiConfigManager" import ApiOptions from "./ApiOptions" import { AutoApproveSettings } from "./AutoApproveSettings" -import { BrowserSettings } from "./BrowserSettings" import { CheckpointSettings } from "./CheckpointSettings" import { NotificationSettings } from "./NotificationSettings" import { ContextManagementSettings } from "./ContextManagementSettings" @@ -102,7 +100,6 @@ export const sectionNames = [ "autoApprove", "slashCommands", "skills", - "browser", "checkpoints", "notifications", "contextManagement", @@ -157,7 +154,6 @@ const SettingsView = forwardRef(({ onDone, t allowedMaxRequests, allowedMaxCost, language, - alwaysAllowBrowser, alwaysAllowExecute, alwaysAllowMcp, alwaysAllowModeSwitch, @@ -167,16 +163,12 @@ const SettingsView = forwardRef(({ onDone, t alwaysAllowWriteProtected, autoCondenseContext, autoCondenseContextPercent, - browserToolEnabled, - browserViewportSize, enableCheckpoints, checkpointTimeout, experiments, maxOpenTabsContext, maxWorkspaceFiles, mcpEnabled, - remoteBrowserHost, - screenshotQuality, soundEnabled, ttsEnabled, ttsSpeed, @@ -194,7 +186,6 @@ const SettingsView = forwardRef(({ onDone, t writeDelayMs, showRooIgnoredFiles, enableSubfolderRules, - remoteBrowserEnabled, maxImageFileSize, maxTotalImageSize, customSupportPrompts, @@ -379,7 +370,6 @@ const SettingsView = forwardRef(({ onDone, t alwaysAllowWriteOutsideWorkspace: alwaysAllowWriteOutsideWorkspace ?? undefined, alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? undefined, alwaysAllowExecute: alwaysAllowExecute ?? undefined, - alwaysAllowBrowser: alwaysAllowBrowser ?? undefined, alwaysAllowMcp, alwaysAllowModeSwitch, allowedCommands: allowedCommands ?? [], @@ -391,18 +381,13 @@ const SettingsView = forwardRef(({ onDone, t allowedMaxCost: allowedMaxCost ?? null, autoCondenseContext, autoCondenseContextPercent, - browserToolEnabled: browserToolEnabled ?? true, soundEnabled: soundEnabled ?? true, soundVolume: soundVolume ?? 0.5, ttsEnabled, ttsSpeed, enableCheckpoints: enableCheckpoints ?? false, checkpointTimeout: checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, - browserViewportSize: browserViewportSize ?? "900x600", - remoteBrowserHost: remoteBrowserEnabled ? remoteBrowserHost : undefined, - remoteBrowserEnabled: remoteBrowserEnabled ?? false, writeDelayMs, - screenshotQuality: screenshotQuality ?? 75, terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? 30_000, terminalShellIntegrationDisabled, terminalCommandDelay, @@ -529,7 +514,6 @@ const SettingsView = forwardRef(({ onDone, t { id: "slashCommands", icon: SquareSlash }, { id: "autoApprove", icon: CheckCheck }, { id: "mcp", icon: Server }, - { id: "browser", icon: SquareMousePointer }, { id: "checkpoints", icon: GitCommitVertical }, { id: "notifications", icon: Bell }, { id: "contextManagement", icon: Database }, @@ -801,7 +785,6 @@ const SettingsView = forwardRef(({ onDone, t alwaysAllowWrite={alwaysAllowWrite} alwaysAllowWriteOutsideWorkspace={alwaysAllowWriteOutsideWorkspace} alwaysAllowWriteProtected={alwaysAllowWriteProtected} - alwaysAllowBrowser={alwaysAllowBrowser} alwaysAllowMcp={alwaysAllowMcp} alwaysAllowModeSwitch={alwaysAllowModeSwitch} alwaysAllowSubtasks={alwaysAllowSubtasks} @@ -822,18 +805,6 @@ const SettingsView = forwardRef(({ onDone, t {/* Skills Section */} {renderTab === "skills" && } - {/* Browser Section */} - {renderTab === "browser" && ( - - )} - {/* Checkpoints Section */} {renderTab === "checkpoints" && ( { const initialProps = { alwaysAllowReadOnly: true, alwaysAllowWrite: false, - alwaysAllowBrowser: false, alwaysAllowMcp: false, alwaysAllowModeSwitch: true, alwaysAllowSubtasks: false, diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx index be725ea6a1..ae4c324ef8 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx @@ -152,9 +152,6 @@ vi.mock("../Section", () => ({ })) // Mock all settings components -vi.mock("../BrowserSettings", () => ({ - BrowserSettings: () => null, -})) vi.mock("../CheckpointSettings", () => ({ CheckpointSettings: () => null, })) @@ -209,7 +206,6 @@ describe("SettingsView - Change Detection Fix", () => { allowedMaxRequests: undefined, allowedMaxCost: undefined, language: "en", - alwaysAllowBrowser: false, alwaysAllowExecute: false, alwaysAllowMcp: false, alwaysAllowModeSwitch: false, @@ -219,15 +215,11 @@ describe("SettingsView - Change Detection Fix", () => { alwaysAllowWriteProtected: false, autoCondenseContext: false, autoCondenseContextPercent: 50, - browserToolEnabled: false, - browserViewportSize: "1280x720", enableCheckpoints: false, experiments: {}, maxOpenTabsContext: 10, maxWorkspaceFiles: 200, mcpEnabled: false, - remoteBrowserHost: "", - screenshotQuality: 75, soundEnabled: false, ttsEnabled: false, ttsSpeed: 1.0, @@ -245,7 +237,6 @@ describe("SettingsView - Change Detection Fix", () => { terminalZdotdir: false, writeDelayMs: 0, showRooIgnoredFiles: false, - remoteBrowserEnabled: false, maxReadFileLine: -1, maxImageFileSize: 5, maxTotalImageSize: 20, diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx index f1e1377550..83e2655237 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx @@ -625,23 +625,6 @@ describe("SettingsView - Allowed Commands", () => { // Check that unsaved changes dialog is shown expect(screen.getByText("settings:unsavedChangesDialog.title")).toBeInTheDocument() }) - - it("renders with targetSection prop", () => { - // Render with a specific target section - render( - - - - - , - ) - - // Hydrate initial state - mockPostMessage({}) - - // Verify browser-related content is visible and API config is not - expect(screen.queryByTestId("api-config-management")).not.toBeInTheDocument() - }) }) }) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx index 437404e0e7..99e1f7424b 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx @@ -149,9 +149,6 @@ vi.mock("../ApiOptions", () => ({ vi.mock("../AutoApproveSettings", () => ({ AutoApproveSettings: vi.fn(() =>
AutoApproveSettings
), })) -vi.mock("../BrowserSettings", () => ({ - BrowserSettings: vi.fn(() =>
BrowserSettings
), -})) vi.mock("../CheckpointSettings", () => ({ CheckpointSettings: vi.fn(() =>
CheckpointSettings
), })) @@ -214,7 +211,6 @@ describe("SettingsView - Unsaved Changes Detection", () => { allowedMaxRequests: undefined, allowedMaxCost: undefined, language: "en", - alwaysAllowBrowser: false, alwaysAllowExecute: false, alwaysAllowMcp: false, alwaysAllowModeSwitch: false, @@ -224,15 +220,11 @@ describe("SettingsView - Unsaved Changes Detection", () => { alwaysAllowWriteProtected: false, autoCondenseContext: false, autoCondenseContextPercent: 50, - browserToolEnabled: false, - browserViewportSize: "1280x720", enableCheckpoints: false, experiments: {}, maxOpenTabsContext: 10, maxWorkspaceFiles: 200, mcpEnabled: false, - remoteBrowserHost: "", - screenshotQuality: 75, soundEnabled: false, ttsEnabled: false, ttsSpeed: 1.0, @@ -250,7 +242,6 @@ describe("SettingsView - Unsaved Changes Detection", () => { terminalZdotdir: false, writeDelayMs: 0, showRooIgnoredFiles: false, - remoteBrowserEnabled: false, maxReadFileLine: -1, maxImageFileSize: 5, maxTotalImageSize: 20, diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 85a750065f..2a48d59d09 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -67,11 +67,9 @@ export interface ExtensionStateContextType extends ExtensionState { setAlwaysAllowWrite: (value: boolean) => void setAlwaysAllowWriteOutsideWorkspace: (value: boolean) => void setAlwaysAllowExecute: (value: boolean) => void - setAlwaysAllowBrowser: (value: boolean) => void setAlwaysAllowMcp: (value: boolean) => void setAlwaysAllowModeSwitch: (value: boolean) => void setAlwaysAllowSubtasks: (value: boolean) => void - setBrowserToolEnabled: (value: boolean) => void setShowRooIgnoredFiles: (value: boolean) => void setEnableSubfolderRules: (value: boolean) => void setShowAnnouncement: (value: boolean) => void @@ -92,10 +90,7 @@ export interface ExtensionStateContextType extends ExtensionState { setEnableCheckpoints: (value: boolean) => void checkpointTimeout: number setCheckpointTimeout: (value: number) => void - setBrowserViewportSize: (value: string) => void setWriteDelayMs: (value: number) => void - screenshotQuality?: number - setScreenshotQuality: (value: number) => void terminalOutputPreviewSize?: "small" | "medium" | "large" setTerminalOutputPreviewSize: (value: "small" | "medium" | "large") => void mcpEnabled: boolean @@ -122,8 +117,6 @@ export interface ExtensionStateContextType extends ExtensionState { maxWorkspaceFiles: number setMaxWorkspaceFiles: (value: number) => void setTelemetrySetting: (value: TelemetrySetting) => void - remoteBrowserEnabled?: boolean - setRemoteBrowserEnabled: (value: boolean) => void awsUsePromptCache?: boolean setAwsUsePromptCache: (value: boolean) => void maxImageFileSize: number @@ -211,15 +204,12 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode deniedCommands: [], soundEnabled: false, soundVolume: 0.5, - isBrowserSessionActive: false, ttsEnabled: false, ttsSpeed: 1.0, enableCheckpoints: true, checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, // Default to 15 seconds language: "en", // Default language code writeDelayMs: 1000, - browserViewportSize: "900x600", - screenshotQuality: 75, terminalShellIntegrationTimeout: 4000, mcpEnabled: true, remoteControlEnabled: false, @@ -238,7 +228,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode maxOpenTabsContext: 20, maxWorkspaceFiles: 200, cwd: "", - browserToolEnabled: true, telemetrySetting: "unset", showRooIgnoredFiles: true, // Default to showing .rooignore'd files with lock symbol (current behavior). enableSubfolderRules: false, // Default to disabled - must be enabled to load rules from subdirectories @@ -514,7 +503,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode soundVolume: state.soundVolume, ttsSpeed: state.ttsSpeed, writeDelayMs: state.writeDelayMs, - screenshotQuality: state.screenshotQuality, routerModels: extensionRouterModels, cloudIsAuthenticated: state.cloudIsAuthenticated ?? false, cloudOrganizations: state.cloudOrganizations ?? [], @@ -538,7 +526,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setAlwaysAllowWriteOutsideWorkspace: (value) => setState((prevState) => ({ ...prevState, alwaysAllowWriteOutsideWorkspace: value })), setAlwaysAllowExecute: (value) => setState((prevState) => ({ ...prevState, alwaysAllowExecute: value })), - setAlwaysAllowBrowser: (value) => setState((prevState) => ({ ...prevState, alwaysAllowBrowser: value })), setAlwaysAllowMcp: (value) => setState((prevState) => ({ ...prevState, alwaysAllowMcp: value })), setAlwaysAllowModeSwitch: (value) => setState((prevState) => ({ ...prevState, alwaysAllowModeSwitch: value })), setAlwaysAllowSubtasks: (value) => setState((prevState) => ({ ...prevState, alwaysAllowSubtasks: value })), @@ -556,10 +543,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setTtsSpeed: (value) => setState((prevState) => ({ ...prevState, ttsSpeed: value })), setEnableCheckpoints: (value) => setState((prevState) => ({ ...prevState, enableCheckpoints: value })), setCheckpointTimeout: (value) => setState((prevState) => ({ ...prevState, checkpointTimeout: value })), - setBrowserViewportSize: (value: string) => - setState((prevState) => ({ ...prevState, browserViewportSize: value })), setWriteDelayMs: (value) => setState((prevState) => ({ ...prevState, writeDelayMs: value })), - setScreenshotQuality: (value) => setState((prevState) => ({ ...prevState, screenshotQuality: value })), setTerminalOutputPreviewSize: (value) => setState((prevState) => ({ ...prevState, terminalOutputPreviewSize: value })), setTerminalShellIntegrationTimeout: (value) => @@ -583,11 +567,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setCustomModes: (value) => setState((prevState) => ({ ...prevState, customModes: value })), setMaxOpenTabsContext: (value) => setState((prevState) => ({ ...prevState, maxOpenTabsContext: value })), setMaxWorkspaceFiles: (value) => setState((prevState) => ({ ...prevState, maxWorkspaceFiles: value })), - setBrowserToolEnabled: (value) => setState((prevState) => ({ ...prevState, browserToolEnabled: value })), setTelemetrySetting: (value) => setState((prevState) => ({ ...prevState, telemetrySetting: value })), setShowRooIgnoredFiles: (value) => setState((prevState) => ({ ...prevState, showRooIgnoredFiles: value })), setEnableSubfolderRules: (value) => setState((prevState) => ({ ...prevState, enableSubfolderRules: value })), - setRemoteBrowserEnabled: (value) => setState((prevState) => ({ ...prevState, remoteBrowserEnabled: value })), setAwsUsePromptCache: (value) => setState((prevState) => ({ ...prevState, awsUsePromptCache: value })), setMaxImageFileSize: (value) => setState((prevState) => ({ ...prevState, maxImageFileSize: value })), setMaxTotalImageSize: (value) => setState((prevState) => ({ ...prevState, maxTotalImageSize: value })), diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 41dabd7f0c..89a92134ad 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -217,7 +217,6 @@ describe("mergeExtensionState", () => { remoteControlEnabled: false, taskSyncEnabled: false, featureRoomoteControlEnabled: false, - isBrowserSessionActive: false, checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, // Add the checkpoint timeout property } @@ -288,7 +287,6 @@ describe("mergeExtensionState", () => { remoteControlEnabled: false, taskSyncEnabled: false, featureRoomoteControlEnabled: false, - isBrowserSessionActive: false, checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, } diff --git a/webview-ui/src/hooks/__tests__/useAutoApprovalState.spec.ts b/webview-ui/src/hooks/__tests__/useAutoApprovalState.spec.ts index b7fde38b70..50f02f807f 100644 --- a/webview-ui/src/hooks/__tests__/useAutoApprovalState.spec.ts +++ b/webview-ui/src/hooks/__tests__/useAutoApprovalState.spec.ts @@ -8,7 +8,6 @@ describe("useAutoApprovalState", () => { alwaysAllowReadOnly: false, alwaysAllowWrite: false, alwaysAllowExecute: false, - alwaysAllowBrowser: false, alwaysAllowMcp: false, alwaysAllowModeSwitch: false, alwaysAllowSubtasks: false, @@ -25,7 +24,6 @@ describe("useAutoApprovalState", () => { alwaysAllowReadOnly: undefined, alwaysAllowWrite: undefined, alwaysAllowExecute: undefined, - alwaysAllowBrowser: undefined, alwaysAllowMcp: undefined, alwaysAllowModeSwitch: undefined, alwaysAllowSubtasks: undefined, @@ -42,7 +40,6 @@ describe("useAutoApprovalState", () => { alwaysAllowReadOnly: true, alwaysAllowWrite: false, alwaysAllowExecute: false, - alwaysAllowBrowser: false, alwaysAllowMcp: false, alwaysAllowModeSwitch: false, alwaysAllowSubtasks: false, @@ -59,7 +56,6 @@ describe("useAutoApprovalState", () => { alwaysAllowReadOnly: true, alwaysAllowWrite: true, alwaysAllowExecute: true, - alwaysAllowBrowser: false, alwaysAllowMcp: false, alwaysAllowModeSwitch: false, alwaysAllowSubtasks: false, @@ -76,7 +72,6 @@ describe("useAutoApprovalState", () => { alwaysAllowReadOnly: true, alwaysAllowWrite: true, alwaysAllowExecute: true, - alwaysAllowBrowser: true, alwaysAllowMcp: true, alwaysAllowModeSwitch: true, alwaysAllowSubtasks: true, @@ -119,7 +114,6 @@ describe("useAutoApprovalState", () => { alwaysAllowReadOnly: false, alwaysAllowWrite: false, alwaysAllowExecute: false, - alwaysAllowBrowser: false, alwaysAllowMcp: false, alwaysAllowModeSwitch: false, alwaysAllowSubtasks: false, @@ -259,7 +253,7 @@ describe("useAutoApprovalState", () => { alwaysAllowReadOnly: 1 as any, // truthy non-boolean alwaysAllowWrite: "" as any, // falsy non-boolean alwaysAllowExecute: null as any, // falsy non-boolean - alwaysAllowBrowser: "yes" as any, // truthy non-boolean + alwaysAllowMcp: "yes" as any, // truthy non-boolean } const { result } = renderHook(() => useAutoApprovalState(toggles, true)) diff --git a/webview-ui/src/hooks/useAutoApprovalState.ts b/webview-ui/src/hooks/useAutoApprovalState.ts index 067a4a45e0..32d5d9a6cf 100644 --- a/webview-ui/src/hooks/useAutoApprovalState.ts +++ b/webview-ui/src/hooks/useAutoApprovalState.ts @@ -4,7 +4,6 @@ interface AutoApprovalToggles { alwaysAllowReadOnly?: boolean alwaysAllowWrite?: boolean alwaysAllowExecute?: boolean - alwaysAllowBrowser?: boolean alwaysAllowMcp?: boolean alwaysAllowModeSwitch?: boolean alwaysAllowSubtasks?: boolean diff --git a/webview-ui/src/hooks/useAutoApprovalToggles.ts b/webview-ui/src/hooks/useAutoApprovalToggles.ts index 9c3e1c689a..0d21b58b7f 100644 --- a/webview-ui/src/hooks/useAutoApprovalToggles.ts +++ b/webview-ui/src/hooks/useAutoApprovalToggles.ts @@ -10,7 +10,6 @@ export function useAutoApprovalToggles() { alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, - alwaysAllowBrowser, alwaysAllowMcp, alwaysAllowModeSwitch, alwaysAllowSubtasks, @@ -22,7 +21,6 @@ export function useAutoApprovalToggles() { alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, - alwaysAllowBrowser, alwaysAllowMcp, alwaysAllowModeSwitch, alwaysAllowSubtasks, @@ -32,7 +30,6 @@ export function useAutoApprovalToggles() { alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, - alwaysAllowBrowser, alwaysAllowMcp, alwaysAllowModeSwitch, alwaysAllowSubtasks, diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index cc9a215d89..d16a446695 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -363,44 +363,6 @@ "total": "Cost total: ${{cost}}", "includesSubtasks": "Inclou els costos de les subtasques" }, - "browser": { - "session": "Sessió del navegador", - "active": "Actiu", - "rooWantsToUse": "Roo vol utilitzar el navegador", - "consoleLogs": "Registres de consola", - "noNewLogs": "(Cap registre nou)", - "screenshot": "Captura de pantalla del navegador", - "cursor": "cursor", - "navigation": { - "step": "Pas {{current}} de {{total}}", - "previous": "Anterior", - "next": "Següent" - }, - "sessionStarted": "Sessió de navegador iniciada", - "actions": { - "title": "Acció del navegador: ", - "launched": "Navegador iniciat", - "launch": "Iniciar navegador a {{url}}", - "clicked": "Fet clic ({{coordinate}})", - "click": "Clic ({{coordinate}})", - "typed": "Escrit: {{text}}", - "type": "Escriure \"{{text}}\"", - "pressed": "Premut: {{key}}", - "press": "Prem {{key}}", - "scrolledDown": "Desplaçat avall", - "scrollDown": "Desplaçar avall", - "scrolledUp": "Desplaçat amunt", - "scrollUp": "Desplaçar amunt", - "hovered": "Planat sobre ({{coordinate}})", - "hover": "Plana sobre ({{coordinate}})", - "resized": "Mida canviada a: {{size}}", - "resize": "Canvia la mida a {{size}}", - "screenshotSaved": "Captura de pantalla guardada", - "screenshot": "Guarda la captura de pantalla a {{path}}", - "closed": "Navegador tancat", - "close": "Tancar navegador" - } - }, "codeblock": { "tooltips": { "expand": "Expandir bloc de codi", diff --git a/webview-ui/src/i18n/locales/ca/prompts.json b/webview-ui/src/i18n/locales/ca/prompts.json index baef6053fe..f5c9842e90 100644 --- a/webview-ui/src/i18n/locales/ca/prompts.json +++ b/webview-ui/src/i18n/locales/ca/prompts.json @@ -25,7 +25,6 @@ "toolNames": { "read": "Llegir fitxers", "edit": "Editar fitxers", - "browser": "Utilitzar navegador", "command": "Executar comandes", "mcp": "Utilitzar MCP" }, diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index a04fd202cc..f1e5ad10e6 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -31,7 +31,6 @@ "mcp": "Servidors MCP", "worktrees": "Worktrees", "autoApprove": "Auto-aprovació", - "browser": "Accés a l'ordinador", "checkpoints": "Punts de control", "notifications": "Notificacions", "contextManagement": "Context", @@ -241,10 +240,6 @@ "description": "Permetre a Roo crear i editar fitxers protegits (com .rooignore i fitxers de configuració .roo/) sense requerir aprovació." } }, - "browser": { - "label": "Navegador", - "description": "Realitzar accions del navegador automàticament sense requerir aprovació. Nota: Només s'aplica quan el model admet l'ús de l'ordinador" - }, "mcp": { "label": "MCP", "description": "Habilitar l'aprovació automàtica d'eines MCP individuals a la vista de Servidors MCP (requereix tant aquesta configuració com la casella \"Permetre sempre\" de l'eina)" @@ -592,34 +587,6 @@ "maxTokensDescription": "Nombre màxim de tokens de sortida per a les respostes de Claude Code. El valor per defecte és 8000." } }, - "browser": { - "enable": { - "label": "Habilitar eina de navegador", - "description": "Quan està habilitat, Roo pot utilitzar un navegador per interactuar amb llocs web quan s'utilitzen models que admeten l'ús de l'ordinador. <0>Més informació" - }, - "viewport": { - "label": "Mida del viewport", - "description": "Seleccioneu la mida del viewport per a interaccions del navegador. Això afecta com es mostren i interactuen els llocs web.", - "options": { - "largeDesktop": "Escriptori gran (1280x800)", - "smallDesktop": "Escriptori petit (900x600)", - "tablet": "Tauleta (768x1024)", - "mobile": "Mòbil (360x640)" - } - }, - "screenshotQuality": { - "label": "Qualitat de captures de pantalla", - "description": "Ajusteu la qualitat WebP de les captures de pantalla del navegador. Valors més alts proporcionen captures més clares però augmenten l'ús de token." - }, - "remote": { - "label": "Utilitzar connexió remota del navegador", - "description": "Connectar a un navegador Chrome que s'executa amb depuració remota habilitada (--remote-debugging-port=9222).", - "urlPlaceholder": "URL personalitzada (ex. http://localhost:9222)", - "testButton": "Provar connexió", - "testingButton": "Provant...", - "instructions": "Introduïu l'adreça d'amfitrió del protocol DevTools o deixeu-la buida per descobrir automàticament instàncies locals de Chrome. El botó Provar Connexió provarà la URL personalitzada si es proporciona, o descobrirà automàticament si el camp està buit." - } - }, "checkpoints": { "timeout": { "label": "Temps d'espera per inicialitzar el punt de control (segons)", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 54c462e2a7..fb1dd29300 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -363,44 +363,6 @@ "total": "Gesamtkosten: ${{cost}}", "includesSubtasks": "Enthält Kosten für Unteraufgaben" }, - "browser": { - "session": "Browser-Sitzung", - "active": "Aktiv", - "rooWantsToUse": "Roo möchte den Browser verwenden", - "consoleLogs": "Konsolenprotokolle", - "noNewLogs": "(Keine neuen Protokolle)", - "screenshot": "Browser-Screenshot", - "cursor": "Cursor", - "navigation": { - "step": "Schritt {{current}} von {{total}}", - "previous": "Zurück", - "next": "Weiter" - }, - "sessionStarted": "Browser-Sitzung gestartet", - "actions": { - "title": "Browser-Aktion: ", - "launched": "Browser gestartet", - "launch": "Browser starten auf {{url}}", - "clicked": "Geklickt auf: {{coordinate}}", - "click": "Klicken ({{coordinate}})", - "typed": "Eingegeben: {{text}}", - "type": "Eingeben \"{{text}}\"", - "pressed": "{{key}} gedrückt", - "press": "{{key}} drücken", - "scrolledDown": "Nach unten gescrollt", - "scrollDown": "Nach unten scrollen", - "scrolledUp": "Nach oben gescrollt", - "scrollUp": "Nach oben scrollen", - "hovered": "Gehovered auf: {{coordinate}}", - "hover": "Hover ({{coordinate}})", - "resized": "Größe geändert auf: {{size}}", - "resize": "Größe ändern auf {{size}}", - "screenshotSaved": "Screenshot gespeichert", - "screenshot": "Screenshot speichern unter {{path}}", - "closed": "Browser geschlossen", - "close": "Browser schließen" - } - }, "codeblock": { "tooltips": { "expand": "Code-Block erweitern", diff --git a/webview-ui/src/i18n/locales/de/prompts.json b/webview-ui/src/i18n/locales/de/prompts.json index 0418741c44..6e7c4bdc75 100644 --- a/webview-ui/src/i18n/locales/de/prompts.json +++ b/webview-ui/src/i18n/locales/de/prompts.json @@ -25,7 +25,6 @@ "toolNames": { "read": "Dateien lesen", "edit": "Dateien bearbeiten", - "browser": "Browser verwenden", "command": "Befehle ausführen", "mcp": "MCP verwenden" }, diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 8f565ba204..0e8d7a3a8e 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -31,7 +31,6 @@ "mcp": "MCP-Server", "worktrees": "Worktrees", "autoApprove": "Auto-Genehmigung", - "browser": "Computerzugriff", "checkpoints": "Kontrollpunkte", "notifications": "Benachrichtigungen", "contextManagement": "Kontext", @@ -241,10 +240,6 @@ "description": "Roo erlauben, geschützte Dateien (wie .rooignore und .roo/ Konfigurationsdateien) ohne Genehmigung zu erstellen und zu bearbeiten." } }, - "browser": { - "label": "Browser", - "description": "Browser-Aktionen automatisch ohne Genehmigung durchführen. Hinweis: Gilt nur, wenn das Modell Computer-Nutzung unterstützt" - }, "mcp": { "label": "MCP", "description": "Automatische Genehmigung einzelner MCP-Tools in der MCP-Server-Ansicht aktivieren (erfordert sowohl diese Einstellung als auch das 'Immer erlauben'-Kontrollkästchen des Tools)" @@ -592,34 +587,6 @@ "maxTokensDescription": "Maximale Anzahl an Ausgabe-Tokens für Claude Code-Antworten. Standard ist 8000." } }, - "browser": { - "enable": { - "label": "Browser-Tool aktivieren", - "description": "Wenn aktiviert, kann Roo einen Browser verwenden, um mit Websites zu interagieren, wenn Modelle verwendet werden, die Computer-Nutzung unterstützen. <0>Mehr erfahren" - }, - "viewport": { - "label": "Viewport-Größe", - "description": "Wählen Sie die Viewport-Größe für Browser-Interaktionen. Dies beeinflusst, wie Websites angezeigt und mit ihnen interagiert wird.", - "options": { - "largeDesktop": "Großer Desktop (1280x800)", - "smallDesktop": "Kleiner Desktop (900x600)", - "tablet": "Tablet (768x1024)", - "mobile": "Mobil (360x640)" - } - }, - "screenshotQuality": { - "label": "Screenshot-Qualität", - "description": "Passen Sie die WebP-Qualität von Browser-Screenshots an. Höhere Werte bieten klarere Screenshots, erhöhen aber den Token-Verbrauch." - }, - "remote": { - "label": "Remote-Browser-Verbindung verwenden", - "description": "Verbindung zu einem Chrome-Browser herstellen, der mit aktiviertem Remote-Debugging läuft (--remote-debugging-port=9222).", - "urlPlaceholder": "Benutzerdefinierte URL (z.B. http://localhost:9222)", - "testButton": "Verbindung testen", - "testingButton": "Teste...", - "instructions": "Geben Sie die DevTools-Protokoll-Host-Adresse ein oder lassen Sie das Feld leer, um Chrome lokale Instanzen automatisch zu erkennen. Die Schaltfläche 'Verbindung testen' versucht die benutzerdefinierte URL, wenn angegeben, oder erkennt automatisch, wenn das Feld leer ist." - } - }, "checkpoints": { "timeout": { "label": "Timeout für Checkpoint-Initialisierung (Sekunden)", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 0bb3f5bf4b..b597fdbcc3 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -390,44 +390,6 @@ "total": "Total Cost: ${{cost}}", "includesSubtasks": "Includes subtask costs" }, - "browser": { - "session": "Browser Session", - "active": "Active", - "rooWantsToUse": "Roo wants to use the browser", - "consoleLogs": "Console Logs", - "noNewLogs": "(No new logs)", - "screenshot": "Browser screenshot", - "cursor": "cursor", - "navigation": { - "step": "Step {{current}} of {{total}}", - "previous": "Previous", - "next": "Next" - }, - "sessionStarted": "Browser Session Started", - "actions": { - "title": "Browser Action: ", - "launched": "Launched browser", - "launch": "Launch browser at {{url}}", - "clicked": "Clicked at: {{coordinate}}", - "click": "Click ({{coordinate}})", - "typed": "Typed: {{text}}", - "type": "Type \"{{text}}\"", - "pressed": "Pressed key: {{key}}", - "press": "Press {{key}}", - "scrolledDown": "Scrolled down", - "scrollDown": "Scroll down", - "scrolledUp": "Scrolled up", - "scrollUp": "Scroll up", - "hovered": "Hovered at: {{coordinate}}", - "hover": "Hover ({{coordinate}})", - "resized": "Resized to: {{size}}", - "resize": "Resize to {{size}}", - "screenshotSaved": "Screenshot saved", - "screenshot": "Save screenshot to {{path}}", - "closed": "Closed browser", - "close": "Close browser" - } - }, "codeblock": { "tooltips": { "expand": "Expand code block", diff --git a/webview-ui/src/i18n/locales/en/prompts.json b/webview-ui/src/i18n/locales/en/prompts.json index 9fca2be718..82db8400c1 100644 --- a/webview-ui/src/i18n/locales/en/prompts.json +++ b/webview-ui/src/i18n/locales/en/prompts.json @@ -25,7 +25,6 @@ "toolNames": { "read": "Read Files", "edit": "Edit Files", - "browser": "Use Browser", "command": "Run Commands", "mcp": "Use MCP" }, diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 9f72725520..471309718b 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -32,7 +32,6 @@ "worktrees": "Worktrees", "skills": "Skills", "autoApprove": "Auto-Approve", - "browser": "Browser", "checkpoints": "Checkpoints", "notifications": "Notifications", "contextManagement": "Context", @@ -302,10 +301,6 @@ "description": "Allow Roo to create and edit protected files (like .rooignore and .roo/ configuration files) without requiring approval." } }, - "browser": { - "label": "Browser", - "description": "Automatically perform browser actions without requiring approval. Note: Only applies when the model supports computer use" - }, "mcp": { "label": "MCP", "description": "Enable auto-approval of individual MCP tools in the MCP Servers view (requires both this setting and the tool's individual \"Always allow\" checkbox)" @@ -629,34 +624,6 @@ "maxTokensDescription": "Maximum number of output tokens for Claude Code responses. Default is 8000." } }, - "browser": { - "enable": { - "label": "Enable browser tool", - "description": "When enabled, Roo can use a browser to interact with websites when using models that support computer use. <0>Learn more" - }, - "viewport": { - "label": "Viewport size", - "description": "Select the viewport size for browser interactions. This affects how websites are displayed and interacted with.", - "options": { - "largeDesktop": "Large Desktop (1280x800)", - "smallDesktop": "Small Desktop (900x600)", - "tablet": "Tablet (768x1024)", - "mobile": "Mobile (360x640)" - } - }, - "screenshotQuality": { - "label": "Screenshot quality", - "description": "Adjust the WebP quality of browser screenshots. Higher values provide clearer screenshots but increase token usage." - }, - "remote": { - "label": "Use remote browser connection", - "description": "Connect to a Chrome browser running with remote debugging enabled (--remote-debugging-port=9222).", - "urlPlaceholder": "Custom URL (e.g., http://localhost:9222)", - "testButton": "Test Connection", - "testingButton": "Testing...", - "instructions": "Enter the DevTools Protocol host address or leave empty to auto-discover Chrome local instances. The Test Connection button will try the custom URL if provided, or auto-discover if the field is empty." - } - }, "checkpoints": { "timeout": { "label": "Checkpoint initialization timeout (seconds)", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 71dfd95d69..eb8833b833 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -363,44 +363,6 @@ "total": "Costo total: ${{cost}}", "includesSubtasks": "Incluye costos de subtareas" }, - "browser": { - "session": "Sesión del navegador", - "active": "Activo", - "rooWantsToUse": "Roo quiere usar el navegador", - "consoleLogs": "Registros de la consola", - "noNewLogs": "(No hay nuevos registros)", - "screenshot": "Captura de pantalla del navegador", - "cursor": "cursor", - "navigation": { - "step": "Paso {{current}} de {{total}}", - "previous": "Anterior", - "next": "Siguiente" - }, - "sessionStarted": "Sesión de navegador iniciada", - "actions": { - "title": "Acción del navegador: ", - "launched": "Navegador iniciado", - "launch": "Iniciar navegador en {{url}}", - "clicked": "Clic en: {{coordinate}}", - "click": "Clic ({{coordinate}})", - "typed": "Escrito: {{text}}", - "type": "Escribir \"{{text}}\"", - "pressed": "Pulsado: {{key}}", - "press": "Pulsar {{key}}", - "scrolledDown": "Desplazado hacia abajo", - "scrollDown": "Desplazar hacia abajo", - "scrolledUp": "Desplazado hacia arriba", - "scrollUp": "Desplazar hacia arriba", - "hovered": "Flotado en: {{coordinate}}", - "hover": "Flotar ({{coordinate}})", - "resized": "Tamaño cambiado a: {{size}}", - "resize": "Cambiar tamaño a {{size}}", - "screenshotSaved": "Captura de pantalla guardada", - "screenshot": "Guardar captura de pantalla en {{path}}", - "closed": "Navegador cerrado", - "close": "Cerrar navegador" - } - }, "codeblock": { "tooltips": { "expand": "Expandir bloque de código", diff --git a/webview-ui/src/i18n/locales/es/prompts.json b/webview-ui/src/i18n/locales/es/prompts.json index 52badf6e5b..6e2ad48051 100644 --- a/webview-ui/src/i18n/locales/es/prompts.json +++ b/webview-ui/src/i18n/locales/es/prompts.json @@ -25,7 +25,6 @@ "toolNames": { "read": "Leer archivos", "edit": "Editar archivos", - "browser": "Usar navegador", "command": "Ejecutar comandos", "mcp": "Usar MCP" }, diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 55977aae8d..42688dd541 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -31,7 +31,6 @@ "mcp": "Servidores MCP", "worktrees": "Worktrees", "autoApprove": "Auto-aprobación", - "browser": "Acceso al ordenador", "checkpoints": "Puntos de control", "notifications": "Notificaciones", "contextManagement": "Contexto", @@ -241,10 +240,6 @@ "description": "Permitir a Roo crear y editar archivos protegidos (como .rooignore y archivos de configuración .roo/) sin requerir aprobación." } }, - "browser": { - "label": "Navegador", - "description": "Realizar acciones del navegador automáticamente sin requerir aprobación. Nota: Solo se aplica cuando el modelo admite el uso del ordenador" - }, "mcp": { "label": "MCP", "description": "Habilitar la aprobación automática de herramientas MCP individuales en la vista de Servidores MCP (requiere tanto esta configuración como la casilla \"Permitir siempre\" de la herramienta)" @@ -592,34 +587,6 @@ "maxTokensDescription": "Número máximo de tokens de salida para las respuestas de Claude Code. El valor predeterminado es 8000." } }, - "browser": { - "enable": { - "label": "Habilitar herramienta de navegador", - "description": "Cuando está habilitado, Roo puede usar un navegador para interactuar con sitios web cuando se utilizan modelos que admiten el uso del ordenador. <0>Más información" - }, - "viewport": { - "label": "Tamaño del viewport", - "description": "Seleccione el tamaño del viewport para interacciones del navegador. Esto afecta cómo se muestran e interactúan los sitios web.", - "options": { - "largeDesktop": "Escritorio grande (1280x800)", - "smallDesktop": "Escritorio pequeño (900x600)", - "tablet": "Tablet (768x1024)", - "mobile": "Móvil (360x640)" - } - }, - "screenshotQuality": { - "label": "Calidad de capturas de pantalla", - "description": "Ajuste la calidad WebP de las capturas de pantalla del navegador. Valores más altos proporcionan capturas más claras pero aumentan el uso de token." - }, - "remote": { - "label": "Usar conexión remota del navegador", - "description": "Conectarse a un navegador Chrome que se ejecuta con depuración remota habilitada (--remote-debugging-port=9222).", - "urlPlaceholder": "URL personalizada (ej. http://localhost:9222)", - "testButton": "Probar conexión", - "testingButton": "Probando...", - "instructions": "Ingrese la dirección del host del protocolo DevTools o déjelo vacío para descubrir automáticamente instancias locales de Chrome. El botón Probar Conexión intentará usar la URL personalizada si se proporciona, o descubrirá automáticamente si el campo está vacío." - } - }, "checkpoints": { "timeout": { "label": "Tiempo de espera para inicializar el punto de control (segundos)", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index c86f01fa1a..e2858c8e8c 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -363,44 +363,6 @@ "total": "Coût total : ${{cost}}", "includesSubtasks": "Inclut les coûts des sous-tâches" }, - "browser": { - "session": "Session du navigateur", - "active": "Actif", - "rooWantsToUse": "Roo veut utiliser le navigateur", - "consoleLogs": "Journaux de console", - "noNewLogs": "(Pas de nouveaux journaux)", - "screenshot": "Capture d'écran du navigateur", - "cursor": "curseur", - "navigation": { - "step": "Étape {{current}} sur {{total}}", - "previous": "Précédent", - "next": "Suivant" - }, - "sessionStarted": "Session de navigateur démarrée", - "actions": { - "title": "Action du navigateur : ", - "launched": "Navigateur lancé", - "launch": "Lancer le navigateur sur {{url}}", - "clicked": "Cliqué sur : {{coordinate}}", - "click": "Cliquer ({{coordinate}})", - "typed": "Saisi : {{text}}", - "type": "Saisir \"{{text}}\"", - "pressed": "Appuyé sur : {{key}}", - "press": "Appuyer sur {{key}}", - "scrolledDown": "Défilé vers le bas", - "scrollDown": "Défiler vers le bas", - "scrolledUp": "Défilé vers le haut", - "scrollUp": "Défiler vers le haut", - "hovered": "Survolé : {{coordinate}}", - "hover": "Survoler ({{coordinate}})", - "resized": "Redimensionné à : {{size}}", - "resize": "Redimensionner à {{size}}", - "screenshotSaved": "Capture d'écran enregistrée", - "screenshot": "Enregistrer une capture d'écran dans {{path}}", - "closed": "Navigateur fermé", - "close": "Fermer le navigateur" - } - }, "codeblock": { "tooltips": { "expand": "Développer le bloc de code", diff --git a/webview-ui/src/i18n/locales/fr/prompts.json b/webview-ui/src/i18n/locales/fr/prompts.json index ad8bbc245c..5adce7b17a 100644 --- a/webview-ui/src/i18n/locales/fr/prompts.json +++ b/webview-ui/src/i18n/locales/fr/prompts.json @@ -25,7 +25,6 @@ "toolNames": { "read": "Lire les fichiers", "edit": "Modifier les fichiers", - "browser": "Utiliser le navigateur", "command": "Exécuter des commandes", "mcp": "Utiliser MCP" }, diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 1423704dd8..4a213f3180 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -31,7 +31,6 @@ "mcp": "Serveurs MCP", "worktrees": "Worktrees", "autoApprove": "Auto-approbation", - "browser": "Accès ordinateur", "checkpoints": "Points de contrôle", "notifications": "Notifications", "contextManagement": "Contexte", @@ -242,10 +241,6 @@ "description": "Permettre à Roo de créer et modifier des fichiers protégés (comme .rooignore et les fichiers de configuration .roo/) sans nécessiter d'approbation." } }, - "browser": { - "label": "Navigateur", - "description": "Effectuer automatiquement des actions du navigateur sans nécessiter d'approbation. Remarque : S'applique uniquement lorsque le modèle prend en charge l'utilisation de l'ordinateur" - }, "mcp": { "label": "MCP", "description": "Activer l'approbation automatique des outils MCP individuels dans la vue des serveurs MCP (nécessite à la fois ce paramètre et la case à cocher \"Toujours autoriser\" de l'outil)" @@ -592,34 +587,6 @@ "maxTokensDescription": "Nombre maximum de jetons de sortie pour les réponses de Claude Code. La valeur par défaut est 8000." } }, - "browser": { - "enable": { - "label": "Activer l'outil de navigateur", - "description": "Lorsque cette option est activée, Roo peut utiliser un navigateur pour interagir avec des sites web lors de l'utilisation de modèles qui prennent en charge l'utilisation de l'ordinateur. <0>En savoir plus" - }, - "viewport": { - "label": "Taille de la fenêtre d'affichage", - "description": "Sélectionnez la taille de la fenêtre d'affichage pour les interactions du navigateur. Cela affecte la façon dont les sites web sont affichés et dont on interagit avec eux.", - "options": { - "largeDesktop": "Grand bureau (1280x800)", - "smallDesktop": "Petit bureau (900x600)", - "tablet": "Tablette (768x1024)", - "mobile": "Mobile (360x640)" - } - }, - "screenshotQuality": { - "label": "Qualité des captures d'écran", - "description": "Ajustez la qualité WebP des captures d'écran du navigateur. Des valeurs plus élevées fournissent des captures plus claires mais augmentent l'utilisation de token." - }, - "remote": { - "label": "Utiliser une connexion de navigateur distant", - "description": "Se connecter à un navigateur Chrome exécuté avec le débogage à distance activé (--remote-debugging-port=9222).", - "urlPlaceholder": "URL personnalisée (ex. http://localhost:9222)", - "testButton": "Tester la connexion", - "testingButton": "Test en cours...", - "instructions": "Entrez l'adresse hôte du protocole DevTools ou laissez vide pour découvrir automatiquement les instances Chrome locales. Le bouton Tester la connexion essaiera l'URL personnalisée si fournie, ou découvrira automatiquement si le champ est vide." - } - }, "checkpoints": { "timeout": { "label": "Délai d'initialisation du point de contrôle (secondes)", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index e6447438f2..75ee2c9abf 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -363,44 +363,6 @@ "total": "कुल लागत: ${{cost}}", "includesSubtasks": "उप-कार्यों की लागत शामिल है" }, - "browser": { - "session": "ब्राउज़र सत्र", - "active": "सक्रिय", - "rooWantsToUse": "Roo ब्राउज़र का उपयोग करना चाहता है", - "consoleLogs": "कंसोल लॉग", - "noNewLogs": "(कोई नया लॉग नहीं)", - "screenshot": "ब्राउज़र स्क्रीनशॉट", - "cursor": "कर्सर", - "navigation": { - "step": "चरण {{current}} / {{total}}", - "previous": "पिछला", - "next": "अगला" - }, - "sessionStarted": "ब्राउज़र सत्र शुरू हुआ", - "actions": { - "title": "ब्राउज़र क्रिया: ", - "launched": "ब्राउज़र लॉन्च हुआ", - "launch": "{{url}} पर ब्राउज़र लॉन्च करें", - "clicked": "क्लिक किया गया ({{coordinate}})", - "click": "क्लिक करें ({{coordinate}})", - "typed": "टाइप किया गया: {{text}}", - "type": "टाइप करें \"{{text}}\"", - "pressed": "दबाया गया: {{key}}", - "press": "{{key}} दबाएँ", - "scrolledDown": "नीचे स्क्रॉल किया गया", - "scrollDown": "नीचे स्क्रॉल करें", - "scrolledUp": "ऊपर स्क्रॉल किया गया", - "scrollUp": "ऊपर स्क्रॉल करें", - "hovered": "होवर किया गया ({{coordinate}})", - "hover": "होवर करें ({{coordinate}})", - "resized": "आकार बदला गया: {{size}}", - "resize": "आकार बदलें {{size}}", - "screenshotSaved": "स्क्रीनशॉट सहेजा गया", - "screenshot": "स्क्रीनशॉट को {{path}} में सहेजें", - "closed": "ब्राउज़र बंद किया गया", - "close": "ब्राउज़र बंद करें" - } - }, "codeblock": { "tooltips": { "expand": "कोड ब्लॉक का विस्तार करें", diff --git a/webview-ui/src/i18n/locales/hi/prompts.json b/webview-ui/src/i18n/locales/hi/prompts.json index d710f28c2d..8f8fb9caa6 100644 --- a/webview-ui/src/i18n/locales/hi/prompts.json +++ b/webview-ui/src/i18n/locales/hi/prompts.json @@ -25,7 +25,6 @@ "toolNames": { "read": "फाइलें पढ़ें", "edit": "फाइलें संपादित करें", - "browser": "ब्राउज़र का उपयोग करें", "command": "कमांड्स चलाएँ", "mcp": "MCP का उपयोग करें" }, diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 2932913f37..3544dbb768 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -31,7 +31,6 @@ "mcp": "एमसीपी सर्वर", "worktrees": "Worktrees", "autoApprove": "अनुमोदन", - "browser": "ब्राउज़र", "checkpoints": "चेकपॉइंट", "notifications": "सूचनाएँ", "contextManagement": "संदर्भ", @@ -241,10 +240,6 @@ "description": "Roo को अनुमोदन की आवश्यकता के बिना संरक्षित फाइलें (.rooignore और .roo/ कॉन्फ़िगरेशन फाइलें जैसी) बनाने और संपादित करने की अनुमति दें।" } }, - "browser": { - "label": "ब्राउज़र", - "description": "अनुमोदन की आवश्यकता के बिना स्वचालित रूप से ब्राउज़र क्रियाएँ करें — नोट: केवल तभी लागू होता है जब मॉडल कंप्यूटर उपयोग का समर्थन करता है" - }, "mcp": { "label": "MCP", "description": "MCP सर्वर व्यू में व्यक्तिगत MCP टूल्स के स्वतः अनुमोदन को सक्षम करें (इस सेटिंग और टूल के \"हमेशा अनुमति दें\" चेकबॉक्स दोनों की आवश्यकता है)" @@ -592,34 +587,6 @@ "maxTokensDescription": "Claude Code प्रतिक्रियाओं के लिए आउटपुट टोकन की अधिकतम संख्या। डिफ़ॉल्ट 8000 है।" } }, - "browser": { - "enable": { - "label": "ब्राउज़र टूल सक्षम करें", - "description": "जब सक्षम होता है, तो Roo कंप्यूटर उपयोग का समर्थन करने वाले मॉडल का उपयोग करते समय वेबसाइटों के साथ बातचीत करने के लिए ब्राउज़र का उपयोग कर सकता है। <0>अधिक जानें" - }, - "viewport": { - "label": "व्यूपोर्ट आकार", - "description": "ब्राउज़र इंटरैक्शन के लिए व्यूपोर्ट आकार चुनें। यह वेबसाइटों के प्रदर्शन और उनके साथ बातचीत को प्रभावित करता है।", - "options": { - "largeDesktop": "बड़ा डेस्कटॉप (1280x800)", - "smallDesktop": "छोटा डेस्कटॉप (900x600)", - "tablet": "टैबलेट (768x1024)", - "mobile": "मोबाइल (360x640)" - } - }, - "screenshotQuality": { - "label": "स्क्रीनशॉट गुणवत्ता", - "description": "ब्राउज़र स्क्रीनशॉट की WebP गुणवत्ता समायोजित करें। उच्च मान स्पष्ट स्क्रीनशॉट प्रदान करते हैं लेकिन token उपयोग बढ़ाते हैं।" - }, - "remote": { - "label": "दूरस्थ ब्राउज़र कनेक्शन का उपयोग करें", - "description": "रिमोट डीबगिंग सक्षम के साथ चल रहे Chrome ब्राउज़र से कनेक्ट करें (--remote-debugging-port=9222)।", - "urlPlaceholder": "कस्टम URL (उदा. http://localhost:9222)", - "testButton": "कनेक्शन का परीक्षण करें", - "testingButton": "परीक्षण हो रहा है...", - "instructions": "DevTools प्रोटोकॉल होस्ट पता दर्ज करें या Chrome स्थानीय इंस्टेंस स्वतः खोजने के लिए खाली छोड़ दें। टेस्ट कनेक्शन बटन यदि प्रदान किया गया है तो कस्टम URL का प्रयास करेगा, या यदि फ़ील्ड खाली है तो स्वतः खोज करेगा।" - } - }, "checkpoints": { "timeout": { "label": "चेकपॉइंट इनिशियलाइज़ेशन टाइमआउट (सेकंड)", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index dd7fd0ed55..ee41b9ceb6 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -400,44 +400,6 @@ "total": "Total Biaya: ${{cost}}", "includesSubtasks": "Termasuk biaya subtugas" }, - "browser": { - "session": "Sesi Browser", - "active": "Aktif", - "rooWantsToUse": "Roo ingin menggunakan browser", - "consoleLogs": "Log Konsol", - "noNewLogs": "(Tidak ada log baru)", - "screenshot": "Screenshot browser", - "cursor": "kursor", - "navigation": { - "step": "Langkah {{current}} dari {{total}}", - "previous": "Sebelumnya", - "next": "Selanjutnya" - }, - "sessionStarted": "Sesi Browser Dimulai", - "actions": { - "title": "Aksi Browser: ", - "launched": "Browser diluncurkan", - "launch": "Luncurkan browser di {{url}}", - "clicked": "Diklik ({{coordinate}})", - "click": "Klik ({{coordinate}})", - "typed": "Diketik: {{text}}", - "type": "Ketik \"{{text}}\"", - "pressed": "Ditekan: {{key}}", - "press": "Tekan {{key}}", - "scrolledDown": "Digulir ke bawah", - "scrollDown": "Gulir ke bawah", - "scrolledUp": "Digulir ke atas", - "scrollUp": "Gulir ke atas", - "hovered": "Diarahkan ({{coordinate}})", - "hover": "Arahkan ({{coordinate}})", - "resized": "Ukuran diubah ke: {{size}}", - "resize": "Ubah ukuran ke {{size}}", - "screenshotSaved": "Screenshot disimpan", - "screenshot": "Simpan screenshot ke {{path}}", - "closed": "Browser ditutup", - "close": "Tutup browser" - } - }, "codeblock": { "tooltips": { "expand": "Perluas blok kode", diff --git a/webview-ui/src/i18n/locales/id/prompts.json b/webview-ui/src/i18n/locales/id/prompts.json index 28454e744f..938d8de34c 100644 --- a/webview-ui/src/i18n/locales/id/prompts.json +++ b/webview-ui/src/i18n/locales/id/prompts.json @@ -25,7 +25,6 @@ "toolNames": { "read": "Baca File", "edit": "Edit File", - "browser": "Gunakan Browser", "command": "Jalankan Perintah", "mcp": "Gunakan MCP" }, diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 08e8c7ccec..bc5db3ae85 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -31,7 +31,6 @@ "mcp": "Server MCP", "worktrees": "Worktrees", "autoApprove": "Auto-Approve", - "browser": "Browser", "checkpoints": "Checkpoint", "notifications": "Notifikasi", "contextManagement": "Konteks", @@ -241,10 +240,6 @@ "description": "Izinkan Roo membuat dan mengedit file yang dilindungi (seperti .rooignore dan file konfigurasi .roo/) tanpa memerlukan persetujuan." } }, - "browser": { - "label": "Browser", - "description": "Secara otomatis melakukan aksi browser tanpa memerlukan persetujuan. Catatan: Hanya berlaku ketika model mendukung computer use" - }, "mcp": { "label": "MCP", "description": "Aktifkan auto-approval tool MCP individual di tampilan Server MCP (memerlukan pengaturan ini dan checkbox \"Selalu izinkan\" tool tersebut)" @@ -592,34 +587,6 @@ "maxTokensDescription": "Jumlah maksimum token output untuk respons Claude Code. Default adalah 8000." } }, - "browser": { - "enable": { - "label": "Aktifkan tool browser", - "description": "Ketika diaktifkan, Roo dapat menggunakan browser untuk berinteraksi dengan website ketika menggunakan model yang mendukung computer use. <0>Pelajari lebih lanjut" - }, - "viewport": { - "label": "Ukuran viewport", - "description": "Pilih ukuran viewport untuk interaksi browser. Ini mempengaruhi bagaimana website ditampilkan dan berinteraksi.", - "options": { - "largeDesktop": "Desktop Besar (1280x800)", - "smallDesktop": "Desktop Kecil (900x600)", - "tablet": "Tablet (768x1024)", - "mobile": "Mobile (360x640)" - } - }, - "screenshotQuality": { - "label": "Kualitas screenshot", - "description": "Sesuaikan kualitas WebP screenshot browser. Nilai yang lebih tinggi memberikan screenshot yang lebih jelas tetapi meningkatkan penggunaan token." - }, - "remote": { - "label": "Gunakan koneksi browser remote", - "description": "Hubungkan ke browser Chrome yang berjalan dengan remote debugging diaktifkan (--remote-debugging-port=9222).", - "urlPlaceholder": "URL Kustom (misalnya, http://localhost:9222)", - "testButton": "Test Koneksi", - "testingButton": "Testing...", - "instructions": "Masukkan alamat host DevTools Protocol atau biarkan kosong untuk auto-discover instance Chrome lokal. Tombol Test Koneksi akan mencoba URL kustom jika disediakan, atau auto-discover jika field kosong." - } - }, "checkpoints": { "timeout": { "label": "Batas waktu inisialisasi checkpoint (detik)", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index c201425813..54f5bf9061 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -363,44 +363,6 @@ "total": "Costo totale: ${{cost}}", "includesSubtasks": "Include i costi delle sottoattività" }, - "browser": { - "session": "Sessione del browser", - "active": "Attivo", - "rooWantsToUse": "Roo vuole utilizzare il browser", - "consoleLogs": "Log della console", - "noNewLogs": "(Nessun nuovo log)", - "screenshot": "Screenshot del browser", - "cursor": "cursore", - "navigation": { - "step": "Passo {{current}} di {{total}}", - "previous": "Precedente", - "next": "Successivo" - }, - "sessionStarted": "Sessione browser avviata", - "actions": { - "title": "Azione browser: ", - "launched": "Browser avviato", - "launch": "Avvia browser su {{url}}", - "clicked": "Cliccato su: {{coordinate}}", - "click": "Clic ({{coordinate}})", - "typed": "Digitato: {{text}}", - "type": "Digita \"{{text}}\"", - "pressed": "Premuto: {{key}}", - "press": "Premi {{key}}", - "scrolledDown": "Scorso verso il basso", - "scrollDown": "Scorri verso il basso", - "scrolledUp": "Scorso verso l'alto", - "scrollUp": "Scorri verso l'alto", - "hovered": "Passato il mouse su: {{coordinate}}", - "hover": "Passa il mouse ({{coordinate}})", - "resized": "Ridimensionato a: {{size}}", - "resize": "Ridimensiona a {{size}}", - "screenshotSaved": "Screenshot salvato", - "screenshot": "Salva screenshot in {{path}}", - "closed": "Browser chiuso", - "close": "Chiudi browser" - } - }, "codeblock": { "tooltips": { "expand": "Espandi blocco di codice", diff --git a/webview-ui/src/i18n/locales/it/prompts.json b/webview-ui/src/i18n/locales/it/prompts.json index 7ba95815a7..538124e584 100644 --- a/webview-ui/src/i18n/locales/it/prompts.json +++ b/webview-ui/src/i18n/locales/it/prompts.json @@ -25,7 +25,6 @@ "toolNames": { "read": "Leggi file", "edit": "Modifica file", - "browser": "Usa browser", "command": "Esegui comandi", "mcp": "Usa MCP" }, diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 7ee641c3b3..7eddf6c612 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -31,7 +31,6 @@ "mcp": "Server MCP", "worktrees": "Worktrees", "autoApprove": "Auto-approvazione", - "browser": "Accesso computer", "checkpoints": "Punti di controllo", "notifications": "Notifiche", "contextManagement": "Contesto", @@ -241,10 +240,6 @@ "description": "Permetti a Roo di creare e modificare file protetti (come .rooignore e file di configurazione .roo/) senza richiedere approvazione." } }, - "browser": { - "label": "Browser", - "description": "Esegui automaticamente azioni del browser senza richiedere approvazione. Nota: Si applica solo quando il modello supporta l'uso del computer" - }, "mcp": { "label": "MCP", "description": "Abilita l'approvazione automatica dei singoli strumenti MCP nella vista Server MCP (richiede sia questa impostazione che la casella \"Consenti sempre\" dello strumento)" @@ -592,34 +587,6 @@ "maxTokensDescription": "Numero massimo di token di output per le risposte di Claude Code. Il valore predefinito è 8000." } }, - "browser": { - "enable": { - "label": "Abilita strumento browser", - "description": "Quando abilitato, Roo può utilizzare un browser per interagire con siti web quando si utilizzano modelli che supportano l'uso del computer. <0>Scopri di più" - }, - "viewport": { - "label": "Dimensione viewport", - "description": "Seleziona la dimensione del viewport per le interazioni del browser. Questo influisce su come i siti web vengono visualizzati e su come vi si interagisce.", - "options": { - "largeDesktop": "Desktop grande (1280x800)", - "smallDesktop": "Desktop piccolo (900x600)", - "tablet": "Tablet (768x1024)", - "mobile": "Mobile (360x640)" - } - }, - "screenshotQuality": { - "label": "Qualità screenshot", - "description": "Regola la qualità WebP degli screenshot del browser. Valori più alti forniscono screenshot più nitidi ma aumentano l'utilizzo di token." - }, - "remote": { - "label": "Usa connessione browser remoto", - "description": "Connettiti a un browser Chrome in esecuzione con debug remoto abilitato (--remote-debugging-port=9222).", - "urlPlaceholder": "URL personalizzato (es. http://localhost:9222)", - "testButton": "Testa connessione", - "testingButton": "Test in corso...", - "instructions": "Inserisci l'indirizzo host del protocollo DevTools o lascia vuoto per scoprire automaticamente le istanze Chrome locali. Il pulsante Test Connessione proverà l'URL personalizzato se fornito, o scoprirà automaticamente se il campo è vuoto." - } - }, "checkpoints": { "timeout": { "label": "Timeout inizializzazione checkpoint (secondi)", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 17fa51aca7..2cf7185503 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -363,44 +363,6 @@ "total": "合計コスト: ${{cost}}", "includesSubtasks": "サブタスクのコストを含む" }, - "browser": { - "session": "ブラウザセッション", - "active": "アクティブ", - "rooWantsToUse": "Rooはブラウザを使用したい", - "consoleLogs": "コンソールログ", - "noNewLogs": "(新しいログはありません)", - "screenshot": "ブラウザのスクリーンショット", - "cursor": "カーソル", - "navigation": { - "step": "ステップ {{current}} / {{total}}", - "previous": "前へ", - "next": "次へ" - }, - "sessionStarted": "ブラウザセッション開始", - "actions": { - "title": "ブラウザ操作: ", - "launched": "ブラウザを起動しました", - "launch": "{{url}} でブラウザを起動", - "clicked": "クリック: {{coordinate}}", - "click": "クリック ({{coordinate}})", - "typed": "入力: {{text}}", - "type": "入力 \"{{text}}\"", - "pressed": "{{key}}を押しました", - "press": "{{key}}を押す", - "scrolledDown": "下にスクロールしました", - "scrollDown": "下にスクロール", - "scrolledUp": "上にスクロールしました", - "scrollUp": "上にスクロール", - "hovered": "ホバー: {{coordinate}}", - "hover": "ホバー ({{coordinate}})", - "resized": "サイズを変更しました: {{size}}", - "resize": "サイズを {{size}} に変更", - "screenshotSaved": "スクリーンショットを保存しました", - "screenshot": "スクリーンショットを {{path}} に保存", - "closed": "ブラウザを閉じました", - "close": "ブラウザを閉じる" - } - }, "codeblock": { "tooltips": { "expand": "コードブロックを展開", diff --git a/webview-ui/src/i18n/locales/ja/prompts.json b/webview-ui/src/i18n/locales/ja/prompts.json index fc774fc318..4d7c77e688 100644 --- a/webview-ui/src/i18n/locales/ja/prompts.json +++ b/webview-ui/src/i18n/locales/ja/prompts.json @@ -25,7 +25,6 @@ "toolNames": { "read": "ファイルを読み込む", "edit": "ファイルを編集", - "browser": "ブラウザを使用", "command": "コマンドを実行", "mcp": "MCP を使用" }, diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 787d3db218..533acf119c 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -31,7 +31,6 @@ "mcp": "MCPサーバー", "worktrees": "Worktrees", "autoApprove": "自動承認", - "browser": "コンピューターアクセス", "checkpoints": "チェックポイント", "notifications": "通知", "contextManagement": "コンテキスト", @@ -241,10 +240,6 @@ "description": "Rooが保護されたファイル(.rooignoreや.roo/設定ファイルなど)を承認なしで作成・編集することを許可します。" } }, - "browser": { - "label": "ブラウザ", - "description": "承認なしで自動的にブラウザアクションを実行 — 注意:コンピューター使用をサポートするモデルを使用している場合のみ適用されます" - }, "mcp": { "label": "MCP", "description": "MCPサーバービューで個々のMCPツールの自動承認を有効にします(この設定とツールの「常に許可」チェックボックスの両方が必要)" @@ -592,34 +587,6 @@ "maxTokensDescription": "Claude Codeレスポンスの最大出力トークン数。デフォルトは8000です。" } }, - "browser": { - "enable": { - "label": "ブラウザツールを有効化", - "description": "有効にすると、コンピューター使用をサポートするモデルを使用する際に、Rooはウェブサイトとのやり取りにブラウザを使用できます。 <0>詳細情報" - }, - "viewport": { - "label": "ビューポートサイズ", - "description": "ブラウザインタラクションのビューポートサイズを選択します。これはウェブサイトの表示方法とインタラクション方法に影響します。", - "options": { - "largeDesktop": "大型デスクトップ (1280x800)", - "smallDesktop": "小型デスクトップ (900x600)", - "tablet": "タブレット (768x1024)", - "mobile": "モバイル (360x640)" - } - }, - "screenshotQuality": { - "label": "スクリーンショット品質", - "description": "ブラウザスクリーンショットのWebP品質を調整します。高い値はより鮮明なスクリーンショットを提供しますが、token使用量が増加します。" - }, - "remote": { - "label": "リモートブラウザ接続を使用", - "description": "リモートデバッグを有効にして実行しているChromeブラウザに接続します(--remote-debugging-port=9222)。", - "urlPlaceholder": "カスタムURL(例:http://localhost:9222)", - "testButton": "接続テスト", - "testingButton": "テスト中...", - "instructions": "DevToolsプロトコルホストアドレスを入力するか、Chromeのローカルインスタンスを自動検出するために空のままにします。接続テストボタンは、提供されている場合はカスタムURLを試み、フィールドが空の場合は自動検出します。" - } - }, "checkpoints": { "timeout": { "label": "チェックポイント初期化タイムアウト(秒)", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index a91e59fddc..24debd83ed 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -363,44 +363,6 @@ "total": "총 비용: ${{cost}}", "includesSubtasks": "하위 작업 비용 포함" }, - "browser": { - "session": "브라우저 세션", - "active": "활성", - "rooWantsToUse": "Roo가 브라우저를 사용하고 싶어합니다", - "consoleLogs": "콘솔 로그", - "noNewLogs": "(새 로그 없음)", - "screenshot": "브라우저 스크린샷", - "cursor": "커서", - "navigation": { - "step": "단계 {{current}} / {{total}}", - "previous": "이전", - "next": "다음" - }, - "sessionStarted": "브라우저 세션 시작됨", - "actions": { - "title": "브라우저 작업: ", - "launched": "브라우저 실행됨", - "launch": "{{url}}에서 브라우저 실행", - "clicked": "클릭함: {{coordinate}}", - "click": "클릭 ({{coordinate}})", - "typed": "입력함: {{text}}", - "type": "입력 \"{{text}}\"", - "pressed": "{{key}} 누름", - "press": "{{key}} 누르기", - "scrolledDown": "아래로 스크롤됨", - "scrollDown": "아래로 스크롤", - "scrolledUp": "위로 스크롤됨", - "scrollUp": "위로 스크롤", - "hovered": "가리킴: {{coordinate}}", - "hover": "가리키기 ({{coordinate}})", - "resized": "크기 조정됨: {{size}}", - "resize": "크기를 {{size}}로 조정", - "screenshotSaved": "스크린샷 저장됨", - "screenshot": "스크린샷을 {{path}}에 저장", - "closed": "브라우저 닫음", - "close": "브라우저 닫기" - } - }, "codeblock": { "tooltips": { "expand": "코드 블록 확장", diff --git a/webview-ui/src/i18n/locales/ko/prompts.json b/webview-ui/src/i18n/locales/ko/prompts.json index f3666f1b66..204547e4c3 100644 --- a/webview-ui/src/i18n/locales/ko/prompts.json +++ b/webview-ui/src/i18n/locales/ko/prompts.json @@ -25,7 +25,6 @@ "toolNames": { "read": "파일 읽기", "edit": "파일 편집", - "browser": "브라우저 사용", "command": "명령 실행", "mcp": "MCP 사용" }, diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 6c4a91f9c4..5254415402 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -31,7 +31,6 @@ "mcp": "MCP 서버", "worktrees": "Worktrees", "autoApprove": "자동 승인", - "browser": "컴퓨터 접근", "checkpoints": "체크포인트", "notifications": "알림", "contextManagement": "컨텍스트", @@ -241,10 +240,6 @@ "description": "Roo가 보호된 파일(.rooignore 및 .roo/ 구성 파일 등)을 승인 없이 생성하고 편집할 수 있도록 허용합니다." } }, - "browser": { - "label": "브라우저", - "description": "승인 없이 자동으로 브라우저 작업 수행 — 참고: 모델이 컴퓨터 사용을 지원할 때만 적용됩니다" - }, "mcp": { "label": "MCP", "description": "MCP 서버 보기에서 개별 MCP 도구의 자동 승인 활성화(이 설정과 도구의 \"항상 허용\" 체크박스 모두 필요)" @@ -592,34 +587,6 @@ "maxTokensDescription": "Claude Code 응답의 최대 출력 토큰 수. 기본값은 8000입니다." } }, - "browser": { - "enable": { - "label": "브라우저 도구 활성화", - "description": "활성화되면 Roo는 컴퓨터 사용을 지원하는 모델을 사용할 때 웹사이트와 상호 작용하기 위해 브라우저를 사용할 수 있습니다. <0>더 알아보기" - }, - "viewport": { - "label": "뷰포트 크기", - "description": "브라우저 상호 작용을 위한 뷰포트 크기를 선택하세요. 이는 웹사이트가 표시되고 상호 작용하는 방식에 영향을 미칩니다.", - "options": { - "largeDesktop": "대형 데스크톱 (1280x800)", - "smallDesktop": "소형 데스크톱 (900x600)", - "tablet": "태블릿 (768x1024)", - "mobile": "모바일 (360x640)" - } - }, - "screenshotQuality": { - "label": "스크린샷 품질", - "description": "브라우저 스크린샷의 WebP 품질을 조정합니다. 높은 값은 더 선명한 스크린샷을 제공하지만 token 사용량이 증가합니다." - }, - "remote": { - "label": "원격 브라우저 연결 사용", - "description": "원격 디버깅이 활성화된 Chrome 브라우저에 연결합니다(--remote-debugging-port=9222).", - "urlPlaceholder": "사용자 정의 URL(예: http://localhost:9222)", - "testButton": "연결 테스트", - "testingButton": "테스트 중...", - "instructions": "DevTools 프로토콜 호스트 주소를 입력하거나 Chrome 로컬 인스턴스를 자동으로 발견하기 위해 비워두세요. 연결 테스트 버튼은 제공된 경우 사용자 정의 URL을 시도하거나, 필드가 비어 있으면 자동으로 발견합니다." - } - }, "checkpoints": { "timeout": { "label": "체크포인트 초기화 타임아웃(초)", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 613deed507..0bec6846c0 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -363,44 +363,6 @@ "total": "Totale kosten: ${{cost}}", "includesSubtasks": "Inclusief kosten van subtaken" }, - "browser": { - "session": "Browsersessie", - "active": "Actief", - "rooWantsToUse": "Roo wil de browser gebruiken", - "consoleLogs": "Console-logboeken", - "noNewLogs": "(Geen nieuwe logboeken)", - "screenshot": "Browserschermopname", - "cursor": "cursor", - "navigation": { - "step": "Stap {{current}} van {{total}}", - "previous": "Vorige", - "next": "Volgende" - }, - "sessionStarted": "Browsersessie gestart", - "actions": { - "title": "Browseractie: ", - "launched": "Browser gestart", - "launch": "Browser starten op {{url}}", - "clicked": "Geklikt ({{coordinate}})", - "click": "Klik ({{coordinate}})", - "typed": "Getypt: {{text}}", - "type": "Typ \"{{text}}\"", - "pressed": "Ingedrukt: {{key}}", - "press": "Druk op {{key}}", - "scrolledDown": "Naar beneden gescrolld", - "scrollDown": "Scroll naar beneden", - "scrolledUp": "Naar boven gescrolld", - "scrollUp": "Scroll naar boven", - "hovered": "Zweefde ({{coordinate}})", - "hover": "Zweven ({{coordinate}})", - "resized": "Grootte gewijzigd naar: {{size}}", - "resize": "Grootte wijzigen naar {{size}}", - "screenshotSaved": "Schermopname opgeslagen", - "screenshot": "Schermopname opslaan naar {{path}}", - "closed": "Browser gesloten", - "close": "Browser sluiten" - } - }, "codeblock": { "tooltips": { "expand": "Codeblok uitvouwen", diff --git a/webview-ui/src/i18n/locales/nl/prompts.json b/webview-ui/src/i18n/locales/nl/prompts.json index 097549e8a7..b1be9df568 100644 --- a/webview-ui/src/i18n/locales/nl/prompts.json +++ b/webview-ui/src/i18n/locales/nl/prompts.json @@ -25,7 +25,6 @@ "toolNames": { "read": "Bestanden lezen", "edit": "Bestanden bewerken", - "browser": "Browser gebruiken", "command": "Commando's uitvoeren", "mcp": "MCP gebruiken" }, diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 3cf6385716..db7b0554c3 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -31,7 +31,6 @@ "mcp": "MCP-servers", "worktrees": "Worktrees", "autoApprove": "Auto-goedkeuren", - "browser": "Browser", "checkpoints": "Checkpoints", "notifications": "Meldingen", "contextManagement": "Context", @@ -241,10 +240,6 @@ "description": "Sta Roo toe om beschermde bestanden (zoals .rooignore en .roo/ configuratiebestanden) aan te maken en te bewerken zonder goedkeuring." } }, - "browser": { - "label": "Browser", - "description": "Automatisch browseracties uitvoeren zonder goedkeuring. Let op: geldt alleen als het model computergebruik ondersteunt." - }, "mcp": { "label": "MCP", "description": "Automatische goedkeuring van individuele MCP-tools in het MCP-serversoverzicht inschakelen (vereist zowel deze instelling als het selectievakje 'Altijd toestaan' bij de tool)" @@ -592,34 +587,6 @@ "maxTokensDescription": "Maximaal aantal output-tokens voor Claude Code-reacties. Standaard is 8000." } }, - "browser": { - "enable": { - "label": "Browserhulpmiddel inschakelen", - "description": "Indien ingeschakeld, kan Roo een browser gebruiken om te interageren met websites wanneer modellen computergebruik ondersteunen. <0>Meer informatie" - }, - "viewport": { - "label": "Viewport-grootte", - "description": "Selecteer de viewport-grootte voor browserinteracties. Dit beïnvloedt hoe websites worden weergegeven en gebruikt.", - "options": { - "largeDesktop": "Groot bureaublad (1280x800)", - "smallDesktop": "Klein bureaublad (900x600)", - "tablet": "Tablet (768x1024)", - "mobile": "Mobiel (360x640)" - } - }, - "screenshotQuality": { - "label": "Screenshotkwaliteit", - "description": "Pas de WebP-kwaliteit van browserscreenshots aan. Hogere waarden geven duidelijkere screenshots maar verhogen het tokengebruik." - }, - "remote": { - "label": "Gebruik externe browserverbinding", - "description": "Verbind met een Chrome-browser die draait met remote debugging ingeschakeld (--remote-debugging-port=9222).", - "urlPlaceholder": "Aangepaste URL (bijv. http://localhost:9222)", - "testButton": "Verbinding testen", - "testingButton": "Bezig met testen...", - "instructions": "Voer het DevTools Protocol hostadres in of laat leeg om lokale Chrome-instanties automatisch te detecteren. De knop Verbinding testen probeert de aangepaste URL als opgegeven, of detecteert automatisch als het veld leeg is." - } - }, "checkpoints": { "timeout": { "label": "Timeout voor checkpoint-initialisatie (seconden)", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 026f0ce288..be012354aa 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -363,44 +363,6 @@ "total": "Całkowity koszt: ${{cost}}", "includesSubtasks": "Zawiera koszty podzadań" }, - "browser": { - "session": "Sesja przeglądarki", - "active": "Aktywna", - "rooWantsToUse": "Roo chce użyć przeglądarki", - "consoleLogs": "Logi konsoli", - "noNewLogs": "(Brak nowych logów)", - "screenshot": "Zrzut ekranu przeglądarki", - "cursor": "kursor", - "navigation": { - "step": "Krok {{current}} z {{total}}", - "previous": "Poprzedni", - "next": "Następny" - }, - "sessionStarted": "Sesja przeglądarki rozpoczęta", - "actions": { - "title": "Akcja przeglądarki: ", - "launched": "Przeglądarka uruchomiona", - "launch": "Uruchom przeglądarkę na {{url}}", - "clicked": "Kliknięto ({{coordinate}})", - "click": "Kliknij ({{coordinate}})", - "typed": "Wpisano: {{text}}", - "type": "Wpisz \"{{text}}\"", - "pressed": "Naciśnięto: {{key}}", - "press": "Naciśnij {{key}}", - "scrolledDown": "Przewinięto w dół", - "scrollDown": "Przewiń w dół", - "scrolledUp": "Przewinięto w górę", - "scrollUp": "Przewiń w górę", - "hovered": "Najechano ({{coordinate}})", - "hover": "Najedź ({{coordinate}})", - "resized": "Zmieniono rozmiar na: {{size}}", - "resize": "Zmień rozmiar na {{size}}", - "screenshotSaved": "Zrzut ekranu zapisany", - "screenshot": "Zapisz zrzut ekranu do {{path}}", - "closed": "Przeglądarka zamknięta", - "close": "Zamknij przeglądarkę" - } - }, "codeblock": { "tooltips": { "expand": "Rozwiń blok kodu", diff --git a/webview-ui/src/i18n/locales/pl/prompts.json b/webview-ui/src/i18n/locales/pl/prompts.json index b1fb3317c0..7aa686af04 100644 --- a/webview-ui/src/i18n/locales/pl/prompts.json +++ b/webview-ui/src/i18n/locales/pl/prompts.json @@ -25,7 +25,6 @@ "toolNames": { "read": "Czytaj pliki", "edit": "Edytuj pliki", - "browser": "Używaj przeglądarki", "command": "Uruchamiaj polecenia", "mcp": "Używaj MCP" }, diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 7dc5dfa766..875f00838d 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -31,7 +31,6 @@ "mcp": "Serwery MCP", "worktrees": "Worktrees", "autoApprove": "Auto-zatwierdzanie", - "browser": "Dostęp komputera", "checkpoints": "Punkty kontrolne", "notifications": "Powiadomienia", "contextManagement": "Kontekst", @@ -241,10 +240,6 @@ "description": "Pozwól Roo na tworzenie i edycję plików chronionych (takich jak .rooignore i pliki konfiguracyjne .roo/) bez konieczności zatwierdzania." } }, - "browser": { - "label": "Przeglądarka", - "description": "Automatycznie wykonuj akcje przeglądarki bez konieczności zatwierdzania. Uwaga: Dotyczy tylko gdy model obsługuje używanie komputera" - }, "mcp": { "label": "MCP", "description": "Włącz automatyczne zatwierdzanie poszczególnych narzędzi MCP w widoku Serwerów MCP (wymaga zarówno tego ustawienia, jak i pola wyboru \"Zawsze zezwalaj\" narzędzia)" @@ -592,34 +587,6 @@ "maxTokensDescription": "Maksymalna liczba tokenów wyjściowych dla odpowiedzi Claude Code. Domyślnie 8000." } }, - "browser": { - "enable": { - "label": "Włącz narzędzie przeglądarki", - "description": "Gdy włączone, Roo może używać przeglądarki do interakcji ze stronami internetowymi podczas korzystania z modeli obsługujących używanie komputera. <0>Dowiedz się więcej" - }, - "viewport": { - "label": "Rozmiar viewportu", - "description": "Wybierz rozmiar viewportu dla interakcji przeglądarki. Wpływa to na sposób wyświetlania stron internetowych i interakcji z nimi.", - "options": { - "largeDesktop": "Duży pulpit (1280x800)", - "smallDesktop": "Mały pulpit (900x600)", - "tablet": "Tablet (768x1024)", - "mobile": "Telefon (360x640)" - } - }, - "screenshotQuality": { - "label": "Jakość zrzutów ekranu", - "description": "Dostosuj jakość WebP zrzutów ekranu przeglądarki. Wyższe wartości zapewniają wyraźniejsze zrzuty ekranu, ale zwiększają zużycie token." - }, - "remote": { - "label": "Użyj zdalnego połączenia przeglądarki", - "description": "Połącz się z przeglądarką Chrome uruchomioną z włączonym zdalnym debugowaniem (--remote-debugging-port=9222).", - "urlPlaceholder": "Niestandardowy URL (np. http://localhost:9222)", - "testButton": "Testuj połączenie", - "testingButton": "Testowanie...", - "instructions": "Wprowadź adres hosta protokołu DevTools lub pozostaw puste, aby automatycznie wykryć lokalne instancje Chrome. Przycisk Test Połączenia spróbuje użyć niestandardowego URL, jeśli podany, lub automatycznie wykryje, jeśli pole jest puste." - } - }, "checkpoints": { "timeout": { "label": "Limit czasu inicjalizacji punktu kontrolnego (sekundy)", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index ac20822912..ed4eaf541e 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -363,44 +363,6 @@ "total": "Custo Total: ${{cost}}", "includesSubtasks": "Inclui custos de subtarefas" }, - "browser": { - "session": "Sessão do Navegador", - "active": "Ativo", - "rooWantsToUse": "Roo quer usar o navegador", - "consoleLogs": "Logs do console", - "noNewLogs": "(Sem novos logs)", - "screenshot": "Captura de tela do navegador", - "cursor": "cursor", - "navigation": { - "step": "Passo {{current}} de {{total}}", - "previous": "Anterior", - "next": "Próximo" - }, - "sessionStarted": "Sessão do navegador iniciada", - "actions": { - "title": "Ação do navegador: ", - "launched": "Navegador iniciado", - "launch": "Iniciar navegador em {{url}}", - "clicked": "Clicado em: {{coordinate}}", - "click": "Clique ({{coordinate}})", - "typed": "Digitado: {{text}}", - "type": "Digitar \"{{text}}\"", - "pressed": "Pressionado: {{key}}", - "press": "Pressione {{key}}", - "scrolledDown": "Rolado para baixo", - "scrollDown": "Rolar para baixo", - "scrolledUp": "Rolado para cima", - "scrollUp": "Rolar para cima", - "hovered": "Pairado em: {{coordinate}}", - "hover": "Pairar ({{coordinate}})", - "resized": "Redimensionado para: {{size}}", - "resize": "Redimensionar para {{size}}", - "screenshotSaved": "Captura de tela salva", - "screenshot": "Salvar captura de tela em {{path}}", - "closed": "Navegador fechado", - "close": "Fechar navegador" - } - }, "codeblock": { "tooltips": { "expand": "Expandir bloco de código", diff --git a/webview-ui/src/i18n/locales/pt-BR/prompts.json b/webview-ui/src/i18n/locales/pt-BR/prompts.json index cbec033b40..1cef958f59 100644 --- a/webview-ui/src/i18n/locales/pt-BR/prompts.json +++ b/webview-ui/src/i18n/locales/pt-BR/prompts.json @@ -25,7 +25,6 @@ "toolNames": { "read": "Ler arquivos", "edit": "Editar arquivos", - "browser": "Usar navegador", "command": "Executar comandos", "mcp": "Usar MCP" }, diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index af1e68e824..0d77ea6e5a 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -31,7 +31,6 @@ "mcp": "Servidores MCP", "worktrees": "Worktrees", "autoApprove": "Aprovação", - "browser": "Navegador", "checkpoints": "Checkpoints", "notifications": "Notificações", "contextManagement": "Contexto", @@ -241,10 +240,6 @@ "description": "Permitir que o Roo crie e edite arquivos protegidos (como .rooignore e arquivos de configuração .roo/) sem exigir aprovação." } }, - "browser": { - "label": "Navegador", - "description": "Realizar ações do navegador automaticamente sem exigir aprovação. Nota: Aplica-se apenas quando o modelo suporta uso do computador" - }, "mcp": { "label": "MCP", "description": "Ativar aprovação automática de ferramentas MCP individuais na visualização de Servidores MCP (requer tanto esta configuração quanto a caixa de seleção \"Permitir sempre\" da ferramenta)" @@ -592,34 +587,6 @@ "maxTokensDescription": "Número máximo de tokens de saída para respostas do Claude Code. O padrão é 8000." } }, - "browser": { - "enable": { - "label": "Ativar ferramenta de navegador", - "description": "Quando ativado, o Roo pode usar um navegador para interagir com sites ao usar modelos que suportam o uso do computador. <0>Saiba mais" - }, - "viewport": { - "label": "Tamanho da viewport", - "description": "Selecione o tamanho da viewport para interações do navegador. Isso afeta como os sites são exibidos e como se interage com eles.", - "options": { - "largeDesktop": "Desktop grande (1280x800)", - "smallDesktop": "Desktop pequeno (900x600)", - "tablet": "Tablet (768x1024)", - "mobile": "Móvel (360x640)" - } - }, - "screenshotQuality": { - "label": "Qualidade das capturas de tela", - "description": "Ajuste a qualidade WebP das capturas de tela do navegador. Valores mais altos fornecem capturas mais nítidas, mas aumentam o uso de token." - }, - "remote": { - "label": "Usar conexão remota de navegador", - "description": "Conectar a um navegador Chrome executando com depuração remota ativada (--remote-debugging-port=9222).", - "urlPlaceholder": "URL personalizado (ex. http://localhost:9222)", - "testButton": "Testar conexão", - "testingButton": "Testando...", - "instructions": "Digite o endereço do host do protocolo DevTools ou deixe em branco para descobrir automaticamente instâncias locais do Chrome. O botão Testar Conexão tentará usar o URL personalizado, se fornecido, ou descobrirá automaticamente se o campo estiver vazio." - } - }, "checkpoints": { "timeout": { "label": "Tempo limite para inicialização do checkpoint (segundos)", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 5d6206b84f..15d8c9ee99 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -364,44 +364,6 @@ "total": "Общая стоимость: ${{cost}}", "includesSubtasks": "Включает стоимость подзадач" }, - "browser": { - "session": "Сеанс браузера", - "active": "Активен", - "rooWantsToUse": "Roo хочет использовать браузер", - "consoleLogs": "Логи консоли", - "noNewLogs": "(Новых логов нет)", - "screenshot": "Скриншот браузера", - "cursor": "курсор", - "navigation": { - "step": "Шаг {{current}} из {{total}}", - "previous": "Предыдущий", - "next": "Следующий" - }, - "sessionStarted": "Сессия браузера запущена", - "actions": { - "title": "Действие браузера: ", - "launched": "Браузер открыт", - "launch": "Открыть браузер по адресу {{url}}", - "clicked": "Клик в: {{coordinate}}", - "click": "Клик ({{coordinate}})", - "typed": "Введено: {{text}}", - "type": "Ввести \"{{text}}\"", - "pressed": "Нажато: {{key}}", - "press": "Нажать {{key}}", - "scrolledDown": "Прокручено вниз", - "scrollDown": "Прокрутить вниз", - "scrolledUp": "Прокручено вверх", - "scrollUp": "Прокрутить вверх", - "hovered": "Наведено в: {{coordinate}}", - "hover": "Навести ({{coordinate}})", - "resized": "Размер изменен на: {{size}}", - "resize": "Изменить размер на {{size}}", - "screenshotSaved": "Снимок экрана сохранён", - "screenshot": "Сохранить снимок экрана в {{path}}", - "closed": "Браузер закрыт", - "close": "Закрыть браузер" - } - }, "codeblock": { "tooltips": { "expand": "Развернуть блок кода", diff --git a/webview-ui/src/i18n/locales/ru/prompts.json b/webview-ui/src/i18n/locales/ru/prompts.json index 3cc222a819..5af112ceaa 100644 --- a/webview-ui/src/i18n/locales/ru/prompts.json +++ b/webview-ui/src/i18n/locales/ru/prompts.json @@ -25,7 +25,6 @@ "toolNames": { "read": "Чтение файлов", "edit": "Редактирование файлов", - "browser": "Использовать браузер", "command": "Выполнять команды", "mcp": "Использовать MCP" }, diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 94e9a78ff5..87563bdab6 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -31,7 +31,6 @@ "mcp": "Серверы MCP", "worktrees": "Worktrees", "autoApprove": "Автоодобрение", - "browser": "Доступ к компьютеру", "checkpoints": "Контрольные точки", "notifications": "Уведомления", "contextManagement": "Контекст", @@ -241,10 +240,6 @@ "description": "Разрешить Roo создавать и редактировать защищенные файлы (такие как .rooignore и файлы конфигурации .roo/) без необходимости одобрения." } }, - "browser": { - "label": "Браузер", - "description": "Автоматически выполнять действия в браузере без необходимости одобрения. Применяется только, если модель поддерживает использование компьютера" - }, "mcp": { "label": "MCP", "description": "Включить автоодобрение отдельных инструментов MCP в представлении MCP Servers (требуется включить как этот параметр, так и индивидуальный чекбокс инструмента \"Всегда разрешать\")" @@ -592,34 +587,6 @@ "maxTokensDescription": "Максимальное количество выходных токенов для ответов Claude Code. По умолчанию 8000." } }, - "browser": { - "enable": { - "label": "Включить инструмент браузера", - "description": "Если включено, Roo может использовать браузер для взаимодействия с сайтами при использовании моделей, поддерживающих работу с компьютером. <0>Подробнее" - }, - "viewport": { - "label": "Размер окна просмотра", - "description": "Выберите размер окна для взаимодействия с браузером. Влияет на отображение и взаимодействие с сайтами.", - "options": { - "largeDesktop": "Большой рабочий стол (1280x800)", - "smallDesktop": "Маленький рабочий стол (900x600)", - "tablet": "Планшет (768x1024)", - "mobile": "Мобильный (360x640)" - } - }, - "screenshotQuality": { - "label": "Качество скриншота", - "description": "Настройте качество WebP для скриншотов браузера. Более высокие значения дают более чёткие изображения, но увеличивают расход токенов." - }, - "remote": { - "label": "Использовать удалённое подключение к браузеру", - "description": "Подключиться к Chrome с включённым удалённым дебагом (--remote-debugging-port=9222).", - "urlPlaceholder": "Пользовательский URL (например, http://localhost:9222)", - "testButton": "Проверить соединение", - "testingButton": "Проверка...", - "instructions": "Введите адрес DevTools Protocol или оставьте поле пустым для автоматического поиска локальных экземпляров Chrome. Кнопка проверки попробует пользовательский URL, если он указан, или выполнит автопоиск." - } - }, "checkpoints": { "timeout": { "label": "Таймаут инициализации контрольной точки (секунды)", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index db854b6a3d..e9552d5bfd 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -364,44 +364,6 @@ "total": "Toplam Maliyet: ${{cost}}", "includesSubtasks": "Alt görev maliyetlerini içerir" }, - "browser": { - "session": "Tarayıcı Oturumu", - "active": "Aktif", - "rooWantsToUse": "Roo tarayıcıyı kullanmak istiyor", - "consoleLogs": "Konsol Kayıtları", - "noNewLogs": "(Yeni kayıt yok)", - "screenshot": "Tarayıcı ekran görüntüsü", - "cursor": "imleç", - "navigation": { - "step": "Adım {{current}} / {{total}}", - "previous": "Önceki", - "next": "Sonraki" - }, - "sessionStarted": "Tarayıcı Oturumu Başlatıldı", - "actions": { - "title": "Tarayıcı Eylemi: ", - "launched": "Tarayıcı başlatıldı", - "launch": "{{url}} adresinde tarayıcı başlat", - "clicked": "Tıklandı ({{coordinate}})", - "click": "Tıkla ({{coordinate}})", - "typed": "Yazıldı: {{text}}", - "type": "Yaz \"{{text}}\"", - "pressed": "Basıldı: {{key}}", - "press": "{{key}} tuşuna bas", - "scrolledDown": "Aşağı kaydırıldı", - "scrollDown": "Aşağı kaydır", - "scrolledUp": "Yukarı kaydırıldı", - "scrollUp": "Yukarı kaydır", - "hovered": "Üzerine gelinildi ({{coordinate}})", - "hover": "Üzerine gel ({{coordinate}})", - "resized": "Boyut değiştirildi: {{size}}", - "resize": "Boyutu değiştir {{size}}", - "screenshotSaved": "Ekran görüntüsü kaydedildi", - "screenshot": "Ekran görüntüsünü {{path}} yoluna kaydet", - "closed": "Tarayıcı kapatıldı", - "close": "Tarayıcıyı kapat" - } - }, "codeblock": { "tooltips": { "expand": "Kod bloğunu genişlet", diff --git a/webview-ui/src/i18n/locales/tr/prompts.json b/webview-ui/src/i18n/locales/tr/prompts.json index b2771d32fe..543fd88898 100644 --- a/webview-ui/src/i18n/locales/tr/prompts.json +++ b/webview-ui/src/i18n/locales/tr/prompts.json @@ -25,7 +25,6 @@ "toolNames": { "read": "Dosyaları Oku", "edit": "Dosyaları Düzenle", - "browser": "Tarayıcı Kullan", "command": "Komutları Çalıştır", "mcp": "MCP Kullan" }, diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index df219f179d..ee46b9f01c 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -31,7 +31,6 @@ "mcp": "MCP Sunucuları", "worktrees": "Worktrees", "autoApprove": "Oto-Onay", - "browser": "Bilgisayar Erişimi", "checkpoints": "Kontrol Noktaları", "notifications": "Bildirimler", "contextManagement": "Bağlam", @@ -241,10 +240,6 @@ "description": "Roo'nun korumalı dosyaları (.rooignore ve .roo/ yapılandırma dosyaları gibi) onay gerektirmeden oluşturmasına ve düzenlemesine izin ver." } }, - "browser": { - "label": "Tarayıcı", - "description": "Onay gerektirmeden otomatik olarak tarayıcı eylemleri gerçekleştir. Not: Yalnızca model bilgisayar kullanımını desteklediğinde geçerlidir" - }, "mcp": { "label": "MCP", "description": "MCP Sunucuları görünümünde bireysel MCP araçlarının otomatik onayını etkinleştir (hem bu ayar hem de aracın \"Her zaman izin ver\" onay kutusu gerekir)" @@ -592,34 +587,6 @@ "maxTokensDescription": "Claude Code yanıtları için maksimum çıktı token sayısı. Varsayılan 8000'dir." } }, - "browser": { - "enable": { - "label": "Tarayıcı aracını etkinleştir", - "description": "Etkinleştirildiğinde, Roo bilgisayar kullanımını destekleyen modeller kullanırken web siteleriyle etkileşim kurmak için bir tarayıcı kullanabilir. <0>Daha fazla bilgi" - }, - "viewport": { - "label": "Görünüm alanı boyutu", - "description": "Tarayıcı etkileşimleri için görünüm alanı boyutunu seçin. Bu, web sitelerinin nasıl görüntülendiğini ve etkileşime girdiğini etkiler.", - "options": { - "largeDesktop": "Büyük Masaüstü (1280x800)", - "smallDesktop": "Küçük Masaüstü (900x600)", - "tablet": "Tablet (768x1024)", - "mobile": "Mobil (360x640)" - } - }, - "screenshotQuality": { - "label": "Ekran görüntüsü kalitesi", - "description": "Tarayıcı ekran görüntülerinin WebP kalitesini ayarlayın. Daha yüksek değerler daha net ekran görüntüleri sağlar ancak token kullanımını artırır." - }, - "remote": { - "label": "Uzak tarayıcı bağlantısı kullan", - "description": "Uzaktan hata ayıklama etkinleştirilmiş olarak çalışan bir Chrome tarayıcısına bağlanın (--remote-debugging-port=9222).", - "urlPlaceholder": "Özel URL (örn. http://localhost:9222)", - "testButton": "Bağlantıyı Test Et", - "testingButton": "Test Ediliyor...", - "instructions": "DevTools protokolü ana bilgisayar adresini girin veya yerel Chrome örneklerini otomatik olarak keşfetmek için boş bırakın. Bağlantıyı Test Et düğmesi, sağlanmışsa özel URL'yi deneyecek veya alan boşsa otomatik olarak keşfedecektir." - } - }, "checkpoints": { "timeout": { "label": "Kontrol noktası başlatma zaman aşımı (saniye)", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 72391ddced..dfa5b56c4b 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -364,44 +364,6 @@ "total": "Tổng chi phí: ${{cost}}", "includesSubtasks": "Bao gồm chi phí của các tác vụ phụ" }, - "browser": { - "session": "Phiên trình duyệt", - "active": "Đang hoạt động", - "rooWantsToUse": "Roo muốn sử dụng trình duyệt", - "consoleLogs": "Nhật ký bảng điều khiển", - "noNewLogs": "(Không có nhật ký mới)", - "screenshot": "Ảnh chụp màn hình trình duyệt", - "cursor": "con trỏ", - "navigation": { - "step": "Bước {{current}} / {{total}}", - "previous": "Trước", - "next": "Tiếp" - }, - "sessionStarted": "Phiên trình duyệt đã bắt đầu", - "actions": { - "title": "Hành động trình duyệt: ", - "launched": "Trình duyệt đã khởi chạy", - "launch": "Khởi chạy trình duyệt tại {{url}}", - "clicked": "Đã nhấp ({{coordinate}})", - "click": "Nhấp ({{coordinate}})", - "typed": "Đã gõ: {{text}}", - "type": "Gõ \"{{text}}\"", - "pressed": "Đã nhấn: {{key}}", - "press": "Nhấn {{key}}", - "scrolledDown": "Đã cuộn xuống", - "scrollDown": "Cuộn xuống", - "scrolledUp": "Đã cuộn lên", - "scrollUp": "Cuộn lên", - "hovered": "Đã di chuột ({{coordinate}})", - "hover": "Di chuột ({{coordinate}})", - "resized": "Đã thay đổi kích thước: {{size}}", - "resize": "Thay đổi kích thước thành {{size}}", - "screenshotSaved": "Ảnh chụp màn hình đã được lưu", - "screenshot": "Lưu ảnh chụp màn hình vào {{path}}", - "closed": "Trình duyệt đã đóng", - "close": "Đóng trình duyệt" - } - }, "codeblock": { "tooltips": { "expand": "Mở rộng khối mã", diff --git a/webview-ui/src/i18n/locales/vi/prompts.json b/webview-ui/src/i18n/locales/vi/prompts.json index 2583d70306..7ac6fa5d6a 100644 --- a/webview-ui/src/i18n/locales/vi/prompts.json +++ b/webview-ui/src/i18n/locales/vi/prompts.json @@ -25,7 +25,6 @@ "toolNames": { "read": "Đọc tệp", "edit": "Chỉnh sửa tệp", - "browser": "Sử dụng trình duyệt", "command": "Chạy lệnh", "mcp": "Sử dụng MCP" }, diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 5664181173..1eb0fb09d3 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -31,7 +31,6 @@ "mcp": "Máy chủ MCP", "worktrees": "Worktrees", "autoApprove": "Phê duyệt", - "browser": "Trình duyệt", "checkpoints": "Điểm kiểm tra", "notifications": "Thông báo", "contextManagement": "Ngữ cảnh", @@ -241,10 +240,6 @@ "description": "Cho phép Roo tạo và chỉnh sửa các tệp được bảo vệ (như .rooignore và các tệp cấu hình .roo/) mà không yêu cầu phê duyệt." } }, - "browser": { - "label": "Trình duyệt", - "description": "Tự động thực hiện các hành động trình duyệt mà không cần phê duyệt. Lưu ý: Chỉ áp dụng khi mô hình hỗ trợ sử dụng máy tính" - }, "mcp": { "label": "MCP", "description": "Bật tự động phê duyệt các công cụ MCP riêng lẻ trong chế độ xem Máy chủ MCP (yêu cầu cả cài đặt này và hộp kiểm \"Luôn cho phép\" của công cụ)" @@ -592,34 +587,6 @@ "maxTokensDescription": "Số lượng token đầu ra tối đa cho các phản hồi của Claude Code. Mặc định là 8000." } }, - "browser": { - "enable": { - "label": "Bật công cụ trình duyệt", - "description": "Khi được bật, Roo có thể sử dụng trình duyệt để tương tác với các trang web khi sử dụng các mô hình hỗ trợ sử dụng máy tính. <0>Tìm hiểu thêm" - }, - "viewport": { - "label": "Kích thước khung nhìn", - "description": "Chọn kích thước khung nhìn cho tương tác trình duyệt. Điều này ảnh hưởng đến cách trang web được hiển thị và tương tác.", - "options": { - "largeDesktop": "Máy tính để bàn lớn (1280x800)", - "smallDesktop": "Máy tính để bàn nhỏ (900x600)", - "tablet": "Máy tính bảng (768x1024)", - "mobile": "Di động (360x640)" - } - }, - "screenshotQuality": { - "label": "Chất lượng ảnh chụp màn hình", - "description": "Điều chỉnh chất lượng WebP của ảnh chụp màn hình trình duyệt. Giá trị cao hơn cung cấp ảnh chụp màn hình rõ ràng hơn nhưng tăng sử dụng token." - }, - "remote": { - "label": "Sử dụng kết nối trình duyệt từ xa", - "description": "Kết nối với trình duyệt Chrome đang chạy với tính năng gỡ lỗi từ xa được bật (--remote-debugging-port=9222).", - "urlPlaceholder": "URL tùy chỉnh (ví dụ: http://localhost:9222)", - "testButton": "Kiểm tra kết nối", - "testingButton": "Đang kiểm tra...", - "instructions": "Nhập địa chỉ DevTools Protocol hoặc để trống để tự động phát hiện các instance Chrome cục bộ. Nút Kiểm tra kết nối sẽ thử URL tùy chỉnh nếu được cung cấp, hoặc tự động phát hiện nếu trường này trống." - } - }, "checkpoints": { "timeout": { "label": "Thời gian chờ khởi tạo điểm kiểm tra (giây)", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 1bd2121506..35646dc1df 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -364,44 +364,6 @@ "total": "总成本: ${{cost}}", "includesSubtasks": "包括子任务成本" }, - "browser": { - "session": "浏览器会话", - "active": "活动中", - "rooWantsToUse": "Roo想使用浏览器", - "consoleLogs": "控制台日志", - "noNewLogs": "(没有新日志)", - "screenshot": "浏览器截图", - "cursor": "光标", - "navigation": { - "step": "步骤 {{current}} / {{total}}", - "previous": "上一步", - "next": "下一步" - }, - "sessionStarted": "浏览器会话已启动", - "actions": { - "title": "浏览器操作: ", - "launched": "浏览器已启动", - "launch": "访问 {{url}}", - "clicked": "点击位置: {{coordinate}}", - "click": "点击 ({{coordinate}})", - "typed": "输入: {{text}}", - "type": "输入 \"{{text}}\"", - "pressed": "已按 {{key}}", - "press": "按 {{key}}", - "scrolledDown": "已向下滚动", - "scrollDown": "向下滚动", - "scrolledUp": "已向上滚动", - "scrollUp": "向上滚动", - "hovered": "悬停位置: {{coordinate}}", - "hover": "悬停 ({{coordinate}})", - "resized": "大小已更改: {{size}}", - "resize": "调整大小为 {{size}}", - "screenshotSaved": "截图已保存", - "screenshot": "将截图保存到 {{path}}", - "closed": "浏览器已关闭", - "close": "关闭浏览器" - } - }, "codeblock": { "tooltips": { "expand": "展开代码块", diff --git a/webview-ui/src/i18n/locales/zh-CN/prompts.json b/webview-ui/src/i18n/locales/zh-CN/prompts.json index 3ecec96c6d..84255d8868 100644 --- a/webview-ui/src/i18n/locales/zh-CN/prompts.json +++ b/webview-ui/src/i18n/locales/zh-CN/prompts.json @@ -25,7 +25,6 @@ "toolNames": { "read": "读取文件", "edit": "编辑文件", - "browser": "浏览器", "command": "运行命令", "mcp": "MCP服务" }, diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 4c2eef0efb..aa0391ac55 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -31,7 +31,6 @@ "mcp": "MCP 服务", "worktrees": "Worktrees", "autoApprove": "自动批准", - "browser": "计算机交互", "checkpoints": "存档点", "notifications": "通知", "contextManagement": "上下文", @@ -241,10 +240,6 @@ "description": "允许 Roo 创建和编辑受保护的文件(如 .rooignore 和 .roo/ 配置文件),无需批准。" } }, - "browser": { - "label": "浏览器", - "description": "自动执行浏览器操作而无需批准 — 注意:仅当模型支持计算机功能调用时适用" - }, "mcp": { "label": "MCP", "description": "允许自动调用MCP服务而无需批准" @@ -592,34 +587,6 @@ "maxTokensDescription": "Claude Code 响应的最大输出 Token 数量。默认为 8000。" } }, - "browser": { - "enable": { - "label": "启用浏览器工具", - "description": "启用后,若模型支持计算机功能调用,Roo 可以使用浏览器与网站交互。 <0>了解更多" - }, - "viewport": { - "label": "视口大小", - "description": "选择浏览器交互的视口大小。这会影响网站的显示方式和交互方式。", - "options": { - "largeDesktop": "大桌面 (1280x800)", - "smallDesktop": "小桌面 (900x600)", - "tablet": "平板 (768x1024)", - "mobile": "移动设备 (360x640)" - } - }, - "screenshotQuality": { - "label": "截图质量", - "description": "调整浏览器的截图质量。更高的值提供更清晰的截图,但会增加 token 消耗。" - }, - "remote": { - "label": "使用远程浏览器连接", - "description": "连接到启用远程调试的 Chrome 浏览器 (--remote-debugging-port=9222)。", - "urlPlaceholder": "自定义 URL(例如 http://localhost:9222)", - "testButton": "测试连接", - "testingButton": "测试中...", - "instructions": "输入 DevTools 协议主机地址或留空以自动发现本地 Chrome 实例。测试连接按钮将尝试使用自定义 URL(如果提供),或者如果字段为空则自动发现。" - } - }, "checkpoints": { "timeout": { "label": "存档点初始化超时时间(秒)", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index e26bb516bb..8009d33f21 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -393,44 +393,6 @@ "total": "總成本:${{cost}}", "includesSubtasks": "包含子任務成本" }, - "browser": { - "session": "瀏覽器工作階段", - "active": "使用中", - "rooWantsToUse": "Roo 想要使用瀏覽器", - "consoleLogs": "主控台記錄", - "noNewLogs": "(沒有新記錄)", - "screenshot": "瀏覽器螢幕擷圖", - "cursor": "游標", - "navigation": { - "step": "步驟 {{current}}/{{total}}", - "previous": "上一步", - "next": "下一步" - }, - "sessionStarted": "瀏覽器工作階段已啟動", - "actions": { - "title": "瀏覽器動作:", - "launched": "瀏覽器已啟動", - "launch": "在 {{url}} 啟動瀏覽器", - "clicked": "點選位置:{{coordinate}}", - "click": "點選 ({{coordinate}})", - "typed": "輸入:{{text}}", - "type": "輸入「{{text}}」", - "pressed": "已按下 {{key}}", - "press": "按下 {{key}}", - "scrolledDown": "已向下捲動", - "scrollDown": "向下捲動", - "scrolledUp": "已向上捲動", - "scrollUp": "向上捲動", - "hovered": "滑鼠停留位置:{{coordinate}}", - "hover": "滑鼠停留 ({{coordinate}})", - "resized": "大小已變更:{{size}}", - "resize": "調整大小為 {{size}}", - "screenshotSaved": "螢幕擷圖已儲存", - "screenshot": "將螢幕擷圖儲存至 {{path}}", - "closed": "瀏覽器已關閉", - "close": "關閉瀏覽器" - } - }, "codeblock": { "tooltips": { "expand": "展開程式碼區塊", diff --git a/webview-ui/src/i18n/locales/zh-TW/prompts.json b/webview-ui/src/i18n/locales/zh-TW/prompts.json index 2620472af9..89e84824ba 100644 --- a/webview-ui/src/i18n/locales/zh-TW/prompts.json +++ b/webview-ui/src/i18n/locales/zh-TW/prompts.json @@ -25,7 +25,6 @@ "toolNames": { "read": "讀取檔案", "edit": "編輯檔案", - "browser": "使用瀏覽器", "command": "執行命令", "mcp": "使用 MCP" }, diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index cf27a25498..0e51b95b6a 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -31,7 +31,6 @@ "mcp": "MCP 伺服器", "worktrees": "Worktree", "autoApprove": "自動核准", - "browser": "瀏覽器", "checkpoints": "檢查點", "notifications": "通知", "contextManagement": "上下文", @@ -249,10 +248,6 @@ "description": "允許 Roo 建立與編輯受保護的檔案(如 .rooignore 和 .roo/ 設定檔)且無需核准。" } }, - "browser": { - "label": "瀏覽器", - "description": "無需核准即可自動執行瀏覽器動作。注意:僅適用於支援電腦操作的模型。" - }, "mcp": { "label": "MCP", "description": "啟用 MCP 伺服器檢視中個別 MCP 工具的自動核准(需同時啟用此設定與該工具的「始終允許」核取方塊)" @@ -602,34 +597,6 @@ "maxTokensDescription": "Claude Code 回應的最大輸出 Token 數量。預設為 8000。" } }, - "browser": { - "enable": { - "label": "啟用瀏覽器工具", - "description": "啟用後,Roo 可在使用支援電腦使用的模型時使用瀏覽器與網站互動。 <0>了解更多" - }, - "viewport": { - "label": "視窗大小", - "description": "選擇瀏覽器互動的視窗大小。這會影響網站的顯示方式和互動方式。", - "options": { - "largeDesktop": "大型桌面 (1280x800)", - "smallDesktop": "小型桌面 (900x600)", - "tablet": "平板 (768x1024)", - "mobile": "行動裝置 (360x640)" - } - }, - "screenshotQuality": { - "label": "截圖品質", - "description": "調整瀏覽器截圖的 WebP 品質。數值越高截圖越清晰,但會增加 Token 用量。" - }, - "remote": { - "label": "使用遠端瀏覽器連線", - "description": "連線到啟用遠端除錯的 Chrome 瀏覽器(--remote-debugging-port=9222)。", - "urlPlaceholder": "自訂 URL(例如 http://localhost:9222)", - "testButton": "測試連線", - "testingButton": "測試中...", - "instructions": "請輸入 DevTools Protocol 主機位址,或留空以自動偵測本機 Chrome 執行個體。「測試連線」按鈕將嘗試連線至您提供的自訂 URL,若未提供則會自動偵測。" - } - }, "checkpoints": { "timeout": { "label": "檢查點初始化逾時(秒)", diff --git a/webview-ui/vite.config.ts b/webview-ui/vite.config.ts index 40c1e449b9..e60a3fada6 100644 --- a/webview-ui/vite.config.ts +++ b/webview-ui/vite.config.ts @@ -110,7 +110,7 @@ export default defineConfig(({ mode }) => { sourcemap: true, // Ensure source maps are properly included in the build minify: mode === "production" ? "esbuild" : false, - // Use a single combined CSS bundle so both webviews share styles + // Use a single combined CSS bundle so all webviews share styles cssCodeSplit: false, rollupOptions: { // Externalize vscode module - it's imported by file-search.ts which is @@ -119,7 +119,6 @@ export default defineConfig(({ mode }) => { external: ["vscode"], input: { index: resolve(__dirname, "index.html"), - "browser-panel": resolve(__dirname, "browser-panel.html"), }, output: { entryFileNames: `assets/[name].js`,