diff --git a/.roo/commands/release.md b/.roo/commands/release.md index 8adf57e6f0..707844cdc4 100644 --- a/.roo/commands/release.md +++ b/.roo/commands/release.md @@ -16,14 +16,14 @@ argument-hint: patch | minor | major [list of changes] ``` -- Always include contributor attribution using format: (thanks @username!) -- For PRs that close issues, also include the issue number and reporter: "- Fix: Description (#123 by @reporter, PR by @contributor)" -- For PRs without linked issues, use the standard format: "- Add support for feature (thanks @contributor!)" +- Always include contributor attribution and the PR number: use "(PR # by @username)". +- For PRs that close issues, include both the issue number and the PR number and authors: "- Fix: Description (#123 by @reporter, PR #456 by @contributor)" +- For PRs without linked issues, include the PR number and author: "- Add support for feature (PR #456 by @contributor)" - Provide brief descriptions of each item to explain the change - Order the list from most important to least important - Example formats: - - With issue: "- Fix: Resolve memory leak in extension (#456 by @issueReporter, PR by @prAuthor)" - - Without issue: "- Add support for Gemini 2.5 Pro caching (thanks @contributor!)" + - With issue: "- Fix: Resolve memory leak in extension (#456 by @issueReporter, PR #789 by @prAuthor)" + - Without issue: "- Add support for Gemini 2.5 Pro caching (PR #789 by @contributor)" - CRITICAL: Include EVERY SINGLE PR in the changeset - don't assume you know which ones are important. Count the total PRs to verify completeness and cross-reference the list to ensure nothing is missed. 6. If the generate_image tool is available, create a release image at `releases/[version]-release.png` diff --git a/CHANGELOG.md b/CHANGELOG.md index 63d1d5dd32..aa3d4527d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,79 @@ # Roo Code Changelog +## [3.33.1] - 2025-11-18 + +![3.33.1 Release - Native Tool Protocol Fixes](/releases/3.33.1-release.png) + +- Add native tool calling support to OpenAI-compatible (PR #9369 by @mrubens) +- Fix: Resolve native tool protocol race condition causing 400 errors (PR #9363 by @daniel-lxs) +- Fix: Update tools to return structured JSON for native protocol (PR #9373 by @daniel-lxs) +- Fix: Include nativeArgs in tool repetition detection (PR #9377 by @daniel-lxs) +- Fix: Ensure no XML parsing when protocol is native (PR #9371 by @daniel-lxs) +- Fix: Gemini maxOutputTokens and reasoning config (PR #9375 by @hannesrudolph) +- Fix: Gemini thought signature validation and token counting errors (PR #9380 by @hannesrudolph) +- Fix: Exclude XML tool examples from MODES section when native protocol enabled (PR #9367 by @daniel-lxs) +- Retry eval tasks if API instability detected (PR #9365 by @cte) +- Add toolProtocol property to PostHog tool usage telemetry (PR #9374 by @app/roomote) + +## [3.33.0] - 2025-11-18 + +![v3.33.0 Release - Twin Kangaroos and the Gemini Constellation](/releases/v3.33.0-release.png) + +- Add Gemini 3 Pro Preview model (PR #9357 by @hannesrudolph) +- Improve Google Gemini defaults with better temperature and cost reporting (PR #9327 by @hannesrudolph) +- Enable native tool calling for openai-native provider (PR #9348 by @hannesrudolph) +- Add git status information to environment details (PR #9310 by @daniel-lxs) +- Add tool protocol selector to advanced settings (PR #9324 by @daniel-lxs) +- Implement dynamic tool protocol resolution with proper precedence hierarchy (PR #9286 by @daniel-lxs) +- Move Import/Export functionality to Modes view toolbar and cleanup Mode Edit view (PR #9077 by @hannesrudolph) +- Update cloud agent CTA to point to setup page (PR #9338 by @app/roomote) +- Fix: Prevent duplicate tool_result blocks in native tool protocol (PR #9248 by @daniel-lxs) +- Fix: Format tool responses properly for native protocol (PR #9270 by @daniel-lxs) +- Fix: Centralize toolProtocol configuration checks (PR #9279 by @daniel-lxs) +- Fix: Preserve tool blocks for native protocol in conversation history (PR #9319 by @daniel-lxs) +- Fix: Prevent infinite loop when task_done succeeds (PR #9325 by @daniel-lxs) +- Fix: Sync parser state with profile/model changes (PR #9355 by @daniel-lxs) +- Fix: Pass tool protocol parameter to lineCountTruncationError (PR #9358 by @daniel-lxs) +- Use VSCode theme color for outline button borders (PR #9336 by @app/roomote) +- Replace broken badgen.net badges with shields.io (PR #9318 by @app/roomote) +- Add max git status files setting to evals (PR #9322 by @mrubens) +- Roo Code Cloud Provider pricing page and changes elsewhere (PR #9195 by @brunobergher) + +## [3.32.1] - 2025-11-14 + +![3.32.1 Release - Bug Fixes](/releases/3.32.1-release.png) + +- Fix: Add abort controller for request cancellation in OpenAI native protocol (PR #9276 by @daniel-lxs) +- Fix: Resolve duplicate tool blocks causing 'tool has already been used' error in native protocol mode (PR #9275 by @daniel-lxs) +- Fix: Prevent duplicate tool_result blocks in native protocol mode for read_file (PR #9272 by @daniel-lxs) +- Fix: Correct OpenAI Native handling of encrypted reasoning blocks to prevent errors during condensing (PR #9263 by @hannesrudolph) +- Fix: Disable XML parser for native tool protocol to prevent parsing conflicts (PR #9277 by @daniel-lxs) + +## [3.32.0] - 2025-11-14 + +![3.32.0 Release - GPT-5.1 models and OpenAI prompt caching](/releases/3.32.0-release.png) + +- Feature: Add GPT-5.1 models to OpenAI provider (PR #9252 by @hannesrudolph) +- Feature: Support for OpenAI Responses 24 hour prompt caching (PR #9259 by @hannesrudolph) +- Fix: Repair the share button in the UI (PR #9253 by @hannesrudolph) +- Docs: Include PR numbers in the release guide to improve traceability (PR #9236 by @hannesrudolph) + +## [3.31.3] - 2025-11-13 + +![3.31.3 Release - Kangaroo Decrypting a Message](/releases/3.31.3-release.png) + +- Fix: OpenAI Native encrypted_content handling and remove gpt-5-chat-latest verbosity flag (#9225 by @politsin, PR by @hannesrudolph) +- Fix: Roo Code Cloud provider Anthropic input token normalization to avoid double-counting (thanks @hannesrudolph!) +- Refactor: Rename sliding-window to context-management and truncateConversationIfNeeded to manageContext (thanks @hannesrudolph!) + +## [3.31.2] - 2025-11-12 + +- Fix: Apply updated API profile settings when provider/model unchanged (#9208 by @hannesrudolph, PR by @hannesrudolph) +- Migrate conversation continuity to plugin-side encrypted reasoning items using Responses API for improved reliability (thanks @hannesrudolph!) +- Fix: Include mcpServers in getState() for auto-approval (#9190 by @bozoweed, PR by @daniel-lxs) +- Batch settings updates from the webview to the extension host for improved performance (thanks @cte!) +- Fix: Replace rate-limited badges with badgen.net to improve README reliability (thanks @daniel-lxs!) + ## [3.31.1] - 2025-11-11 ![3.31.1 Release - Kangaroo Stuck in the Clouds](/releases/3.31.1-release.png) diff --git a/README.md b/README.md index d6a7d99c8e..9e98cd8845 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@

- VS Code - Installs - Rating + VS Code Marketplace X YouTube Join Discord @@ -37,7 +35,7 @@ - [简体中文](locales/zh-CN/README.md) - [繁體中文](locales/zh-TW/README.md) - ... - + --- @@ -169,16 +167,6 @@ We love community contributions! Get started by reading our [CONTRIBUTING.md](CO --- -## Contributors - -Thanks to all our contributors who have helped make Roo Code better! - - - -[![Contributors](https://contrib.rocks/image?repo=RooCodeInc/roo-code&max=120&columns=12&cacheBust=0000000000)](https://github.com/RooCodeInc/roo-code/graphs/contributors) - - - ## License [Apache 2.0 © 2025 Roo Code, Inc.](./LICENSE) diff --git a/apps/web-roo-code/.env.example b/apps/web-roo-code/.env.example index 01258bc500..c5e8726df1 100644 --- a/apps/web-roo-code/.env.example +++ b/apps/web-roo-code/.env.example @@ -1,7 +1,6 @@ # PostHog Analytics Configuration # Replace these values with your actual PostHog API key and host NEXT_PUBLIC_POSTHOG_KEY=your_posthog_api_key_here -NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com # Basin Form Endpoint for Static Form Submissions # Replace this with your actual Basin form endpoint (e.g., https://usebasin.com/f/your-form-id) diff --git a/apps/web-roo-code/src/app/pricing/page.tsx b/apps/web-roo-code/src/app/pricing/page.tsx index 9985881e1d..664f0ea904 100644 --- a/apps/web-roo-code/src/app/pricing/page.tsx +++ b/apps/web-roo-code/src/app/pricing/page.tsx @@ -1,4 +1,4 @@ -import { Users, Building2, ArrowRight, Star, LucideIcon, Check, Cloud } from "lucide-react" +import { Users, Building2, ArrowRight, Star, LucideIcon, Check, Cloud, PlugZap } from "lucide-react" import type { Metadata } from "next" import Link from "next/link" @@ -64,7 +64,6 @@ interface PricingTier { period?: string creditPrice?: string trial?: string - cancellation?: string description: string featuresIntro?: string features: string[] @@ -80,13 +79,12 @@ const pricingTiers: PricingTier[] = [ name: "Cloud Free", icon: Cloud, price: "$0", - cancellation: "Cancel anytime", description: "For folks just getting started", features: [ "Token usage analytics", + "Access to the Roo Code Cloud Provider, including early access to free stealth models", "Follow your tasks from anywhere", "Share tasks with friends and co-workers", - "Early access to free AI Models", "Community support", ], cta: { @@ -95,18 +93,18 @@ const pricingTiers: PricingTier[] = [ }, }, { - name: "Pro", + name: "Cloud Pro", icon: Star, price: "$20", period: "/mo", - trial: "Free 14-day trial · ", + trial: "Free for 14 days, then", creditPrice: `$${PRICE_CREDITS}`, - cancellation: "Cancel anytime", description: "For pro Roo coders", featuresIntro: "Everything in Free +", features: [ - "Cloud Agents: PR Reviewer and more", - "Roomote Control: Start, stop and control tasks from anywhere", + "Cloud Agents: Coder, Explainer, Planner, Reviewer, Fixer and more", + "Start tasks from Slack", + "Roomote Control: Start, stop and control extension tasks from anywhere", "Paid support", ], cta: { @@ -115,13 +113,12 @@ const pricingTiers: PricingTier[] = [ }, }, { - name: "Team", + name: "Cloud Team", icon: Users, price: "$99", period: "/mo", creditPrice: `$${PRICE_CREDITS}`, - trial: "Free 14-day trial · ", - cancellation: "Cancel anytime", + trial: "Free for 14 days, then", description: "For AI-forward teams", featuresIntro: "Everything in Pro +", features: ["Unlimited users (no per-seat cost)", "Shared configuration & policies", "Centralized billing"], @@ -151,12 +148,30 @@ export default function PricingPage() { - {/* Free Extension Notice */} -

-
+
+

- The Roo Code extension is free! - Roo Code Cloud is an optional service which takes it to the next level. + The Roo Code extension is totally free! + But Cloud takes you so much further. +

+
+
+ +
+

+ Roo Code Provider + +

+
+

+ On any plan, you can bring your own provider key or use the built-in Roo Code Cloud provider. +

+

+ We offer a select mix of tested state of the art closed and open weight LLMs for you to choose, + with no markup. + + See detailed pricing +

@@ -183,7 +198,7 @@ export default function PricingPage() {

{tier.featuresIntro} 

-
    +
      {tier.features.map((feature) => (
    • @@ -193,20 +208,28 @@ export default function PricingPage() {
-

- {tier.price} - {tier.period} +

{tier.trial}

+ +

+ {tier.price} + {tier.period} + prepaid credits

- {tier.creditPrice && ( -

- + {tier.creditPrice}/hour for Cloud tasks -

- )} - -

- {tier.trial} - {tier.cancellation} +

+ {tier.creditPrice && ( + <> + Cloud Agents: {tier.creditPrice}/hour if used +
+ + )} + Inference:{" "} + + Roo Provider pricing + {" "} + or{" "} + + BYOK +

{tier.cta.isContactForm ? ( @@ -249,7 +272,7 @@ export default function PricingPage() {

Frequently Asked Questions

-
+

Wait, is Roo Code free or not?

Yes! The Roo Code VS Code extension is open source and free forever. The extension acts @@ -257,7 +280,7 @@ export default function PricingPage() { Code Cloud.

-
+

Is there a free trial?

Yes, all paid plans come with a 14-day free trial to try out functionality. @@ -266,12 +289,25 @@ export default function PricingPage() { To use Cloud Agents, you can buy credits.

-
-

How do Cloud Agent credits work?

+
+

How do credits work?

- Cloud Agents are a version of Roo running in the cloud without depending on your IDE. - You can run as many as you want, and bring your own inference provider key. + Roo Code Cloud credits can be used in two ways:

+
    +
  • To pay for Cloud Agents running time (${PRICE_CREDITS}/hour)
  • +
  • + To pay for AI model inference costs ( + + varies by model + + ) +
  • +

To cover our infrastructure costs, we charge ${PRICE_CREDITS}/hour while the agent is running (independent of inference costs). @@ -280,25 +316,25 @@ export default function PricingPage() { There are no markups, no tiers, no dumbing-down of models to increase our profit.

-
+

Do I need a credit card for the free trial?

Yes, but you won't be charged until your trial ends, except for credit purchases.

You can cancel anytime with one click.

-
+

What payment methods do you accept?

We accept all major credit cards, debit cards, and can arrange invoice billing for Enterprise customers.

-
-

Can I change plans anytime?

+
+

Can I cancel or change plans?

- Yes, you can upgrade or downgrade your plan at any time. Changes will be reflected in - your next billing cycle. + Yes, you can upgrade, downgrade or cancel your plan at any time. Changes will be + reflected in your next billing cycle.

diff --git a/apps/web-roo-code/src/app/provider/pricing/components/model-card.tsx b/apps/web-roo-code/src/app/provider/pricing/components/model-card.tsx new file mode 100644 index 0000000000..26f3545791 --- /dev/null +++ b/apps/web-roo-code/src/app/provider/pricing/components/model-card.tsx @@ -0,0 +1,190 @@ +import { ModelWithTotalPrice } from "@/lib/types/models" +import { formatCurrency, formatTokens } from "@/lib/formatters" +import { + ArrowLeftToLine, + ArrowRightToLine, + Building2, + Check, + Expand, + Gift, + HardDriveDownload, + HardDriveUpload, + RulerDimensionLine, + ChevronDown, + ChevronUp, +} from "lucide-react" +import { useState } from "react" + +interface ModelCardProps { + model: ModelWithTotalPrice +} + +export function ModelCard({ model }: ModelCardProps) { + // Prices are per token, multiply by 1M to get price per million tokens + const inputPrice = parseFloat(model.pricing.input) * 1_000_000 + const outputPrice = parseFloat(model.pricing.output) * 1_000_000 + const cacheReadPrice = parseFloat(model.pricing.input_cache_read || "0") * 1_000_000 + const cacheWritePrice = parseFloat(model.pricing.input_cache_write || "0") * 1_000_000 + + const free = model.tags.includes("free") + // Filter tags to only show vision and reasoning + const displayTags = model.tags.filter((tag) => tag === "vision" || tag === "reasoning") + + // Mobile collapsed/expanded state + const [expanded, setExpanded] = useState(false) + + return ( +
+ {/* Header: always visible */} +
+

+ {model.name} + {free && ( + + + Free! + + )} +

+

+ {model.description} +

+
+ + {/* Content - pinned to bottom */} +
+ + + {/* Provider: always visible if present */} + {model.owned_by && ( + + + + + )} + + {/* Context Window: always visible */} + + + + + + {/* Max Output Tokens: always visible on >=sm, expandable on mobile */} + + + + + + {/* Input Price: always visible */} + + + + + + {/* Output Price: always visible */} + + + + + + {/* Cache pricing: only visible on mobile when expanded, always visible on >=sm */} + {cacheReadPrice > 0 && ( + + + + + )} + + {cacheWritePrice > 0 && ( + + + + + )} + + {/* Tags row: only show if there are vision or reasoning tags */} + {displayTags.length > 0 && ( + + + + + )} + + {/* Mobile-only toggle row */} + + + + +
+ + Provider + {model.owned_by}
+ + Context Window + {formatTokens(model.context_window)}
+ + Max Output Tokens + {formatTokens(model.max_tokens)}
+ + Input Price + + {inputPrice === 0 ? "Free" : `${formatCurrency(inputPrice)}/1M tokens`} +
+ + Output Price + + {outputPrice === 0 ? "Free" : `${formatCurrency(outputPrice)}/1M tokens`} +
+ + Cache Read + {formatCurrency(cacheReadPrice)}/1M tokens
+ + Cache Write + {formatCurrency(cacheWritePrice)}/1M tokens
Features + {displayTags.map((tag) => ( + + + {tag} + + ))} +
+ +
+
+
+ ) +} diff --git a/apps/web-roo-code/src/app/provider/pricing/page.tsx b/apps/web-roo-code/src/app/provider/pricing/page.tsx new file mode 100644 index 0000000000..4558355d2c --- /dev/null +++ b/apps/web-roo-code/src/app/provider/pricing/page.tsx @@ -0,0 +1,253 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { ModelCard } from "./components/model-card" +import { Model, ModelWithTotalPrice, ModelsResponse, SortOption } from "@/lib/types/models" +import Link from "next/link" +import { ChevronDown, CircleX, Loader, LoaderCircle, Search } from "lucide-react" + +const API_URL = "https://api.roocode.com/proxy/v1/models?include_paid=true" + +const faqs = [ + { + question: "What are AI model providers?", + answer: "AI model providers offer various language models with different capabilities and pricing.", + }, + { + question: "How is pricing calculated?", + answer: "Pricing is based on token usage for input and output, measured per million tokens, like pretty much any other provider out there.", + }, + { + question: "What is the Roo Code Cloud Provider?", + answer: ( + <> +

This is our very own model provider, optimized to work seamlessly with Roo Code Cloud.

+

+ It offers a selection of state-of-the-art LLMs (both closed and open weight) we know work well with + Roo for you to choose, with no markup. +

+

+ We also often feature 100% free models which labs share with us for the community to use and provide + feedback. +

+ + ), + }, + { + question: "But how much does the Roo Code Cloud service cost?", + answer: ( + <> + Our{" "} + + service pricing is here. + + + ), + }, +] + +function calculateTotalPrice(model: Model): number { + return parseFloat(model.pricing.input) + parseFloat(model.pricing.output) +} + +function enrichModelWithTotalPrice(model: Model): ModelWithTotalPrice { + return { + ...model, + totalPrice: calculateTotalPrice(model), + } +} + +export default function ProviderPricingPage() { + const [models, setModels] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [searchQuery, setSearchQuery] = useState("") + const [sortOption, setSortOption] = useState("alphabetical") + + useEffect(() => { + async function fetchModels() { + try { + setLoading(true) + setError(null) + const response = await fetch(API_URL) + if (!response.ok) { + throw new Error(`Failed to fetch models: ${response.statusText}`) + } + const data: ModelsResponse = await response.json() + const enrichedModels = data.data.map(enrichModelWithTotalPrice) + setModels(enrichedModels) + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred while fetching models") + } finally { + setLoading(false) + } + } + + fetchModels() + }, []) + + const filteredAndSortedModels = useMemo(() => { + // Filter out deprecated models + let filtered = models.filter((model) => !model.deprecated) + + // Filter by search query + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase() + filtered = filtered.filter((model) => { + return ( + model.name.toLowerCase().includes(query) || + model.owned_by?.toLowerCase().includes(query) || + model.description.toLowerCase().includes(query) + ) + }) + } + + // Sort filtered results + const sorted = [...filtered] + switch (sortOption) { + case "alphabetical": + sorted.sort((a, b) => a.name.localeCompare(b.name)) + break + case "price-asc": + sorted.sort((a, b) => a.totalPrice - b.totalPrice) + break + case "price-desc": + sorted.sort((a, b) => b.totalPrice - a.totalPrice) + break + case "context-window-asc": + sorted.sort((a, b) => a.context_window - b.context_window) + break + case "context-window-desc": + sorted.sort((a, b) => b.context_window - a.context_window) + break + } + + return sorted + }, [models, searchQuery, sortOption]) + + // Count non-deprecated models for the display + const nonDeprecatedCount = useMemo(() => models.filter((model) => !model.deprecated).length, [models]) + + return ( + <> +
+
+
+

+ Roo Code Cloud Provider Pricing +

+

+ See pricing and features for all models we offer in our selection. +
+ You can always bring your own key ( + + FAQ + + ). +

+
+
+
+ +
+
+
+
+
+
+
+ + setSearchQuery(e.target.value)} + className="w-full rounded-full border border-input bg-background px-10 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" + /> + +
+ {filteredAndSortedModels.length} of {nonDeprecatedCount} models +
+
+
+
+
+ + +
+
+
+
+
+ +
+
+ {loading && ( +
+ +

Loading model list...

+
+ )} + + {error && ( +
+ +

Oops, couldn't load the model list.

+

Try again in a bit please.

+
+ )} + + {!loading && !error && filteredAndSortedModels.length === 0 && ( +
+ +

No models match your search.

+

+ Keep in mind we don't have every model under the sun – only the ones we think + are worth using. +
+ You can always use a third-party provider to access a wider selection. +

+
+ )} + + {!loading && !error && filteredAndSortedModels.length > 0 && ( +
+ {filteredAndSortedModels.map((model) => ( + + ))} +
+ )} +
+
+
+ + {/* FAQ Section */} +
+ +
+
+

Frequently Asked Questions

+
+
+ {faqs.map((faq, index) => ( +
+

{faq.question}

+

{faq.answer}

+
+ ))} +
+
+
+ + ) +} diff --git a/apps/web-roo-code/src/components/providers/posthog-provider.tsx b/apps/web-roo-code/src/components/providers/posthog-provider.tsx index 0d8932d549..29e2fca933 100644 --- a/apps/web-roo-code/src/components/providers/posthog-provider.tsx +++ b/apps/web-roo-code/src/components/providers/posthog-provider.tsx @@ -32,7 +32,6 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) { // Initialize PostHog immediately on the client side if (typeof window !== "undefined" && !posthog.__loaded) { const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY - const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST // Check if environment variables are set if (!posthogKey) { @@ -43,19 +42,13 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) { return } - if (!posthogHost) { - console.warn( - "PostHog host URL is missing. Using default host. " + - "Please set NEXT_PUBLIC_POSTHOG_HOST in your .env file.", - ) - } - // Check if user has already consented to cookies const userHasConsented = hasConsent() // Initialize PostHog with appropriate persistence based on consent posthog.init(posthogKey, { - api_host: posthogHost || "https://us.i.posthog.com", + api_host: "https://ph.roocode.com", + ui_host: "https://us.posthog.com", capture_pageview: false, // We handle pageview tracking manually loaded: (posthogInstance) => { if (process.env.NODE_ENV === "development") { diff --git a/apps/web-roo-code/src/lib/constants.ts b/apps/web-roo-code/src/lib/constants.ts index 3fd6900953..4191190a14 100644 --- a/apps/web-roo-code/src/lib/constants.ts +++ b/apps/web-roo-code/src/lib/constants.ts @@ -26,7 +26,7 @@ export const EXTERNAL_LINKS = { TESTIMONIALS: "https://roocode.com/#testimonials", CLOUD_APP_LOGIN: "https://app.roocode.com/sign-in", CLOUD_APP_SIGNUP: "https://app.roocode.com/sign-up", - CLOUD_APP_SIGNUP_PRO: "https://app.roocode.com/sign-up?redirect_url=/cloud-agents/welcome", + CLOUD_APP_SIGNUP_PRO: "https://app.roocode.com/sign-up?redirect_url=/cloud-agents/setup", } export const INTERNAL_LINKS = { diff --git a/apps/web-roo-code/src/lib/formatters.ts b/apps/web-roo-code/src/lib/formatters.ts new file mode 100644 index 0000000000..88ca558764 --- /dev/null +++ b/apps/web-roo-code/src/lib/formatters.ts @@ -0,0 +1,22 @@ +const formatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", +}) + +export const formatCurrency = (amount: number) => formatter.format(amount) + +export const formatTokens = (tokens: number) => { + if (tokens < 1000) { + return tokens.toString() + } + + if (tokens < 1000000) { + return `${(tokens / 1000).toFixed(1)}K` + } + + if (tokens < 1000000000) { + return `${(tokens / 1000000).toFixed(1)}M` + } + + return `${(tokens / 1000000000).toFixed(1)}B` +} diff --git a/apps/web-roo-code/src/lib/types/models.ts b/apps/web-roo-code/src/lib/types/models.ts new file mode 100644 index 0000000000..4bcd3116ab --- /dev/null +++ b/apps/web-roo-code/src/lib/types/models.ts @@ -0,0 +1,31 @@ +export interface ModelPricing { + input: string + output: string + input_cache_read: string + input_cache_write: string +} + +export interface Model { + id: string + object: string + created: number + owned_by: string + name: string + description: string + context_window: number + max_tokens: number + type: string + tags: string[] + pricing: ModelPricing + deprecated?: boolean +} + +export interface ModelsResponse { + data: Model[] +} + +export interface ModelWithTotalPrice extends Model { + totalPrice: number +} + +export type SortOption = "alphabetical" | "price-asc" | "price-desc" | "context-window-asc" | "context-window-desc" diff --git a/locales/ca/README.md b/locales/ca/README.md index 2c7d1788f7..b8feb8a99a 100644 --- a/locales/ca/README.md +++ b/locales/ca/README.md @@ -1,5 +1,5 @@

- VS Code + VS Code Marketplace X YouTube Join Discord @@ -167,16 +167,6 @@ Ens encanten les contribucions de la comunitat! Comença llegint el nostre [CONT --- -## Col·laboradors - -Gràcies a tots els nostres col·laboradors que han ajudat a millorar Roo Code! - - - -[![Contributors](https://contrib.rocks/image?repo=RooCodeInc/roo-code&max=120&columns=12&cacheBust=0000000000)](https://github.com/RooCodeInc/roo-code/graphs/contributors) - - - ## Llicència [Apache 2.0 © 2025 Roo Code, Inc.](../../LICENSE) diff --git a/locales/de/README.md b/locales/de/README.md index 22b4db6399..d63dfdf955 100644 --- a/locales/de/README.md +++ b/locales/de/README.md @@ -1,5 +1,5 @@

- VS Code + VS Code Marketplace X YouTube Join Discord @@ -167,16 +167,6 @@ Wir lieben Community-Beiträge! Lies unsere [CONTRIBUTING.md](CONTRIBUTING.md), --- -## Mitwirkende - -Danke an alle unsere Mitwirkenden, die geholfen haben, Roo Code besser zu machen! - - - -[![Contributors](https://contrib.rocks/image?repo=RooCodeInc/roo-code&max=120&columns=12&cacheBust=0000000000)](https://github.com/RooCodeInc/roo-code/graphs/contributors) - - - ## Lizenz [Apache 2.0 © 2025 Roo Code, Inc.](../../LICENSE) diff --git a/locales/es/README.md b/locales/es/README.md index 3f0135c1d8..af7666d106 100644 --- a/locales/es/README.md +++ b/locales/es/README.md @@ -1,5 +1,5 @@

- VS Code + VS Code Marketplace X YouTube Join Discord @@ -167,16 +167,6 @@ Usamos [changesets](https://github.com/changesets/changesets) para el versionado --- -## Colaboradores - -¡Gracias a todos nuestros colaboradores que han ayudado a mejorar Roo Code! - - - -[![Contributors](https://contrib.rocks/image?repo=RooCodeInc/roo-code&max=120&columns=12&cacheBust=0000000000)](https://github.com/RooCodeInc/roo-code/graphs/contributors) - - - ## Licencia [Apache 2.0 © 2025 Roo Code, Inc.](../../LICENSE) diff --git a/locales/fr/README.md b/locales/fr/README.md index 9596d9cbae..b92535f9e7 100644 --- a/locales/fr/README.md +++ b/locales/fr/README.md @@ -1,5 +1,5 @@

- VS Code + VS Code Marketplace X YouTube Join Discord @@ -167,16 +167,6 @@ Nous adorons les contributions de la communauté ! Commencez par lire notre [CON --- -## Contributeurs - -Merci à tous nos contributeurs qui ont aidé à améliorer Roo Code ! - - - -[![Contributors](https://contrib.rocks/image?repo=RooCodeInc/roo-code&max=120&columns=12&cacheBust=0000000000)](https://github.com/RooCodeInc/roo-code/graphs/contributors) - - - ## Licence [Apache 2.0 © 2025 Roo Code, Inc.](../../LICENSE) diff --git a/locales/hi/README.md b/locales/hi/README.md index 53939eb802..4499feac72 100644 --- a/locales/hi/README.md +++ b/locales/hi/README.md @@ -1,5 +1,5 @@

- VS Code + VS Code Marketplace X YouTube Join Discord @@ -167,16 +167,6 @@ pnpm install:vsix [-y] [--editor=] --- -## योगदानकर्ता - -हमारे सभी योगदानकर्ताओं को धन्यवाद जिन्होंने Roo Code को बेहतर बनाने में मदद की है! - - - -[![Contributors](https://contrib.rocks/image?repo=RooCodeInc/roo-code&max=120&columns=12&cacheBust=0000000000)](https://github.com/RooCodeInc/roo-code/graphs/contributors) - - - ## लाइसेंस [Apache 2.0 © 2025 Roo Code, Inc.](../../LICENSE) diff --git a/locales/id/README.md b/locales/id/README.md index 8ec72301fc..9c46a5ca2e 100644 --- a/locales/id/README.md +++ b/locales/id/README.md @@ -1,5 +1,5 @@

- VS Code + VS Code Marketplace X YouTube Join Discord @@ -167,16 +167,6 @@ Kami menyukai kontribusi komunitas! Mulailah dengan membaca [CONTRIBUTING.md](CO --- -## Kontributor - -Terima kasih kepada semua kontributor kami yang telah membantu membuat Roo Code lebih baik! - - - -[![Contributors](https://contrib.rocks/image?repo=RooCodeInc/roo-code&max=120&columns=12&cacheBust=0000000000)](https://github.com/RooCodeInc/roo-code/graphs/contributors) - - - ## Lisensi [Apache 2.0 © 2025 Roo Code, Inc.](../../LICENSE) diff --git a/locales/it/README.md b/locales/it/README.md index e3687df010..71bbda7c81 100644 --- a/locales/it/README.md +++ b/locales/it/README.md @@ -1,5 +1,5 @@

- VS Code + VS Code Marketplace X YouTube Join Discord @@ -167,16 +167,6 @@ Adoriamo i contributi della community! Inizia leggendo il nostro [CONTRIBUTING.m --- -## Contributori - -Grazie a tutti i nostri contributori che hanno aiutato a migliorare Roo Code! - - - -[![Contributors](https://contrib.rocks/image?repo=RooCodeInc/roo-code&max=120&columns=12&cacheBust=0000000000)](https://github.com/RooCodeInc/roo-code/graphs/contributors) - - - ## Licenza [Apache 2.0 © 2025 Roo Code, Inc.](../../LICENSE) diff --git a/locales/ja/README.md b/locales/ja/README.md index 16e2f0d59d..1bf753546a 100644 --- a/locales/ja/README.md +++ b/locales/ja/README.md @@ -1,5 +1,5 @@

- VS Code + VS Code Marketplace X YouTube Join Discord @@ -167,16 +167,6 @@ VSIXパッケージを手動でインストールしたい場合: --- -## 貢献者 - -Roo Codeをより良くするために協力してくれたすべての貢献者に感謝します! - - - -[![Contributors](https://contrib.rocks/image?repo=RooCodeInc/roo-code&max=120&columns=12&cacheBust=0000000000)](https://github.com/RooCodeInc/roo-code/graphs/contributors) - - - ## ライセンス [Apache 2.0 © 2025 Roo Code, Inc.](../../LICENSE) diff --git a/locales/ko/README.md b/locales/ko/README.md index 6ad585859b..8b8f45ebb5 100644 --- a/locales/ko/README.md +++ b/locales/ko/README.md @@ -1,5 +1,5 @@

- VS Code + VS Code Marketplace X YouTube Join Discord @@ -167,16 +167,6 @@ VSIX 패키지를 수동으로 설치하려면: --- -## 기여자 - -Roo Code를 더 좋게 만드는 데 도움을 준 모든 기여자들에게 감사합니다! - - - -[![Contributors](https://contrib.rocks/image?repo=RooCodeInc/roo-code&max=120&columns=12&cacheBust=0000000000)](https://github.com/RooCodeInc/roo-code/graphs/contributors) - - - ## 라이선스 [Apache 2.0 © 2025 Roo Code, Inc.](../../LICENSE) diff --git a/locales/nl/README.md b/locales/nl/README.md index c8b660dfb0..983318aa81 100644 --- a/locales/nl/README.md +++ b/locales/nl/README.md @@ -1,5 +1,5 @@

- VS Code + VS Code Marketplace X YouTube Join Discord @@ -167,16 +167,6 @@ We houden van bijdragen uit de community! Begin met het lezen van onze [CONTRIBU --- -## Bijdragers - -Dank aan al onze bijdragers die hebben geholpen Roo Code beter te maken! - - - -[![Contributors](https://contrib.rocks/image?repo=RooCodeInc/roo-code&max=120&columns=12&cacheBust=0000000000)](https://github.com/RooCodeInc/roo-code/graphs/contributors) - - - ## Licentie [Apache 2.0 © 2025 Roo Code, Inc.](../../LICENSE) diff --git a/locales/pl/README.md b/locales/pl/README.md index 099c415457..94e23b2980 100644 --- a/locales/pl/README.md +++ b/locales/pl/README.md @@ -1,5 +1,5 @@

- VS Code + VS Code Marketplace X YouTube Join Discord @@ -167,16 +167,6 @@ Uwielbiamy wkłady społeczności! Zacznij od przeczytania naszego pliku [CONTRI --- -## Współtwórcy - -Dziękujemy wszystkim naszym współtwórcom, którzy pomogli ulepszyć Roo Code! - - - -[![Contributors](https://contrib.rocks/image?repo=RooCodeInc/roo-code&max=120&columns=12&cacheBust=0000000000)](https://github.com/RooCodeInc/roo-code/graphs/contributors) - - - ## Licencja [Apache 2.0 © 2025 Roo Code, Inc.](../../LICENSE) diff --git a/locales/pt-BR/README.md b/locales/pt-BR/README.md index ea27fb3a14..abca12ffb7 100644 --- a/locales/pt-BR/README.md +++ b/locales/pt-BR/README.md @@ -1,5 +1,5 @@

- VS Code + VS Code Marketplace X YouTube Join Discord @@ -167,16 +167,6 @@ Adoramos contribuições da comunidade! Comece lendo nosso [CONTRIBUTING.md](CON --- -## Contribuidores - -Obrigado a todos os nossos contribuidores que ajudaram a tornar o Roo Code melhor! - - - -[![Contributors](https://contrib.rocks/image?repo=RooCodeInc/roo-code&max=120&columns=12&cacheBust=0000000000)](https://github.com/RooCodeInc/roo-code/graphs/contributors) - - - ## Licença [Apache 2.0 © 2025 Roo Code, Inc.](../../LICENSE) diff --git a/locales/ru/README.md b/locales/ru/README.md index 70f1f8cd90..d2689da5cf 100644 --- a/locales/ru/README.md +++ b/locales/ru/README.md @@ -1,5 +1,5 @@

- VS Code + VS Code Marketplace X YouTube Join Discord @@ -167,16 +167,6 @@ pnpm install:vsix [-y] [--editor=] --- -## Участники - -Спасибо всем нашим участникам, которые помогли сделать Roo Code лучше! - - - -[![Contributors](https://contrib.rocks/image?repo=RooCodeInc/roo-code&max=120&columns=12&cacheBust=0000000000)](https://github.com/RooCodeInc/roo-code/graphs/contributors) - - - ## Лицензия [Apache 2.0 © 2025 Roo Code, Inc.](../../LICENSE) diff --git a/locales/tr/README.md b/locales/tr/README.md index a540061325..c095677851 100644 --- a/locales/tr/README.md +++ b/locales/tr/README.md @@ -1,5 +1,5 @@

- VS Code + VS Code Marketplace X YouTube Join Discord @@ -167,16 +167,6 @@ Topluluk katkılarını çok seviyoruz! [CONTRIBUTING.md](CONTRIBUTING.md) dosya --- -## Katkıda Bulunanlar - -Roo Code'u daha iyi hale getirmemize yardımcı olan tüm katkıda bulunanlarımıza teşekkür ederiz! - - - -[![Contributors](https://contrib.rocks/image?repo=RooCodeInc/roo-code&max=120&columns=12&cacheBust=0000000000)](https://github.com/RooCodeInc/roo-code/graphs/contributors) - - - ## Lisans [Apache 2.0 © 2025 Roo Code, Inc.](../../LICENSE) diff --git a/locales/vi/README.md b/locales/vi/README.md index 3c846d9c96..5b8100eb2b 100644 --- a/locales/vi/README.md +++ b/locales/vi/README.md @@ -1,5 +1,5 @@

- VS Code + VS Code Marketplace X YouTube Join Discord @@ -167,16 +167,6 @@ Chúng tôi yêu thích những đóng góp của cộng đồng! Bắt đầu b --- -## Những người đóng góp - -Cảm ơn tất cả những người đóng góp đã giúp Roo Code trở nên tốt hơn! - - - -[![Contributors](https://contrib.rocks/image?repo=RooCodeInc/roo-code&max=120&columns=12&cacheBust=0000000000)](https://github.com/RooCodeInc/roo-code/graphs/contributors) - - - ## Giấy phép [Apache 2.0 © 2025 Roo Code, Inc.](../../LICENSE) diff --git a/locales/zh-CN/README.md b/locales/zh-CN/README.md index 5a4f5e77c1..162e787a2b 100644 --- a/locales/zh-CN/README.md +++ b/locales/zh-CN/README.md @@ -1,5 +1,5 @@

- VS Code + VS Code Marketplace X YouTube Join Discord @@ -167,16 +167,6 @@ pnpm install:vsix [-y] [--editor=] --- -## 贡献者 - -感谢所有帮助改进 Roo Code 的贡献者! - - - -[![Contributors](https://contrib.rocks/image?repo=RooCodeInc/roo-code&max=120&columns=12&cacheBust=0000000000)](https://github.com/RooCodeInc/roo-code/graphs/contributors) - - - ## 许可证 [Apache 2.0 © 2025 Roo Code, Inc.](../../LICENSE) diff --git a/locales/zh-TW/README.md b/locales/zh-TW/README.md index 240d6794c4..05d46aca68 100644 --- a/locales/zh-TW/README.md +++ b/locales/zh-TW/README.md @@ -1,5 +1,5 @@

- VS Code + VS Code Marketplace X YouTube Join Discord @@ -167,16 +167,6 @@ pnpm install:vsix [-y] [--editor=] --- -## 貢獻者 - -感謝所有幫助改進 Roo Code 的貢獻者! - - - -[![Contributors](https://contrib.rocks/image?repo=RooCodeInc/roo-code&max=120&columns=12&cacheBust=0000000000)](https://github.com/RooCodeInc/roo-code/graphs/contributors) - - - ## 授權 [Apache 2.0 © 2025 Roo Code, Inc.](../../LICENSE) diff --git a/package.json b/package.json index f7f351d3fc..badfdef578 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "install:vsix": "pnpm install --frozen-lockfile && pnpm clean && pnpm vsix && node scripts/install-vsix.js", "changeset:version": "cp CHANGELOG.md src/CHANGELOG.md && changeset version && cp -vf src/CHANGELOG.md .", "knip": "knip --include files", - "update-contributors": "node scripts/update-contributors.js", "evals": "dotenvx run -f packages/evals/.env.development packages/evals/.env.local -- docker compose -f packages/evals/docker-compose.yml --profile server --profile runner up --build --scale runner=0", "npm:publish:types": "pnpm --filter @roo-code/types npm:publish" }, diff --git a/packages/build/src/types.ts b/packages/build/src/types.ts index cbcd8df828..18db4f2e7c 100644 --- a/packages/build/src/types.ts +++ b/packages/build/src/types.ts @@ -86,6 +86,7 @@ const configurationPropertySchema = z.object({ }) .optional(), properties: z.record(z.string(), z.any()).optional(), + enum: z.array(z.any()).optional(), default: z.any().optional(), description: z.string(), }) diff --git a/packages/cloud/src/CloudAPI.ts b/packages/cloud/src/CloudAPI.ts index d1c3f89c2b..239dc9b564 100644 --- a/packages/cloud/src/CloudAPI.ts +++ b/packages/cloud/src/CloudAPI.ts @@ -134,4 +134,14 @@ export class CloudAPI { .parse(data), }) } + + async creditBalance(): Promise { + return this.request("/api/extension/credit-balance", { + method: "GET", + parseResponse: (data) => { + const result = z.object({ balance: z.number() }).parse(data) + return result.balance + }, + }) + } } diff --git a/packages/cloud/src/__tests__/CloudAPI.creditBalance.spec.ts b/packages/cloud/src/__tests__/CloudAPI.creditBalance.spec.ts new file mode 100644 index 0000000000..67ab0cf3b9 --- /dev/null +++ b/packages/cloud/src/__tests__/CloudAPI.creditBalance.spec.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest" +import { CloudAPI } from "../CloudAPI.js" +import { AuthenticationError, CloudAPIError } from "../errors.js" +import type { AuthService } from "@roo-code/types" + +// Mock the config module +vi.mock("../config.js", () => ({ + getRooCodeApiUrl: () => "https://api.test.com", +})) + +// Mock the utils module +vi.mock("../utils.js", () => ({ + getUserAgent: () => "test-user-agent", +})) + +describe("CloudAPI.creditBalance", () => { + let mockAuthService: { + getSessionToken: Mock<() => string | undefined> + } + let cloudAPI: CloudAPI + + beforeEach(() => { + mockAuthService = { + getSessionToken: vi.fn(), + } + cloudAPI = new CloudAPI(mockAuthService as unknown as AuthService) + + // Reset fetch mock + global.fetch = vi.fn() + }) + + it("should fetch credit balance successfully", async () => { + const mockBalance = 12.34 + mockAuthService.getSessionToken.mockReturnValue("test-session-token") + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ balance: mockBalance }), + }) + + const balance = await cloudAPI.creditBalance() + + expect(balance).toBe(mockBalance) + expect(global.fetch).toHaveBeenCalledWith( + "https://api.test.com/api/extension/credit-balance", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: "Bearer test-session-token", + "Content-Type": "application/json", + "User-Agent": "test-user-agent", + }), + }), + ) + }) + + it("should throw AuthenticationError when session token is missing", async () => { + mockAuthService.getSessionToken.mockReturnValue(undefined) + + await expect(cloudAPI.creditBalance()).rejects.toThrow(AuthenticationError) + }) + + it("should handle API errors", async () => { + mockAuthService.getSessionToken.mockReturnValue("test-session-token") + + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + json: async () => ({ error: "Server error" }), + }) + + await expect(cloudAPI.creditBalance()).rejects.toThrow(CloudAPIError) + }) + + it("should handle network errors", async () => { + mockAuthService.getSessionToken.mockReturnValue("test-session-token") + + global.fetch = vi.fn().mockRejectedValue(new TypeError("fetch failed")) + + await expect(cloudAPI.creditBalance()).rejects.toThrow( + "Network error while calling /api/extension/credit-balance", + ) + }) + + it("should handle invalid response format", async () => { + mockAuthService.getSessionToken.mockReturnValue("test-session-token") + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ invalid: "response" }), + }) + + await expect(cloudAPI.creditBalance()).rejects.toThrow() + }) +}) diff --git a/packages/evals/src/cli/runTask.ts b/packages/evals/src/cli/runTask.ts index 8b986e2afa..e914946353 100644 --- a/packages/evals/src/cli/runTask.ts +++ b/packages/evals/src/cli/runTask.ts @@ -217,11 +217,23 @@ export const runTask = async ({ run, task, publish, logger }: RunTaskOptions) => "diff_error", "condense_context", "condense_context_error", + "api_req_retry_delayed", + "api_req_retried", ] + let isApiUnstable = false + client.on(IpcMessageType.TaskEvent, async (taskEvent) => { const { eventName, payload } = taskEvent + if ( + eventName === RooCodeEventName.Message && + payload[0].message.say && + ["api_req_retry_delayed", "api_req_retried"].includes(payload[0].message.say) + ) { + isApiUnstable = true + } + // Publish all events except for these to Redis. if (!ignoreEvents.broadcast.includes(eventName)) { await publish({ ...taskEvent, taskId: task.id }) @@ -388,4 +400,8 @@ export const runTask = async ({ run, task, publish, logger }: RunTaskOptions) => } logger.close() + + if (isApiUnstable) { + throw new Error("API is unstable, throwing to trigger a retry.") + } } diff --git a/packages/telemetry/src/PostHogTelemetryClient.ts b/packages/telemetry/src/PostHogTelemetryClient.ts index f1c46577df..d7f632f137 100644 --- a/packages/telemetry/src/PostHogTelemetryClient.ts +++ b/packages/telemetry/src/PostHogTelemetryClient.ts @@ -25,7 +25,7 @@ export class PostHogTelemetryClient extends BaseTelemetryClient { debug, ) - this.client = new PostHog(process.env.POSTHOG_API_KEY || "", { host: "https://us.i.posthog.com" }) + this.client = new PostHog(process.env.POSTHOG_API_KEY || "", { host: "https://ph.roocode.com" }) } /** diff --git a/packages/telemetry/src/TelemetryService.ts b/packages/telemetry/src/TelemetryService.ts index 5ea4cef936..8f4fbe0974 100644 --- a/packages/telemetry/src/TelemetryService.ts +++ b/packages/telemetry/src/TelemetryService.ts @@ -98,8 +98,8 @@ export class TelemetryService { this.captureEvent(TelemetryEventName.MODE_SWITCH, { taskId, newMode }) } - public captureToolUsage(taskId: string, tool: string): void { - this.captureEvent(TelemetryEventName.TOOL_USED, { taskId, tool }) + public captureToolUsage(taskId: string, tool: string, toolProtocol: string): void { + this.captureEvent(TelemetryEventName.TOOL_USED, { taskId, tool, toolProtocol }) } public captureCheckpointCreated(taskId: string): void { diff --git a/packages/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index b7bbfc898d..9848777bc0 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.85.0", + "version": "1.86.0", "description": "TypeScript type definitions for Roo Code.", "publishConfig": { "access": "public", diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 579356ae2d..3697ef35bb 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -103,6 +103,12 @@ export const globalSettingsSchema = z.object({ * @default true */ includeCurrentCost: z.boolean().optional(), + /** + * Maximum number of git status file entries to include in the environment details. + * Set to 0 to disable git status. The header (branch, commits) is always included when > 0. + * @default 0 + */ + maxGitStatusFiles: z.number().optional(), /** * Whether to include diagnostic messages (errors, warnings) in tool outputs @@ -339,6 +345,7 @@ export const EVALS_SETTINGS: RooCodeSettings = { rateLimitSeconds: 0, maxOpenTabsContext: 20, maxWorkspaceFiles: 200, + maxGitStatusFiles: 20, showRooIgnoredFiles: true, maxReadFileLine: -1, // -1 to enable full file reading. diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 7a7d5059eb..ebebb72313 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -20,6 +20,7 @@ export * from "./todo.js" export * from "./telemetry.js" export * from "./terminal.js" export * from "./tool.js" +export * from "./tool-params.js" export * from "./type-fu.js" export * from "./vscode.js" diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 748acb2858..49e8e73edf 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -18,6 +18,22 @@ export const reasoningEffortWithMinimalSchema = z.union([reasoningEffortsSchema, export type ReasoningEffortWithMinimal = z.infer +/** + * Extended Reasoning Effort (includes "none" and "minimal") + * Note: "disable" is a UI/control value, not a value sent as effort + */ +export const reasoningEffortsExtended = ["none", "minimal", "low", "medium", "high"] as const + +export const reasoningEffortExtendedSchema = z.enum(reasoningEffortsExtended) + +export type ReasoningEffortExtended = z.infer + +/** + * Reasoning Effort user setting (includes "disable") + */ +export const reasoningEffortSettingValues = ["disable", "none", "minimal", "low", "medium", "high"] as const +export const reasoningEffortSettingSchema = z.enum(reasoningEffortSettingValues) + /** * Verbosity */ @@ -58,6 +74,10 @@ export const modelInfoSchema = z.object({ contextWindow: z.number(), supportsImages: z.boolean().optional(), supportsPromptCache: z.boolean(), + // Optional default prompt cache retention policy for providers that support it. + // When set to "24h", extended prompt caching will be requested; when omitted + // or set to "in_memory", the default in‑memory cache is used. + promptCacheRetention: z.enum(["in_memory", "24h"]).optional(), // Capability flag to indicate whether the model supports an output verbosity parameter supportsVerbosity: z.boolean().optional(), supportsReasoningBudget: z.boolean().optional(), @@ -67,7 +87,9 @@ export const modelInfoSchema = z.object({ supportsTemperature: z.boolean().optional(), defaultTemperature: z.number().optional(), requiredReasoningBudget: z.boolean().optional(), - supportsReasoningEffort: z.boolean().optional(), + supportsReasoningEffort: z + .union([z.boolean(), z.array(z.enum(["disable", "none", "minimal", "low", "medium", "high"]))]) + .optional(), requiredReasoningEffort: z.boolean().optional(), preserveReasoning: z.boolean().optional(), supportedParameters: z.array(modelParametersSchema).optional(), @@ -76,7 +98,8 @@ export const modelInfoSchema = z.object({ cacheWritesPrice: z.number().optional(), cacheReadsPrice: z.number().optional(), description: z.string().optional(), - reasoningEffort: reasoningEffortsSchema.optional(), + // Default effort value for models that support reasoning effort + reasoningEffort: reasoningEffortExtendedSchema.optional(), minTokensPerCachePoint: z.number().optional(), maxCachePoints: z.number().optional(), cachableFields: z.array(z.string()).optional(), @@ -84,6 +107,10 @@ export const modelInfoSchema = z.object({ deprecated: z.boolean().optional(), // Flag to indicate if the model is free (no cost) isFree: z.boolean().optional(), + // Flag to indicate if the model supports native tool calling (OpenAI-style function calling) + supportsNativeTools: z.boolean().optional(), + // Default tool protocol preferred by this model (if not specified, falls back to capability/provider defaults) + defaultToolProtocol: z.enum(["xml", "native"]).optional(), /** * Service tiers with pricing information. * Each tier can have a name (for OpenAI service tiers) and pricing overrides. diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 23e0a548d1..7a84e6d2de 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -1,6 +1,6 @@ import { z } from "zod" -import { modelInfoSchema, reasoningEffortWithMinimalSchema, verbosityLevelsSchema, serviceTierSchema } from "./model.js" +import { modelInfoSchema, reasoningEffortSettingSchema, verbosityLevelsSchema, serviceTierSchema } from "./model.js" import { codebaseIndexProviderSchema } from "./codebase-index.js" import { anthropicModels, @@ -176,12 +176,15 @@ const baseProviderSettingsSchema = z.object({ // Model reasoning. enableReasoningEffort: z.boolean().optional(), - reasoningEffort: reasoningEffortWithMinimalSchema.optional(), + reasoningEffort: reasoningEffortSettingSchema.optional(), modelMaxTokens: z.number().optional(), modelMaxThinkingTokens: z.number().optional(), // Model verbosity. verbosity: verbosityLevelsSchema.optional(), + + // Tool protocol override for this profile. + toolProtocol: z.enum(["xml", "native"]).optional(), }) // Several of the providers share common model config properties. diff --git a/packages/types/src/providers/gemini.ts b/packages/types/src/providers/gemini.ts index aae428d90c..e7a73e6d0e 100644 --- a/packages/types/src/providers/gemini.ts +++ b/packages/types/src/providers/gemini.ts @@ -3,140 +3,68 @@ import type { ModelInfo } from "../model.js" // https://ai.google.dev/gemini-api/docs/models/gemini export type GeminiModelId = keyof typeof geminiModels -export const geminiDefaultModelId: GeminiModelId = "gemini-2.0-flash-001" +export const geminiDefaultModelId: GeminiModelId = "gemini-2.5-pro" export const geminiModels = { - // Latest models (pointing to the most recent stable versions) - "gemini-flash-latest": { + "gemini-3-pro-preview": { maxTokens: 65_536, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: true, - inputPrice: 0.3, - outputPrice: 2.5, - cacheReadsPrice: 0.075, - cacheWritesPrice: 1.0, - maxThinkingTokens: 24_576, - supportsReasoningBudget: true, + supportsReasoningEffort: ["low", "high"], + reasoningEffort: "low", + supportsTemperature: true, + defaultTemperature: 1, + inputPrice: 4.0, + outputPrice: 18.0, + tiers: [ + { + contextWindow: 200_000, + inputPrice: 2.0, + outputPrice: 12.0, + }, + { + contextWindow: Infinity, + inputPrice: 4.0, + outputPrice: 18.0, + }, + ], }, - "gemini-flash-lite-latest": { - maxTokens: 65_536, - contextWindow: 1_048_576, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 0.1, - outputPrice: 0.4, - cacheReadsPrice: 0.025, - cacheWritesPrice: 1.0, - supportsReasoningBudget: true, - maxThinkingTokens: 24_576, - }, - - // 2.5 Flash models (09-2025 versions - most recent) - "gemini-2.5-flash-preview-09-2025": { - maxTokens: 65_536, - contextWindow: 1_048_576, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 0.3, - outputPrice: 2.5, - cacheReadsPrice: 0.075, - cacheWritesPrice: 1.0, - maxThinkingTokens: 24_576, - supportsReasoningBudget: true, - }, - "gemini-2.5-flash-lite-preview-09-2025": { - maxTokens: 65_536, - contextWindow: 1_048_576, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 0.1, - outputPrice: 0.4, - cacheReadsPrice: 0.025, - cacheWritesPrice: 1.0, - supportsReasoningBudget: true, - maxThinkingTokens: 24_576, - }, - - // 2.5 Flash models (06-17 version) - "gemini-2.5-flash-lite-preview-06-17": { - maxTokens: 64_000, - contextWindow: 1_048_576, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 0.1, - outputPrice: 0.4, - cacheReadsPrice: 0.025, - cacheWritesPrice: 1.0, - supportsReasoningBudget: true, - maxThinkingTokens: 24_576, - }, - - // 2.5 Flash models (05-20 versions) - "gemini-2.5-flash-preview-05-20:thinking": { - maxTokens: 65_535, - contextWindow: 1_048_576, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 0.15, - outputPrice: 3.5, - cacheReadsPrice: 0.0375, - cacheWritesPrice: 1.0, - maxThinkingTokens: 24_576, - supportsReasoningBudget: true, - requiredReasoningBudget: true, - }, - "gemini-2.5-flash-preview-05-20": { - maxTokens: 65_535, - contextWindow: 1_048_576, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 0.15, - outputPrice: 0.6, - cacheReadsPrice: 0.0375, - cacheWritesPrice: 1.0, - }, - - // 2.5 Flash models (04-17 versions) - "gemini-2.5-flash-preview-04-17:thinking": { - maxTokens: 65_535, - contextWindow: 1_048_576, - supportsImages: true, - supportsPromptCache: false, - inputPrice: 0.15, - outputPrice: 3.5, - maxThinkingTokens: 24_576, - supportsReasoningBudget: true, - requiredReasoningBudget: true, - }, - "gemini-2.5-flash-preview-04-17": { - maxTokens: 65_535, - contextWindow: 1_048_576, - supportsImages: true, - supportsPromptCache: false, - inputPrice: 0.15, - outputPrice: 0.6, - }, - - // 2.5 Flash stable - "gemini-2.5-flash": { - maxTokens: 64_000, - contextWindow: 1_048_576, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 0.3, - outputPrice: 2.5, - cacheReadsPrice: 0.075, - cacheWritesPrice: 1.0, - maxThinkingTokens: 24_576, - supportsReasoningBudget: true, - }, - // 2.5 Pro models + "gemini-2.5-pro": { + maxTokens: 64_000, + contextWindow: 1_048_576, + supportsImages: true, + supportsNativeTools: true, + supportsPromptCache: true, + inputPrice: 2.5, // This is the pricing for prompts above 200k tokens. + outputPrice: 15, + cacheReadsPrice: 0.625, + cacheWritesPrice: 4.5, + maxThinkingTokens: 32_768, + supportsReasoningBudget: true, + requiredReasoningBudget: true, + tiers: [ + { + contextWindow: 200_000, + inputPrice: 1.25, + outputPrice: 10, + cacheReadsPrice: 0.31, + }, + { + contextWindow: Infinity, + inputPrice: 2.5, + outputPrice: 15, + cacheReadsPrice: 0.625, + }, + ], + }, "gemini-2.5-pro-preview-06-05": { maxTokens: 65_535, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: true, inputPrice: 2.5, // This is the pricing for prompts above 200k tokens. outputPrice: 15, @@ -163,6 +91,7 @@ export const geminiModels = { maxTokens: 65_535, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: true, inputPrice: 2.5, // This is the pricing for prompts above 200k tokens. outputPrice: 15, @@ -187,6 +116,7 @@ export const geminiModels = { maxTokens: 65_535, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: true, inputPrice: 2.5, // This is the pricing for prompts above 200k tokens. outputPrice: 15, @@ -209,163 +139,73 @@ export const geminiModels = { }, ], }, - "gemini-2.5-pro-exp-03-25": { - maxTokens: 65_535, - contextWindow: 1_048_576, - supportsImages: true, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - }, - "gemini-2.5-pro": { - maxTokens: 64_000, - contextWindow: 1_048_576, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 2.5, // This is the pricing for prompts above 200k tokens. - outputPrice: 15, - cacheReadsPrice: 0.625, - cacheWritesPrice: 4.5, - maxThinkingTokens: 32_768, - supportsReasoningBudget: true, - requiredReasoningBudget: true, - tiers: [ - { - contextWindow: 200_000, - inputPrice: 1.25, - outputPrice: 10, - cacheReadsPrice: 0.31, - }, - { - contextWindow: Infinity, - inputPrice: 2.5, - outputPrice: 15, - cacheReadsPrice: 0.625, - }, - ], - }, - // 2.0 Flash models - "gemini-2.0-flash-lite-preview-02-05": { - maxTokens: 8192, - contextWindow: 1_048_576, - supportsImages: true, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - }, - "gemini-2.0-flash-thinking-exp-01-21": { + // 2.5 Flash models + "gemini-flash-latest": { maxTokens: 65_536, contextWindow: 1_048_576, supportsImages: true, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, + supportsNativeTools: true, + supportsPromptCache: true, + inputPrice: 0.3, + outputPrice: 2.5, + cacheReadsPrice: 0.075, + cacheWritesPrice: 1.0, + maxThinkingTokens: 24_576, + supportsReasoningBudget: true, }, - "gemini-2.0-flash-thinking-exp-1219": { - maxTokens: 8192, - contextWindow: 32_767, - supportsImages: true, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - }, - "gemini-2.0-flash-exp": { - maxTokens: 8192, + "gemini-2.5-flash-preview-09-2025": { + maxTokens: 65_536, contextWindow: 1_048_576, supportsImages: true, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, + supportsNativeTools: true, + supportsPromptCache: true, + inputPrice: 0.3, + outputPrice: 2.5, + cacheReadsPrice: 0.075, + cacheWritesPrice: 1.0, + maxThinkingTokens: 24_576, + supportsReasoningBudget: true, }, - "gemini-2.0-flash-001": { - maxTokens: 8192, + "gemini-2.5-flash": { + maxTokens: 64_000, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, + supportsPromptCache: true, + inputPrice: 0.3, + outputPrice: 2.5, + cacheReadsPrice: 0.075, + cacheWritesPrice: 1.0, + maxThinkingTokens: 24_576, + supportsReasoningBudget: true, + }, + + // 2.5 Flash Lite models + "gemini-flash-lite-latest": { + maxTokens: 65_536, + contextWindow: 1_048_576, + supportsImages: true, + supportsNativeTools: true, supportsPromptCache: true, inputPrice: 0.1, outputPrice: 0.4, cacheReadsPrice: 0.025, cacheWritesPrice: 1.0, + supportsReasoningBudget: true, + maxThinkingTokens: 24_576, }, - - // 2.0 Pro models - "gemini-2.0-pro-exp-02-05": { - maxTokens: 8192, - contextWindow: 2_097_152, - supportsImages: true, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - }, - - // 1.5 Flash models - "gemini-1.5-flash-002": { - maxTokens: 8192, + "gemini-2.5-flash-lite-preview-09-2025": { + maxTokens: 65_536, contextWindow: 1_048_576, supportsImages: true, + supportsNativeTools: true, supportsPromptCache: true, - inputPrice: 0.15, // This is the pricing for prompts above 128k tokens. - outputPrice: 0.6, - cacheReadsPrice: 0.0375, + inputPrice: 0.1, + outputPrice: 0.4, + cacheReadsPrice: 0.025, cacheWritesPrice: 1.0, - tiers: [ - { - contextWindow: 128_000, - inputPrice: 0.075, - outputPrice: 0.3, - cacheReadsPrice: 0.01875, - }, - { - contextWindow: Infinity, - inputPrice: 0.15, - outputPrice: 0.6, - cacheReadsPrice: 0.0375, - }, - ], - }, - "gemini-1.5-flash-exp-0827": { - maxTokens: 8192, - contextWindow: 1_048_576, - supportsImages: true, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - }, - "gemini-1.5-flash-8b-exp-0827": { - maxTokens: 8192, - contextWindow: 1_048_576, - supportsImages: true, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - }, - - // 1.5 Pro models - "gemini-1.5-pro-002": { - maxTokens: 8192, - contextWindow: 2_097_152, - supportsImages: true, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - }, - "gemini-1.5-pro-exp-0827": { - maxTokens: 8192, - contextWindow: 2_097_152, - supportsImages: true, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, - }, - - // Experimental models - "gemini-exp-1206": { - maxTokens: 8192, - contextWindow: 2_097_152, - supportsImages: true, - supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, + supportsReasoningBudget: true, + maxThinkingTokens: 24_576, }, } as const satisfies Record diff --git a/packages/types/src/providers/openai.ts b/packages/types/src/providers/openai.ts index a3eed1b57c..0bff8aea3f 100644 --- a/packages/types/src/providers/openai.ts +++ b/packages/types/src/providers/openai.ts @@ -3,90 +3,144 @@ import type { ModelInfo } from "../model.js" // https://openai.com/api/pricing/ export type OpenAiNativeModelId = keyof typeof openAiNativeModels -export const openAiNativeDefaultModelId: OpenAiNativeModelId = "gpt-5-2025-08-07" +export const openAiNativeDefaultModelId: OpenAiNativeModelId = "gpt-5.1" export const openAiNativeModels = { - "gpt-5-chat-latest": { + "gpt-5.1": { maxTokens: 128000, contextWindow: 400000, + supportsNativeTools: true, supportsImages: true, supportsPromptCache: true, - supportsReasoningEffort: false, - inputPrice: 1.25, - outputPrice: 10.0, - cacheReadsPrice: 0.13, - description: "GPT-5 Chat Latest: Optimized for conversational AI and non-reasoning tasks", - supportsVerbosity: true, - }, - "gpt-5-2025-08-07": { - maxTokens: 128000, - contextWindow: 400000, - supportsImages: true, - supportsPromptCache: true, - supportsReasoningEffort: true, + promptCacheRetention: "24h", + supportsReasoningEffort: ["none", "low", "medium", "high"], reasoningEffort: "medium", inputPrice: 1.25, outputPrice: 10.0, - cacheReadsPrice: 0.13, - description: "GPT-5: The best model for coding and agentic tasks across domains", - // supportsVerbosity is a new capability; ensure ModelInfo includes it + cacheReadsPrice: 0.125, supportsVerbosity: true, supportsTemperature: false, tiers: [ { name: "flex", contextWindow: 400000, inputPrice: 0.625, outputPrice: 5.0, cacheReadsPrice: 0.0625 }, { name: "priority", contextWindow: 400000, inputPrice: 2.5, outputPrice: 20.0, cacheReadsPrice: 0.25 }, ], + description: "GPT-5.1: The best model for coding and agentic tasks across domains", }, - "gpt-5-mini-2025-08-07": { + "gpt-5.1-codex": { maxTokens: 128000, contextWindow: 400000, + supportsNativeTools: true, supportsImages: true, supportsPromptCache: true, - supportsReasoningEffort: true, + promptCacheRetention: "24h", + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 1.25, + outputPrice: 10.0, + cacheReadsPrice: 0.125, + supportsTemperature: false, + tiers: [{ name: "priority", contextWindow: 400000, inputPrice: 2.5, outputPrice: 20.0, cacheReadsPrice: 0.25 }], + description: "GPT-5.1 Codex: A version of GPT-5.1 optimized for agentic coding in Codex", + }, + "gpt-5.1-codex-mini": { + maxTokens: 128000, + contextWindow: 400000, + supportsNativeTools: true, + supportsImages: true, + supportsPromptCache: true, + promptCacheRetention: "24h", + supportsReasoningEffort: ["low", "medium", "high"], reasoningEffort: "medium", inputPrice: 0.25, outputPrice: 2.0, - cacheReadsPrice: 0.03, - description: "GPT-5 Mini: A faster, more cost-efficient version of GPT-5 for well-defined tasks", + cacheReadsPrice: 0.025, + supportsTemperature: false, + description: "GPT-5.1 Codex mini: A version of GPT-5.1 optimized for agentic coding in Codex", + }, + "gpt-5": { + maxTokens: 128000, + contextWindow: 400000, + supportsNativeTools: true, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["minimal", "low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 1.25, + outputPrice: 10.0, + cacheReadsPrice: 0.125, + supportsVerbosity: true, + supportsTemperature: false, + tiers: [ + { name: "flex", contextWindow: 400000, inputPrice: 0.625, outputPrice: 5.0, cacheReadsPrice: 0.0625 }, + { name: "priority", contextWindow: 400000, inputPrice: 2.5, outputPrice: 20.0, cacheReadsPrice: 0.25 }, + ], + description: "GPT-5: The best model for coding and agentic tasks across domains", + }, + "gpt-5-mini": { + maxTokens: 128000, + contextWindow: 400000, + supportsNativeTools: true, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["minimal", "low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 0.25, + outputPrice: 2.0, + cacheReadsPrice: 0.025, supportsVerbosity: true, supportsTemperature: false, tiers: [ { name: "flex", contextWindow: 400000, inputPrice: 0.125, outputPrice: 1.0, cacheReadsPrice: 0.0125 }, { name: "priority", contextWindow: 400000, inputPrice: 0.45, outputPrice: 3.6, cacheReadsPrice: 0.045 }, ], - }, - "gpt-5-nano-2025-08-07": { - maxTokens: 128000, - contextWindow: 400000, - supportsImages: true, - supportsPromptCache: true, - supportsReasoningEffort: true, - reasoningEffort: "medium", - inputPrice: 0.05, - outputPrice: 0.4, - cacheReadsPrice: 0.01, - description: "GPT-5 Nano: Fastest, most cost-efficient version of GPT-5", - supportsVerbosity: true, - supportsTemperature: false, - tiers: [{ name: "flex", contextWindow: 400000, inputPrice: 0.025, outputPrice: 0.2, cacheReadsPrice: 0.0025 }], + description: "GPT-5 Mini: A faster, more cost-efficient version of GPT-5 for well-defined tasks", }, "gpt-5-codex": { maxTokens: 128000, contextWindow: 400000, + supportsNativeTools: true, supportsImages: true, supportsPromptCache: true, - supportsReasoningEffort: true, + supportsReasoningEffort: ["low", "medium", "high"], reasoningEffort: "medium", inputPrice: 1.25, outputPrice: 10.0, - cacheReadsPrice: 0.13, + cacheReadsPrice: 0.125, + supportsTemperature: false, + tiers: [{ name: "priority", contextWindow: 400000, inputPrice: 2.5, outputPrice: 20.0, cacheReadsPrice: 0.25 }], description: "GPT-5-Codex: A version of GPT-5 optimized for agentic coding in Codex", + }, + "gpt-5-nano": { + maxTokens: 128000, + contextWindow: 400000, + supportsNativeTools: true, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["minimal", "low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 0.05, + outputPrice: 0.4, + cacheReadsPrice: 0.005, supportsVerbosity: true, supportsTemperature: false, + tiers: [{ name: "flex", contextWindow: 400000, inputPrice: 0.025, outputPrice: 0.2, cacheReadsPrice: 0.0025 }], + description: "GPT-5 Nano: Fastest, most cost-efficient version of GPT-5", + }, + "gpt-5-chat-latest": { + maxTokens: 128000, + contextWindow: 400000, + supportsNativeTools: true, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 1.25, + outputPrice: 10.0, + cacheReadsPrice: 0.125, + description: "GPT-5 Chat: Optimized for conversational AI and non-reasoning tasks", }, "gpt-4.1": { maxTokens: 32_768, contextWindow: 1_047_576, + supportsNativeTools: true, supportsImages: true, supportsPromptCache: true, inputPrice: 2, @@ -100,6 +154,7 @@ export const openAiNativeModels = { "gpt-4.1-mini": { maxTokens: 32_768, contextWindow: 1_047_576, + supportsNativeTools: true, supportsImages: true, supportsPromptCache: true, inputPrice: 0.4, @@ -113,6 +168,7 @@ export const openAiNativeModels = { "gpt-4.1-nano": { maxTokens: 32_768, contextWindow: 1_047_576, + supportsNativeTools: true, supportsImages: true, supportsPromptCache: true, inputPrice: 0.1, @@ -126,12 +182,13 @@ export const openAiNativeModels = { o3: { maxTokens: 100_000, contextWindow: 200_000, + supportsNativeTools: true, supportsImages: true, supportsPromptCache: true, inputPrice: 2.0, outputPrice: 8.0, cacheReadsPrice: 0.5, - supportsReasoningEffort: true, + supportsReasoningEffort: ["low", "medium", "high"], reasoningEffort: "medium", supportsTemperature: false, tiers: [ @@ -142,6 +199,7 @@ export const openAiNativeModels = { "o3-high": { maxTokens: 100_000, contextWindow: 200_000, + supportsNativeTools: true, supportsImages: true, supportsPromptCache: true, inputPrice: 2.0, @@ -153,6 +211,7 @@ export const openAiNativeModels = { "o3-low": { maxTokens: 100_000, contextWindow: 200_000, + supportsNativeTools: true, supportsImages: true, supportsPromptCache: true, inputPrice: 2.0, @@ -164,12 +223,13 @@ export const openAiNativeModels = { "o4-mini": { maxTokens: 100_000, contextWindow: 200_000, + supportsNativeTools: true, supportsImages: true, supportsPromptCache: true, inputPrice: 1.1, outputPrice: 4.4, cacheReadsPrice: 0.275, - supportsReasoningEffort: true, + supportsReasoningEffort: ["low", "medium", "high"], reasoningEffort: "medium", supportsTemperature: false, tiers: [ @@ -180,6 +240,7 @@ export const openAiNativeModels = { "o4-mini-high": { maxTokens: 100_000, contextWindow: 200_000, + supportsNativeTools: true, supportsImages: true, supportsPromptCache: true, inputPrice: 1.1, @@ -191,6 +252,7 @@ export const openAiNativeModels = { "o4-mini-low": { maxTokens: 100_000, contextWindow: 200_000, + supportsNativeTools: true, supportsImages: true, supportsPromptCache: true, inputPrice: 1.1, @@ -202,18 +264,20 @@ export const openAiNativeModels = { "o3-mini": { maxTokens: 100_000, contextWindow: 200_000, + supportsNativeTools: true, supportsImages: false, supportsPromptCache: true, inputPrice: 1.1, outputPrice: 4.4, cacheReadsPrice: 0.55, - supportsReasoningEffort: true, + supportsReasoningEffort: ["low", "medium", "high"], reasoningEffort: "medium", supportsTemperature: false, }, "o3-mini-high": { maxTokens: 100_000, contextWindow: 200_000, + supportsNativeTools: true, supportsImages: false, supportsPromptCache: true, inputPrice: 1.1, @@ -225,6 +289,7 @@ export const openAiNativeModels = { "o3-mini-low": { maxTokens: 100_000, contextWindow: 200_000, + supportsNativeTools: true, supportsImages: false, supportsPromptCache: true, inputPrice: 1.1, @@ -236,6 +301,7 @@ export const openAiNativeModels = { o1: { maxTokens: 100_000, contextWindow: 200_000, + supportsNativeTools: true, supportsImages: true, supportsPromptCache: true, inputPrice: 15, @@ -246,6 +312,7 @@ export const openAiNativeModels = { "o1-preview": { maxTokens: 32_768, contextWindow: 128_000, + supportsNativeTools: true, supportsImages: true, supportsPromptCache: true, inputPrice: 15, @@ -256,6 +323,7 @@ export const openAiNativeModels = { "o1-mini": { maxTokens: 65_536, contextWindow: 128_000, + supportsNativeTools: true, supportsImages: true, supportsPromptCache: true, inputPrice: 1.1, @@ -266,6 +334,7 @@ export const openAiNativeModels = { "gpt-4o": { maxTokens: 16_384, contextWindow: 128_000, + supportsNativeTools: true, supportsImages: true, supportsPromptCache: true, inputPrice: 2.5, @@ -279,6 +348,7 @@ export const openAiNativeModels = { "gpt-4o-mini": { maxTokens: 16_384, contextWindow: 128_000, + supportsNativeTools: true, supportsImages: true, supportsPromptCache: true, inputPrice: 0.15, @@ -292,15 +362,71 @@ export const openAiNativeModels = { "codex-mini-latest": { maxTokens: 16_384, contextWindow: 200_000, + supportsNativeTools: true, supportsImages: false, supportsPromptCache: false, inputPrice: 1.5, outputPrice: 6, - cacheReadsPrice: 0, + cacheReadsPrice: 0.375, supportsTemperature: false, description: "Codex Mini: Cloud-based software engineering agent powered by codex-1, a version of o3 optimized for coding tasks. Trained with reinforcement learning to generate human-style code, adhere to instructions, and iteratively run tests.", }, + // Dated clones (snapshots) preserved for backward compatibility + "gpt-5-2025-08-07": { + maxTokens: 128000, + contextWindow: 400000, + supportsNativeTools: true, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["minimal", "low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 1.25, + outputPrice: 10.0, + cacheReadsPrice: 0.125, + supportsVerbosity: true, + supportsTemperature: false, + tiers: [ + { name: "flex", contextWindow: 400000, inputPrice: 0.625, outputPrice: 5.0, cacheReadsPrice: 0.0625 }, + { name: "priority", contextWindow: 400000, inputPrice: 2.5, outputPrice: 20.0, cacheReadsPrice: 0.25 }, + ], + description: "GPT-5: The best model for coding and agentic tasks across domains", + }, + "gpt-5-mini-2025-08-07": { + maxTokens: 128000, + contextWindow: 400000, + supportsNativeTools: true, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["minimal", "low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 0.25, + outputPrice: 2.0, + cacheReadsPrice: 0.025, + supportsVerbosity: true, + supportsTemperature: false, + tiers: [ + { name: "flex", contextWindow: 400000, inputPrice: 0.125, outputPrice: 1.0, cacheReadsPrice: 0.0125 }, + { name: "priority", contextWindow: 400000, inputPrice: 0.45, outputPrice: 3.6, cacheReadsPrice: 0.045 }, + ], + description: "GPT-5 Mini: A faster, more cost-efficient version of GPT-5 for well-defined tasks", + }, + "gpt-5-nano-2025-08-07": { + maxTokens: 128000, + contextWindow: 400000, + supportsNativeTools: true, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["minimal", "low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 0.05, + outputPrice: 0.4, + cacheReadsPrice: 0.005, + supportsVerbosity: true, + supportsTemperature: false, + tiers: [{ name: "flex", contextWindow: 400000, inputPrice: 0.025, outputPrice: 0.2, cacheReadsPrice: 0.0025 }], + description: "GPT-5 Nano: Fastest, most cost-efficient version of GPT-5", + }, } as const satisfies Record export const openAiModelInfoSaneDefaults: ModelInfo = { @@ -310,6 +436,7 @@ export const openAiModelInfoSaneDefaults: ModelInfo = { supportsPromptCache: false, inputPrice: 0, outputPrice: 0, + supportsNativeTools: true, } // https://learn.microsoft.com/en-us/azure/ai-services/openai/api-version-deprecation @@ -317,6 +444,5 @@ export const openAiModelInfoSaneDefaults: ModelInfo = { export const azureOpenAiDefaultApiVersion = "2024-08-01-preview" export const OPENAI_NATIVE_DEFAULT_TEMPERATURE = 0 -export const GPT5_DEFAULT_TEMPERATURE = 1.0 export const OPENAI_AZURE_AI_INFERENCE_PATH = "/models/chat/completions" diff --git a/packages/types/src/providers/openrouter.ts b/packages/types/src/providers/openrouter.ts index e151570796..22285fe6f5 100644 --- a/packages/types/src/providers/openrouter.ts +++ b/packages/types/src/providers/openrouter.ts @@ -8,6 +8,7 @@ export const openRouterDefaultModelInfo: ModelInfo = { contextWindow: 200_000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, inputPrice: 3.0, outputPrice: 15.0, cacheWritesPrice: 3.75, diff --git a/packages/types/src/providers/vertex.ts b/packages/types/src/providers/vertex.ts index f277c58a3e..e533992bd1 100644 --- a/packages/types/src/providers/vertex.ts +++ b/packages/types/src/providers/vertex.ts @@ -6,6 +6,30 @@ export type VertexModelId = keyof typeof vertexModels export const vertexDefaultModelId: VertexModelId = "claude-sonnet-4-5@20250929" export const vertexModels = { + "gemini-3-pro-preview": { + maxTokens: 65_536, + contextWindow: 1_048_576, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "high"], + reasoningEffort: "low", + supportsTemperature: true, + defaultTemperature: 1, + inputPrice: 4.0, + outputPrice: 18.0, + tiers: [ + { + contextWindow: 200_000, + inputPrice: 2.0, + outputPrice: 12.0, + }, + { + contextWindow: Infinity, + inputPrice: 4.0, + outputPrice: 18.0, + }, + ], + }, "gemini-2.5-flash-preview-05-20:thinking": { maxTokens: 65_535, contextWindow: 1_048_576, diff --git a/packages/types/src/tool-params.ts b/packages/types/src/tool-params.ts new file mode 100644 index 0000000000..36f69f7100 --- /dev/null +++ b/packages/types/src/tool-params.ts @@ -0,0 +1,37 @@ +/** + * Tool parameter type definitions for native protocol + */ + +export interface LineRange { + start: number + end: number +} + +export interface FileEntry { + path: string + lineRanges?: LineRange[] +} + +export interface Coordinate { + x: number + y: number +} + +export interface Size { + width: number + height: number +} + +export interface BrowserActionParams { + action: "launch" | "click" | "hover" | "type" | "scroll_down" | "scroll_up" | "resize" | "close" + url?: string + coordinate?: Coordinate + size?: Size + text?: string +} + +export interface GenerateImageParams { + prompt: string + path: string + image?: string +} diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 9d4269b9fa..ae4ddb72fb 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -68,3 +68,24 @@ export const TOOL_PROTOCOL = { * Derived from TOOL_PROTOCOL constants to ensure type safety */ export type ToolProtocol = (typeof TOOL_PROTOCOL)[keyof typeof TOOL_PROTOCOL] + +/** + * Checks if the protocol is native (non-XML). + * + * @param protocol - The tool protocol to check + * @returns True if protocol is native + */ +export function isNativeProtocol(protocol: ToolProtocol): boolean { + return protocol === TOOL_PROTOCOL.NATIVE +} + +/** + * Gets the effective protocol from settings or falls back to the default XML. + * This function is safe to use in webview-accessible code as it doesn't depend on vscode module. + * + * @param toolProtocol - Optional tool protocol from settings + * @returns The effective tool protocol (defaults to "xml") + */ +export function getEffectiveProtocol(toolProtocol?: ToolProtocol): ToolProtocol { + return toolProtocol || TOOL_PROTOCOL.XML +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e803a1a72f..cb94763a83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -627,8 +627,8 @@ importers: specifier: ^3.922.0 version: 3.922.0 '@google/genai': - specifier: ^1.0.0 - version: 1.3.0(@modelcontextprotocol/sdk@1.12.0) + specifier: ^1.29.1 + version: 1.29.1(@modelcontextprotocol/sdk@1.12.0) '@lmstudio/sdk': specifier: ^1.1.1 version: 1.2.0 @@ -773,6 +773,9 @@ importers: reconnecting-eventsource: specifier: ^1.6.4 version: 1.6.4 + safe-stable-stringify: + specifier: ^2.5.0 + version: 2.5.0 sanitize-filename: specifier: ^1.6.3 version: 1.6.3 @@ -1914,11 +1917,14 @@ packages: '@floating-ui/utils@0.2.9': resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} - '@google/genai@1.3.0': - resolution: {integrity: sha512-rrMzAELX4P902FUpuWy/W3NcQ7L3q/qtCzfCmGVqIce8yWpptTF9hkKsw744tvZpwqhuzD0URibcJA95wd8QFA==} + '@google/genai@1.29.1': + resolution: {integrity: sha512-Buywpq0A6xf9cOdhiWCi5KUiDBbZkjCH5xbl+xxNQRItoYQgd31p0OKyn5cUnT0YNzC/pAmszqXoOc7kncqfFQ==} engines: {node: '>=20.0.0'} peerDependencies: - '@modelcontextprotocol/sdk': ^1.11.0 + '@modelcontextprotocol/sdk': ^1.20.1 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true '@hookform/resolvers@5.1.1': resolution: {integrity: sha512-J/NVING3LMAEvexJkyTLjruSm7aOFx7QX21pzkiJfMoNG0wl5aFEjLTl7ay7IQb9EWY6AkrBy7tHL2Alijpdcg==} @@ -6208,10 +6214,18 @@ packages: resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} engines: {node: '>=14'} + gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} + gcp-metadata@6.1.1: resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} engines: {node: '>=14'} + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + gel@2.1.0: resolution: {integrity: sha512-HCeRqInCt6BjbMmeghJ6BKeYwOj7WJT5Db6IWWAA3IMUUa7or7zJfTUEkUWCxiOtoXnwnm96sFK9Fr47Yh2hOA==} engines: {node: '>= 18.0.0'} @@ -6328,6 +6342,10 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + google-auth-library@10.5.0: + resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + engines: {node: '>=18'} + google-auth-library@9.15.1: resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} engines: {node: '>=14'} @@ -6336,6 +6354,10 @@ packages: resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} engines: {node: '>=14'} + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -6354,6 +6376,10 @@ packages: resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} engines: {node: '>=14.0.0'} + gtoken@8.0.0: + resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} + engines: {node: '>=18'} + hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -8750,6 +8776,10 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + rimraf@6.0.1: resolution: {integrity: sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==} engines: {node: 20 || >=22} @@ -8804,6 +8834,10 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -11470,16 +11504,14 @@ snapshots: '@floating-ui/utils@0.2.9': {} - '@google/genai@1.3.0(@modelcontextprotocol/sdk@1.12.0)': + '@google/genai@1.29.1(@modelcontextprotocol/sdk@1.12.0)': dependencies: + google-auth-library: 10.5.0 + ws: 8.18.3 + optionalDependencies: '@modelcontextprotocol/sdk': 1.12.0 - google-auth-library: 9.15.1 - ws: 8.18.2 - zod: 3.25.76 - zod-to-json-schema: 3.24.5(zod@3.25.76) transitivePeerDependencies: - bufferutil - - encoding - supports-color - utf-8-validate @@ -15110,8 +15142,7 @@ snapshots: d3: 7.9.0 lodash-es: 4.17.21 - data-uri-to-buffer@4.0.1: - optional: true + data-uri-to-buffer@4.0.1: {} data-uri-to-buffer@6.0.2: {} @@ -15991,7 +16022,6 @@ snapshots: dependencies: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 - optional: true fflate@0.4.8: {} @@ -16085,7 +16115,6 @@ snapshots: formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 - optional: true forwarded@0.2.0: {} @@ -16170,6 +16199,15 @@ snapshots: - encoding - supports-color + gaxios@7.1.3: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + gcp-metadata@6.1.1: dependencies: gaxios: 6.7.1 @@ -16179,6 +16217,14 @@ snapshots: - encoding - supports-color + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + gel@2.1.0: dependencies: '@petamoriken/float16': 3.9.3 @@ -16316,6 +16362,18 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + google-auth-library@10.5.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.3 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + gtoken: 8.0.0 + jws: 4.0.0 + transitivePeerDependencies: + - supports-color + google-auth-library@9.15.1: dependencies: base64-js: 1.5.1 @@ -16330,6 +16388,8 @@ snapshots: google-logging-utils@0.0.2: {} + google-logging-utils@1.1.3: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -16351,6 +16411,13 @@ snapshots: - encoding - supports-color + gtoken@8.0.0: + dependencies: + gaxios: 7.1.3 + jws: 4.0.0 + transitivePeerDependencies: + - supports-color + hachure-fill@0.5.2: {} harmony-reflect@1.6.2: {} @@ -18141,7 +18208,6 @@ snapshots: data-uri-to-buffer: 4.0.1 fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - optional: true node-ipc@12.0.0: dependencies: @@ -19211,6 +19277,10 @@ snapshots: dependencies: glob: 7.2.3 + rimraf@5.0.10: + dependencies: + glob: 10.4.5 + rimraf@6.0.1: dependencies: glob: 11.0.3 @@ -19298,6 +19368,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} sanitize-filename@1.6.3: @@ -20788,8 +20860,7 @@ snapshots: web-namespaces@2.0.1: {} - web-streams-polyfill@3.3.3: - optional: true + web-streams-polyfill@3.3.3: {} web-streams-polyfill@4.0.0-beta.3: {} @@ -20933,8 +21004,7 @@ snapshots: ws@8.18.2: {} - ws@8.18.3: - optional: true + ws@8.18.3: {} xml-name-validator@5.0.0: {} diff --git a/releases/3.31.3-release.png b/releases/3.31.3-release.png new file mode 100644 index 0000000000..f3b88afa91 Binary files /dev/null and b/releases/3.31.3-release.png differ diff --git a/releases/3.32.0-release.png b/releases/3.32.0-release.png new file mode 100644 index 0000000000..2c7744b9eb Binary files /dev/null and b/releases/3.32.0-release.png differ diff --git a/releases/3.32.1-release.png b/releases/3.32.1-release.png new file mode 100644 index 0000000000..b5613c2bc7 Binary files /dev/null and b/releases/3.32.1-release.png differ diff --git a/releases/3.33.1-release.png b/releases/3.33.1-release.png new file mode 100644 index 0000000000..fc27da29dd Binary files /dev/null and b/releases/3.33.1-release.png differ diff --git a/releases/v3.33.0-release.png b/releases/v3.33.0-release.png new file mode 100644 index 0000000000..12ba322426 Binary files /dev/null and b/releases/v3.33.0-release.png differ diff --git a/scripts/update-contributors.js b/scripts/update-contributors.js deleted file mode 100644 index 6bd4c35f0c..0000000000 --- a/scripts/update-contributors.js +++ /dev/null @@ -1,384 +0,0 @@ -#!/usr/bin/env node - -/** - * This script fetches contributor data from GitHub and updates the README.md file - * with a contributors section showing avatars and usernames. - * It also updates all localized README files in the locales directory. - */ - -const https = require("https") -const fs = require("fs") -const { promisify } = require("util") -const path = require("path") - -// Promisify filesystem operations -const readFileAsync = promisify(fs.readFile) -const writeFileAsync = promisify(fs.writeFile) - -// GitHub API URL for fetching contributors -const GITHUB_API_URL = "https://api.github.com/repos/RooCodeInc/Roo-Code/contributors?per_page=100" -const README_PATH = path.join(__dirname, "..", "README.md") -const LOCALES_DIR = path.join(__dirname, "..", "locales") - -// Sentinel markers for contributors section -const START_MARKER = "" -const END_MARKER = "" - -// HTTP options for GitHub API request -const options = { - headers: { - "User-Agent": "Roo-Code-Contributors-Script", - }, -} - -// Add GitHub token for authentication if available -if (process.env.GITHUB_TOKEN) { - options.headers.Authorization = `token ${process.env.GITHUB_TOKEN}` - console.log("Using GitHub token from environment variable") -} - -/** - * Parses the GitHub API Link header to extract pagination URLs - * Based on RFC 5988 format for the Link header - * @param {string} header The Link header from GitHub API response - * @returns {Object} Object containing URLs for next, prev, first, last pages (if available) - */ -function parseLinkHeader(header) { - // Return empty object if no header is provided - if (!header || header.trim() === "") return {} - - // Initialize links object - const links = {} - - // Split the header into individual link entries - // Example: ; rel="next", ; rel="last" - const entries = header.split(/,\s*/) - - // Process each link entry - for (const entry of entries) { - // Extract the URL (between < and >) and the parameters (after >) - const segments = entry.split(";") - if (segments.length < 2) continue - - // Extract URL from the first segment, removing < and > - const urlMatch = segments[0].match(/<(.+)>/) - if (!urlMatch) continue - const url = urlMatch[1] - - // Find the rel="value" parameter - let rel = null - for (let i = 1; i < segments.length; i++) { - const relMatch = segments[i].match(/\s*rel\s*=\s*"?([^"]+)"?/) - if (relMatch) { - rel = relMatch[1] - break - } - } - - // Only add to links if both URL and rel were found - if (rel) { - links[rel] = url - } - } - - return links -} - -/** - * Performs an HTTP GET request and returns the response - * @param {string} url The URL to fetch - * @param {Object} options Request options - * @returns {Promise} Response object with status, headers and body - */ -function httpGet(url, options) { - return new Promise((resolve, reject) => { - https - .get(url, options, (res) => { - let data = "" - res.on("data", (chunk) => { - data += chunk - }) - - res.on("end", () => { - resolve({ - statusCode: res.statusCode, - headers: res.headers, - body: data, - }) - }) - }) - .on("error", (error) => { - reject(error) - }) - }) -} - -/** - * Fetches a single page of contributors from GitHub API - * @param {string} url The API URL to fetch - * @returns {Promise} Object containing contributors and pagination links - */ -async function fetchContributorsPage(url) { - try { - // Make the HTTP request - const response = await httpGet(url, options) - - // Check for successful response - if (response.statusCode !== 200) { - throw new Error(`GitHub API request failed with status code: ${response.statusCode}`) - } - - // Parse the Link header for pagination - const linkHeader = response.headers.link - const links = parseLinkHeader(linkHeader) - - // Parse the JSON response - const contributors = JSON.parse(response.body) - - return { contributors, links } - } catch (error) { - throw new Error(`Failed to fetch contributors page: ${error.message}`) - } -} - -/** - * Fetches all contributors data from GitHub API (handling pagination) - * @returns {Promise} Array of all contributor objects - */ -async function fetchContributors() { - let allContributors = [] - let currentUrl = GITHUB_API_URL - let pageCount = 1 - - // Loop through all pages of contributors - while (currentUrl) { - console.log(`Fetching contributors page ${pageCount}...`) - const { contributors, links } = await fetchContributorsPage(currentUrl) - - allContributors = allContributors.concat(contributors) - - // Move to the next page if it exists - currentUrl = links.next - pageCount++ - } - - console.log(`Fetched ${allContributors.length} contributors from ${pageCount - 1} pages`) - return allContributors -} - -/** - * Reads the README.md file - * @returns {Promise} README content - */ -async function readReadme() { - try { - return await readFileAsync(README_PATH, "utf8") - } catch (err) { - throw new Error(`Failed to read README.md: ${err.message}`) - } -} - -/** - * Creates HTML for the contributors section - * @param {Array} contributors Array of contributor objects from GitHub API - * @returns {string} HTML for contributors section - */ -const EXCLUDED_LOGIN_SUBSTRINGS = ['[bot]', 'R00-B0T']; -const EXCLUDED_LOGIN_EXACTS = ['cursor', 'roomote']; - -function formatContributorsSection(contributors) { - // Filter out GitHub Actions bot, cursor, and roomote - const filteredContributors = contributors.filter((c) => - !EXCLUDED_LOGIN_SUBSTRINGS.some(sub => c.login.includes(sub)) && - !EXCLUDED_LOGIN_EXACTS.includes(c.login) - ) - - // Start building with Markdown table format - let markdown = `${START_MARKER} -` - // Number of columns in the table - const COLUMNS = 6 - - // Create contributor cell HTML - const createCell = (contributor) => { - return `${contributor.login}
${contributor.login}
` - } - - if (filteredContributors.length > 0) { - // Table header is the first row of contributors - const headerCells = filteredContributors.slice(0, COLUMNS).map(createCell) - - // Fill any empty cells in header row - while (headerCells.length < COLUMNS) { - headerCells.push(" ") - } - - // Add header row - markdown += `|${headerCells.join("|")}|\n` - - // Add alignment row - markdown += "|" - for (let i = 0; i < COLUMNS; i++) { - markdown += ":---:|" - } - markdown += "\n" - - // Add remaining contributor rows starting with the second batch - for (let i = COLUMNS; i < filteredContributors.length; i += COLUMNS) { - const rowContributors = filteredContributors.slice(i, i + COLUMNS) - - // Create cells for each contributor in this row - const cells = rowContributors.map(createCell) - - // Fill any empty cells to maintain table structure - while (cells.length < COLUMNS) { - cells.push(" ") - } - - // Add row to the table - markdown += `|${cells.join("|")}|\n` - } - } - - markdown += `${END_MARKER}` - return markdown -} - -/** - * Updates the README.md file with contributors section - * @param {string} readmeContent Original README content - * @param {string} contributorsSection HTML for contributors section - * @returns {Promise} - */ -async function updateReadme(readmeContent, contributorsSection) { - // Find existing contributors section markers - const startPos = readmeContent.indexOf(START_MARKER) - const endPos = readmeContent.indexOf(END_MARKER) - - if (startPos === -1 || endPos === -1) { - console.warn("Warning: Could not find contributors section markers in README.md") - console.warn("Skipping update - please add markers to enable automatic updates.") - return - } - - // Replace existing section, trimming whitespace at section boundaries - const beforeSection = readmeContent.substring(0, startPos).trimEnd() - const afterSection = readmeContent.substring(endPos + END_MARKER.length).trimStart() - // Ensure single newline separators between sections - const updatedContent = beforeSection + "\n\n" + contributorsSection.trim() + "\n\n" + afterSection - - await writeReadme(updatedContent) -} - -/** - * Writes updated content to README.md - * @param {string} content Updated README content - * @returns {Promise} - */ -async function writeReadme(content) { - try { - await writeFileAsync(README_PATH, content, "utf8") - } catch (err) { - throw new Error(`Failed to write updated README.md: ${err.message}`) - } -} -/** - * Finds all localized README files in the locales directory - * @returns {Promise} Array of README file paths - */ -async function findLocalizedReadmes() { - const readmeFiles = [] - - // Check if locales directory exists - if (!fs.existsSync(LOCALES_DIR)) { - // No localized READMEs found - return readmeFiles - } - - // Get all language subdirectories - const languageDirs = fs - .readdirSync(LOCALES_DIR, { withFileTypes: true }) - .filter((dirent) => dirent.isDirectory()) - .map((dirent) => dirent.name) - - // Add all localized READMEs to the list - for (const langDir of languageDirs) { - const readmePath = path.join(LOCALES_DIR, langDir, "README.md") - if (fs.existsSync(readmePath)) { - readmeFiles.push(readmePath) - } - } - - return readmeFiles -} - -/** - * Updates a localized README file with contributors section - * @param {string} filePath Path to the README file - * @param {string} contributorsSection HTML for contributors section - * @returns {Promise} - */ -async function updateLocalizedReadme(filePath, contributorsSection) { - try { - // Read the file content - const readmeContent = await readFileAsync(filePath, "utf8") - - // Find existing contributors section markers - const startPos = readmeContent.indexOf(START_MARKER) - const endPos = readmeContent.indexOf(END_MARKER) - - if (startPos === -1 || endPos === -1) { - console.warn(`Warning: Could not find contributors section markers in ${filePath}`) - console.warn(`Skipping update for ${filePath}`) - return - } - - // Replace existing section, trimming whitespace at section boundaries - const beforeSection = readmeContent.substring(0, startPos).trimEnd() - const afterSection = readmeContent.substring(endPos + END_MARKER.length).trimStart() - // Ensure single newline separators between sections - const updatedContent = beforeSection + "\n\n" + contributorsSection.trim() + "\n\n" + afterSection - - // Write the updated content - await writeFileAsync(filePath, updatedContent, "utf8") - console.log(`Updated ${filePath}`) - } catch (err) { - console.warn(`Warning: Could not update ${filePath}: ${err.message}`) - } -} - -/** - * Main function that orchestrates the update process - */ -async function main() { - try { - // Fetch contributors from GitHub (now handles pagination) - const contributors = await fetchContributors() - console.log(`Total contributors: ${contributors.length}`) - - // Generate contributors section - const contributorsSection = formatContributorsSection(contributors) - - // Update main README - const readmeContent = await readReadme() - await updateReadme(readmeContent, contributorsSection) - console.log(`Updated ${README_PATH}`) - - // Find and update all localized README files - const localizedReadmes = await findLocalizedReadmes() - console.log(`Found ${localizedReadmes.length} localized README files`) - - // Update each localized README - for (const readmePath of localizedReadmes) { - await updateLocalizedReadme(readmePath, contributorsSection) - } - - console.log("Contributors section update complete") - } catch (error) { - console.error(`Error: ${error.message}`) - process.exit(1) - } -} - -// Run the script -main() diff --git a/src/api/index.ts b/src/api/index.ts index ae8be51349..05c7493078 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,6 +1,7 @@ import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" -import type { ProviderSettings, ModelInfo } from "@roo-code/types" +import type { ProviderSettings, ModelInfo, ToolProtocol } from "@roo-code/types" import { ApiStream } from "./transform/stream" @@ -63,6 +64,30 @@ export interface ApiHandlerCreateMessageMetadata { * - Unbound: Sent in unbound_metadata */ mode?: string + suppressPreviousResponseId?: boolean + /** + * Controls whether the response should be stored for 30 days in OpenAI's Responses API. + * When true (default), responses are stored and can be referenced in future requests + * using the previous_response_id for efficient conversation continuity. + * Set to false to opt out of response storage for privacy or compliance reasons. + * @default true + */ + store?: boolean + /** + * Optional array of tool definitions to pass to the model. + * For OpenAI-compatible providers, these are ChatCompletionTool definitions. + */ + tools?: OpenAI.Chat.ChatCompletionTool[] + /** + * Controls which (if any) tool is called by the model. + * Can be "none", "auto", "required", or a specific tool choice. + */ + tool_choice?: OpenAI.Chat.ChatCompletionCreateParams["tool_choice"] + /** + * The tool protocol being used (XML or Native). + * Used by providers to determine whether to include native tool definitions. + */ + toolProtocol?: ToolProtocol } export interface ApiHandler { diff --git a/src/api/providers/__tests__/gemini.spec.ts b/src/api/providers/__tests__/gemini.spec.ts index 812c1ae1a6..e778524c26 100644 --- a/src/api/providers/__tests__/gemini.spec.ts +++ b/src/api/providers/__tests__/gemini.spec.ts @@ -7,7 +7,7 @@ import { type ModelInfo, geminiDefaultModelId } from "@roo-code/types" import { t } from "i18next" import { GeminiHandler } from "../gemini" -const GEMINI_20_FLASH_THINKING_NAME = "gemini-2.0-flash-thinking-exp-1219" +const GEMINI_MODEL_NAME = geminiDefaultModelId describe("GeminiHandler", () => { let handler: GeminiHandler @@ -20,7 +20,7 @@ describe("GeminiHandler", () => { handler = new GeminiHandler({ apiKey: "test-key", - apiModelId: GEMINI_20_FLASH_THINKING_NAME, + apiModelId: GEMINI_MODEL_NAME, geminiApiKey: "test-key", }) @@ -37,7 +37,7 @@ describe("GeminiHandler", () => { describe("constructor", () => { it("should initialize with provided config", () => { expect(handler["options"].geminiApiKey).toBe("test-key") - expect(handler["options"].apiModelId).toBe(GEMINI_20_FLASH_THINKING_NAME) + expect(handler["options"].apiModelId).toBe(GEMINI_MODEL_NAME) }) }) @@ -76,14 +76,14 @@ describe("GeminiHandler", () => { expect(chunks.length).toBe(3) expect(chunks[0]).toEqual({ type: "text", text: "Hello" }) expect(chunks[1]).toEqual({ type: "text", text: " world!" }) - expect(chunks[2]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 5 }) + expect(chunks[2]).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5 }) // Verify the call to generateContentStream expect(handler["client"].models.generateContentStream).toHaveBeenCalledWith( expect.objectContaining({ - model: GEMINI_20_FLASH_THINKING_NAME, + model: GEMINI_MODEL_NAME, config: expect.objectContaining({ - temperature: 0, + temperature: 1, systemInstruction: systemPrompt, }), }), @@ -116,11 +116,11 @@ describe("GeminiHandler", () => { // Verify the call to generateContent expect(handler["client"].models.generateContent).toHaveBeenCalledWith({ - model: GEMINI_20_FLASH_THINKING_NAME, + model: GEMINI_MODEL_NAME, contents: [{ role: "user", parts: [{ text: "Test prompt" }] }], config: { httpOptions: undefined, - temperature: 0, + temperature: 1, }, }) }) @@ -148,10 +148,8 @@ describe("GeminiHandler", () => { describe("getModel", () => { it("should return correct model info", () => { const modelInfo = handler.getModel() - expect(modelInfo.id).toBe(GEMINI_20_FLASH_THINKING_NAME) + expect(modelInfo.id).toBe(GEMINI_MODEL_NAME) expect(modelInfo.info).toBeDefined() - expect(modelInfo.info.maxTokens).toBe(8192) - expect(modelInfo.info.contextWindow).toBe(32_767) }) it("should return default model if invalid model specified", () => { @@ -208,23 +206,6 @@ describe("GeminiHandler", () => { expect(handler.calculateCost({ info: mockInfo, inputTokens: 0, outputTokens })).toBeCloseTo(expectedCost) }) - it("should calculate cost with cache write tokens", () => { - const inputTokens = 10000 - const outputTokens = 20000 - const cacheWriteTokens = 5000 - const CACHE_TTL = 5 // Match the constant in gemini.ts - - // Added non-null assertions (!) - const expectedInputCost = (inputTokens / 1_000_000) * mockInfo.inputPrice! - const expectedOutputCost = (outputTokens / 1_000_000) * mockInfo.outputPrice! - const expectedCacheWriteCost = - mockInfo.cacheWritesPrice! * (cacheWriteTokens / 1_000_000) * (CACHE_TTL / 60) - const expectedCost = expectedInputCost + expectedOutputCost + expectedCacheWriteCost - - const cost = handler.calculateCost({ info: mockInfo, inputTokens, outputTokens }) - expect(cost).toBeCloseTo(expectedCost) - }) - it("should calculate cost with cache read tokens", () => { const inputTokens = 10000 // Total logical input const outputTokens = 20000 diff --git a/src/api/providers/__tests__/openai-native-usage.spec.ts b/src/api/providers/__tests__/openai-native-usage.spec.ts index 74806b26ab..48e1c26877 100644 --- a/src/api/providers/__tests__/openai-native-usage.spec.ts +++ b/src/api/providers/__tests__/openai-native-usage.spec.ts @@ -344,6 +344,51 @@ describe("OpenAiNativeHandler - normalizeUsage", () => { }) }) + describe("OpenAiNativeHandler - prompt cache retention", () => { + let handler: OpenAiNativeHandler + + beforeEach(() => { + handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + }) + }) + + const buildRequestBodyForModel = (modelId: string) => { + // Force the handler to use the requested model ID + ;(handler as any).options.apiModelId = modelId + const model = handler.getModel() + // Minimal formatted input/systemPrompt/verbosity/metadata for building the body + return (handler as any).buildRequestBody(model, [], "", model.verbosity, undefined, undefined) + } + + it("should set prompt_cache_retention=24h for gpt-5.1 models that support prompt caching", () => { + const body = buildRequestBodyForModel("gpt-5.1") + expect(body.prompt_cache_retention).toBe("24h") + + const codexBody = buildRequestBodyForModel("gpt-5.1-codex") + expect(codexBody.prompt_cache_retention).toBe("24h") + + const codexMiniBody = buildRequestBodyForModel("gpt-5.1-codex-mini") + expect(codexMiniBody.prompt_cache_retention).toBe("24h") + }) + + it("should not set prompt_cache_retention for non-gpt-5.1 models even if they support prompt caching", () => { + const body = buildRequestBodyForModel("gpt-5") + expect(body.prompt_cache_retention).toBeUndefined() + + const fourOBody = buildRequestBodyForModel("gpt-4o") + expect(fourOBody.prompt_cache_retention).toBeUndefined() + }) + + it("should not set prompt_cache_retention when the model does not support prompt caching", () => { + const modelId = "codex-mini-latest" + expect(openAiNativeModels[modelId as keyof typeof openAiNativeModels].supportsPromptCache).toBe(false) + + const body = buildRequestBodyForModel(modelId) + expect(body.prompt_cache_retention).toBeUndefined() + }) + }) + describe("cost calculation", () => { it("should pass total input tokens to calculateApiCostOpenAI", () => { const usage = { diff --git a/src/api/providers/__tests__/openai-native.spec.ts b/src/api/providers/__tests__/openai-native.spec.ts index 405d275951..73c79aebd9 100644 --- a/src/api/providers/__tests__/openai-native.spec.ts +++ b/src/api/providers/__tests__/openai-native.spec.ts @@ -159,6 +159,9 @@ describe("OpenAiNativeHandler", () => { }, ], }), + expect.objectContaining({ + signal: expect.any(Object), + }), ) }) @@ -202,7 +205,7 @@ describe("OpenAiNativeHandler", () => { openAiNativeApiKey: "test-api-key", }) const modelInfo = handlerWithoutModel.getModel() - expect(modelInfo.id).toBe("gpt-5-2025-08-07") // Default model + expect(modelInfo.id).toBe("gpt-5.1") // Default model expect(modelInfo.info).toBeDefined() }) }) @@ -247,7 +250,7 @@ describe("OpenAiNativeHandler", () => { handler = new OpenAiNativeHandler({ ...mockOptions, - apiModelId: "gpt-5-2025-08-07", + apiModelId: "gpt-5.1", }) const stream = handler.createMessage(systemPrompt, messages) @@ -271,7 +274,7 @@ describe("OpenAiNativeHandler", () => { ) const body1 = (mockFetch.mock.calls[0][1] as any).body as string const parsedBody = JSON.parse(body1) - expect(parsedBody.model).toBe("gpt-5-2025-08-07") + expect(parsedBody.model).toBe("gpt-5.1") expect(parsedBody.instructions).toBe("You are a helpful assistant.") // Now using structured format with content arrays (no system prompt in input; it's provided via `instructions`) expect(parsedBody.input).toEqual([ @@ -399,7 +402,7 @@ describe("OpenAiNativeHandler", () => { handler = new OpenAiNativeHandler({ ...mockOptions, - apiModelId: "gpt-5-2025-08-07", + apiModelId: "gpt-5.1", verbosity: "low", // Set verbosity through options }) @@ -442,7 +445,7 @@ describe("OpenAiNativeHandler", () => { handler = new OpenAiNativeHandler({ ...mockOptions, - apiModelId: "gpt-5-2025-08-07", + apiModelId: "gpt-5.1", reasoningEffort: "minimal" as any, // GPT-5 supports minimal }) @@ -461,6 +464,44 @@ describe("OpenAiNativeHandler", () => { ) }) + it("should omit reasoning when selection is 'disable'", async () => { + // Mock fetch for Responses API + const mockFetch = vitest.fn().mockResolvedValue({ + ok: true, + body: new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'data: {"type":"response.output_item.added","item":{"type":"text","text":"No reasoning"}}\n\n', + ), + ) + controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) + controller.close() + }, + }), + }) + global.fetch = mockFetch as any + + // Mock SDK to fail + mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) + + const handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1", + reasoningEffort: "disable" as any, + }) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // drain + } + + const bodyStr = (mockFetch.mock.calls[0][1] as any).body as string + const parsed = JSON.parse(bodyStr) + expect(parsed.reasoning).toBeUndefined() + expect(parsed.include).toBeUndefined() + }) + it("should support low reasoning effort for GPT-5", async () => { // Mock fetch for Responses API const mockFetch = vitest.fn().mockResolvedValue({ @@ -484,7 +525,7 @@ describe("OpenAiNativeHandler", () => { handler = new OpenAiNativeHandler({ ...mockOptions, - apiModelId: "gpt-5-2025-08-07", + apiModelId: "gpt-5.1", reasoningEffort: "low", }) @@ -503,7 +544,7 @@ describe("OpenAiNativeHandler", () => { ) const body2 = (mockFetch.mock.calls[0][1] as any).body as string const parsedBody = JSON.parse(body2) - expect(parsedBody.model).toBe("gpt-5-2025-08-07") + expect(parsedBody.model).toBe("gpt-5.1") expect(parsedBody.reasoning?.effort).toBe("low") expect(parsedBody.reasoning?.summary).toBe("auto") expect(parsedBody.text?.verbosity).toBe("medium") @@ -535,7 +576,7 @@ describe("OpenAiNativeHandler", () => { handler = new OpenAiNativeHandler({ ...mockOptions, - apiModelId: "gpt-5-2025-08-07", + apiModelId: "gpt-5.1", verbosity: "high", reasoningEffort: "minimal" as any, }) @@ -555,7 +596,7 @@ describe("OpenAiNativeHandler", () => { ) const body3 = (mockFetch.mock.calls[0][1] as any).body as string const parsedBody = JSON.parse(body3) - expect(parsedBody.model).toBe("gpt-5-2025-08-07") + expect(parsedBody.model).toBe("gpt-5.1") expect(parsedBody.reasoning?.effort).toBe("minimal") expect(parsedBody.reasoning?.summary).toBe("auto") expect(parsedBody.text?.verbosity).toBe("high") @@ -613,7 +654,7 @@ describe("OpenAiNativeHandler", () => { handler = new OpenAiNativeHandler({ ...mockOptions, - apiModelId: "gpt-5-2025-08-07", + apiModelId: "gpt-5.1", }) const stream = handler.createMessage(systemPrompt, messages) @@ -669,7 +710,7 @@ describe("OpenAiNativeHandler", () => { handler = new OpenAiNativeHandler({ ...mockOptions, - apiModelId: "gpt-5-2025-08-07", + apiModelId: "gpt-5.1", }) const stream = handler.createMessage(systemPrompt, messages) @@ -714,7 +755,7 @@ describe("OpenAiNativeHandler", () => { handler = new OpenAiNativeHandler({ ...mockOptions, - apiModelId: "gpt-5-2025-08-07", + apiModelId: "gpt-5.1", }) const stream = handler.createMessage(systemPrompt, messages) @@ -755,7 +796,7 @@ describe("OpenAiNativeHandler", () => { const gpt5Handler = new OpenAiNativeHandler({ ...mockOptions, - apiModelId: "gpt-5-2025-08-07", + apiModelId: "gpt-5.1", }) const stream = gpt5Handler.createMessage(systemPrompt, messages, { @@ -800,7 +841,7 @@ describe("OpenAiNativeHandler", () => { handler = new OpenAiNativeHandler({ ...mockOptions, - apiModelId: "gpt-5-2025-08-07", + apiModelId: "gpt-5.1", }) const stream = handler.createMessage(systemPrompt, messages) @@ -850,7 +891,7 @@ describe("GPT-5 streaming event coverage (additional)", () => { mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) const handler = new OpenAiNativeHandler({ - apiModelId: "gpt-5-2025-08-07", + apiModelId: "gpt-5.1", openAiNativeApiKey: "test-api-key", }) @@ -893,7 +934,7 @@ describe("GPT-5 streaming event coverage (additional)", () => { mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) const handler = new OpenAiNativeHandler({ - apiModelId: "gpt-5-2025-08-07", + apiModelId: "gpt-5.1", openAiNativeApiKey: "test-api-key", }) @@ -942,7 +983,7 @@ describe("GPT-5 streaming event coverage (additional)", () => { mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) const handler = new OpenAiNativeHandler({ - apiModelId: "gpt-5-2025-08-07", + apiModelId: "gpt-5.1", openAiNativeApiKey: "test-api-key", }) @@ -1098,6 +1139,9 @@ describe("GPT-5 streaming event coverage (additional)", () => { stream: false, store: false, }), + expect.objectContaining({ + signal: expect.any(Object), + }), ) }) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 6ec43a6dfa..452664e7dd 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -157,6 +157,55 @@ describe("OpenAiHandler", () => { expect(usageChunk?.outputTokens).toBe(5) }) + it("should handle tool calls in non-streaming mode", async () => { + mockCreate.mockResolvedValueOnce({ + choices: [ + { + message: { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "test_tool", + arguments: '{"arg":"value"}', + }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + }, + }) + + const handler = new OpenAiHandler({ + ...mockOptions, + openAiStreamingEnabled: false, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") + expect(toolCallChunks).toHaveLength(1) + expect(toolCallChunks[0]).toEqual({ + type: "tool_call", + id: "call_1", + name: "test_tool", + arguments: '{"arg":"value"}', + }) + }) + it("should handle streaming responses", async () => { const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] @@ -170,6 +219,66 @@ describe("OpenAiHandler", () => { expect(textChunks[0].text).toBe("Test response") }) + it("should handle tool calls in streaming responses", async () => { + mockCreate.mockImplementation(async (options) => { + return { + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_1", + function: { name: "test_tool", arguments: "" }, + }, + ], + }, + finish_reason: null, + }, + ], + } + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: '{"arg":' } }], + }, + finish_reason: null, + }, + ], + } + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: '"value"}' } }], + }, + finish_reason: "tool_calls", + }, + ], + } + }, + } + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") + expect(toolCallChunks).toHaveLength(1) + expect(toolCallChunks[0]).toEqual({ + type: "tool_call", + id: "call_1", + name: "test_tool", + arguments: '{"arg":"value"}', + }) + }) + it("should include reasoning_effort when reasoning effort is enabled", async () => { const reasoningOptions: ApiHandlerOptions = { ...mockOptions, @@ -618,6 +727,58 @@ describe("OpenAiHandler", () => { ) }) + it("should handle tool calls with O3 model in streaming mode", async () => { + const o3Handler = new OpenAiHandler(o3Options) + + mockCreate.mockImplementation(async (options) => { + return { + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_1", + function: { name: "test_tool", arguments: "" }, + }, + ], + }, + finish_reason: null, + }, + ], + } + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: "{}" } }], + }, + finish_reason: "tool_calls", + }, + ], + } + }, + } + }) + + const stream = o3Handler.createMessage("system", []) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") + expect(toolCallChunks).toHaveLength(1) + expect(toolCallChunks[0]).toEqual({ + type: "tool_call", + id: "call_1", + name: "test_tool", + arguments: "{}", + }) + }) + it("should handle O3 model with streaming and exclude max_tokens when includeMaxTokens is false", async () => { const o3Handler = new OpenAiHandler({ ...o3Options, @@ -705,6 +866,55 @@ describe("OpenAiHandler", () => { expect(callArgs).not.toHaveProperty("stream") }) + it("should handle tool calls with O3 model in non-streaming mode", async () => { + const o3Handler = new OpenAiHandler({ + ...o3Options, + openAiStreamingEnabled: false, + }) + + mockCreate.mockResolvedValueOnce({ + choices: [ + { + message: { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "test_tool", + arguments: "{}", + }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + }, + }) + + const stream = o3Handler.createMessage("system", []) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") + expect(toolCallChunks).toHaveLength(1) + expect(toolCallChunks[0]).toEqual({ + type: "tool_call", + id: "call_1", + name: "test_tool", + arguments: "{}", + }) + }) + it("should use default temperature of 0 when not specified for O3 models", async () => { const o3Handler = new OpenAiHandler({ ...o3Options, diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index f5067ef34c..118be755d7 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -33,6 +33,7 @@ vitest.mock("../fetchers/modelCache", () => ({ contextWindow: 200000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, inputPrice: 3, outputPrice: 15, cacheWritesPrice: 3.75, @@ -97,6 +98,7 @@ describe("OpenRouterHandler", () => { const result = await handler.fetchModel() expect(result.id).toBe("anthropic/claude-sonnet-4.5") expect(result.info.supportsPromptCache).toBe(true) + expect(result.info.supportsNativeTools).toBe(true) }) it("honors custom maxTokens for thinking models", async () => { diff --git a/src/api/providers/__tests__/vertex.spec.ts b/src/api/providers/__tests__/vertex.spec.ts index d147e79ba8..1420b05c7a 100644 --- a/src/api/providers/__tests__/vertex.spec.ts +++ b/src/api/providers/__tests__/vertex.spec.ts @@ -95,7 +95,7 @@ describe("VertexHandler", () => { model: expect.any(String), contents: [{ role: "user", parts: [{ text: "Test prompt" }] }], config: expect.objectContaining({ - temperature: 0, + temperature: 1, }), }), ) diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index 2a240510a2..51db85410e 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -90,6 +90,8 @@ export abstract class BaseOpenAiCompatibleProvider messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], stream: true, stream_options: { include_usage: true }, + ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }), + ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), } try { @@ -115,6 +117,8 @@ export abstract class BaseOpenAiCompatibleProvider }) as const, ) + const toolCallAccumulator = new Map() + for await (const chunk of stream) { // Check for provider-specific error responses (e.g., MiniMax base_resp) const chunkAny = chunk as any @@ -125,6 +129,7 @@ export abstract class BaseOpenAiCompatibleProvider } const delta = chunk.choices?.[0]?.delta + const finishReason = chunk.choices?.[0]?.finish_reason if (delta?.content) { for (const processedChunk of matcher.update(delta.content)) { @@ -139,6 +144,37 @@ export abstract class BaseOpenAiCompatibleProvider } } + if (delta?.tool_calls) { + for (const toolCall of delta.tool_calls) { + const index = toolCall.index + const existing = toolCallAccumulator.get(index) + + if (existing) { + if (toolCall.function?.arguments) { + existing.arguments += toolCall.function.arguments + } + } else { + toolCallAccumulator.set(index, { + id: toolCall.id || "", + name: toolCall.function?.name || "", + arguments: toolCall.function?.arguments || "", + }) + } + } + } + + if (finishReason === "tool_calls") { + for (const toolCall of toolCallAccumulator.values()) { + yield { + type: "tool_call", + id: toolCall.id, + name: toolCall.name, + arguments: toolCall.arguments, + } + } + toolCallAccumulator.clear() + } + if (chunk.usage) { yield { type: "usage", diff --git a/src/api/providers/base-provider.ts b/src/api/providers/base-provider.ts index 1abbf5f558..a0611a7b3f 100644 --- a/src/api/providers/base-provider.ts +++ b/src/api/providers/base-provider.ts @@ -18,6 +18,75 @@ export abstract class BaseProvider implements ApiHandler { abstract getModel(): { id: string; info: ModelInfo } + /** + * Converts an array of tools to be compatible with OpenAI's strict mode. + * Filters for function tools and applies schema conversion to their parameters. + */ + protected convertToolsForOpenAI(tools: any[] | undefined): any[] | undefined { + if (!tools) { + return undefined + } + + return tools.map((tool) => + tool.type === "function" + ? { + ...tool, + function: { + ...tool.function, + parameters: this.convertToolSchemaForOpenAI(tool.function.parameters), + }, + } + : tool, + ) + } + + /** + * Converts tool schemas to be compatible with OpenAI's strict mode by: + * - Ensuring all properties are in the required array (strict mode requirement) + * - Converting nullable types (["type", "null"]) to non-nullable ("type") + * - Recursively processing nested objects and arrays + * + * This matches the behavior of ensureAllRequired in openai-native.ts + */ + protected convertToolSchemaForOpenAI(schema: any): any { + if (!schema || typeof schema !== "object" || schema.type !== "object") { + return schema + } + + const result = { ...schema } + + if (result.properties) { + const allKeys = Object.keys(result.properties) + // OpenAI strict mode requires ALL properties to be in required array + result.required = allKeys + + // Recursively process nested objects and convert nullable types + const newProps = { ...result.properties } + for (const key of allKeys) { + const prop = newProps[key] + + // Handle nullable types by removing null + if (prop && Array.isArray(prop.type) && prop.type.includes("null")) { + const nonNullTypes = prop.type.filter((t: string) => t !== "null") + prop.type = nonNullTypes.length === 1 ? nonNullTypes[0] : nonNullTypes + } + + // Recursively process nested objects + if (prop && prop.type === "object") { + newProps[key] = this.convertToolSchemaForOpenAI(prop) + } else if (prop && prop.type === "array" && prop.items?.type === "object") { + newProps[key] = { + ...prop, + items: this.convertToolSchemaForOpenAI(prop.items), + } + } + } + result.properties = newProps + } + + return result + } + /** * Default token counting implementation using tiktoken. * Providers can override this to use their native token counting endpoints. diff --git a/src/api/providers/featherless.ts b/src/api/providers/featherless.ts index 56d7177de7..2a985e2a87 100644 --- a/src/api/providers/featherless.ts +++ b/src/api/providers/featherless.ts @@ -1,4 +1,9 @@ -import { DEEP_SEEK_DEFAULT_TEMPERATURE, type FeatherlessModelId, featherlessDefaultModelId, featherlessModels } from "@roo-code/types" +import { + DEEP_SEEK_DEFAULT_TEMPERATURE, + type FeatherlessModelId, + featherlessDefaultModelId, + featherlessModels, +} from "@roo-code/types" import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" diff --git a/src/api/providers/fetchers/__tests__/openrouter.spec.ts b/src/api/providers/fetchers/__tests__/openrouter.spec.ts index 37cdc54439..d1faa1162e 100644 --- a/src/api/providers/fetchers/__tests__/openrouter.spec.ts +++ b/src/api/providers/fetchers/__tests__/openrouter.spec.ts @@ -28,6 +28,7 @@ describe("OpenRouter API", () => { description: expect.any(String), supportsReasoningBudget: false, supportsReasoningEffort: false, + supportsNativeTools: true, supportedParameters: ["max_tokens", "temperature", "reasoning", "include_reasoning"], }) @@ -44,6 +45,7 @@ describe("OpenRouter API", () => { supportsReasoningBudget: true, requiredReasoningBudget: true, supportsReasoningEffort: true, + supportsNativeTools: true, supportedParameters: ["max_tokens", "temperature", "reasoning", "include_reasoning"], }) @@ -96,6 +98,7 @@ describe("OpenRouter API", () => { cacheReadsPrice: 0.31, description: undefined, supportsReasoningEffort: undefined, + supportsNativeTools: undefined, supportedParameters: undefined, }, "google-ai-studio": { @@ -110,6 +113,7 @@ describe("OpenRouter API", () => { cacheReadsPrice: 0.31, description: undefined, supportsReasoningEffort: undefined, + supportsNativeTools: undefined, supportedParameters: undefined, }, }) diff --git a/src/api/providers/fetchers/__tests__/roo.spec.ts b/src/api/providers/fetchers/__tests__/roo.spec.ts index dcc79e941f..0fa574cff1 100644 --- a/src/api/providers/fetchers/__tests__/roo.spec.ts +++ b/src/api/providers/fetchers/__tests__/roo.spec.ts @@ -68,6 +68,7 @@ describe("getRooModels", () => { supportsImages: true, supportsReasoningEffort: true, requiredReasoningEffort: false, + supportsNativeTools: false, supportsPromptCache: true, inputPrice: 100, // 0.0001 * 1_000_000 outputPrice: 200, // 0.0002 * 1_000_000 @@ -116,6 +117,7 @@ describe("getRooModels", () => { supportsImages: false, supportsReasoningEffort: true, requiredReasoningEffort: true, + supportsNativeTools: false, supportsPromptCache: false, inputPrice: 100, // 0.0001 * 1_000_000 outputPrice: 200, // 0.0002 * 1_000_000 @@ -162,6 +164,7 @@ describe("getRooModels", () => { supportsImages: false, supportsReasoningEffort: false, requiredReasoningEffort: false, + supportsNativeTools: false, supportsPromptCache: false, inputPrice: 100, // 0.0001 * 1_000_000 outputPrice: 200, // 0.0002 * 1_000_000 diff --git a/src/api/providers/fetchers/openrouter.ts b/src/api/providers/fetchers/openrouter.ts index b546c40a3c..38d3c52fa9 100644 --- a/src/api/providers/fetchers/openrouter.ts +++ b/src/api/providers/fetchers/openrouter.ts @@ -115,7 +115,7 @@ export async function getOpenRouterModels(options?: ApiHandlerOptions): Promise< continue } - models[id] = parseOpenRouterModel({ + const parsedModel = parseOpenRouterModel({ id, model, inputModality: architecture?.input_modalities, @@ -123,6 +123,8 @@ export async function getOpenRouterModels(options?: ApiHandlerOptions): Promise< maxTokens: top_provider?.max_completion_tokens, supportedParameters: supported_parameters, }) + + models[id] = parsedModel } } catch (error) { console.error( @@ -216,6 +218,7 @@ export const parseOpenRouterModel = ({ cacheReadsPrice, description: model.description, supportsReasoningEffort: supportedParameters ? supportedParameters.includes("reasoning") : undefined, + supportsNativeTools: supportedParameters ? supportedParameters.includes("tools") : undefined, supportedParameters: supportedParameters ? supportedParameters.filter(isModelParameter) : undefined, } diff --git a/src/api/providers/fetchers/roo.ts b/src/api/providers/fetchers/roo.ts index 3b0da006a5..e4868bebdb 100644 --- a/src/api/providers/fetchers/roo.ts +++ b/src/api/providers/fetchers/roo.ts @@ -92,18 +92,23 @@ export async function getRooModels(baseUrl: string, apiKey?: string): Promise { + const meta = message as ReasoningMetaLike + if (meta.type === "reasoning") { + return false + } + return true + }) + + // Build a map of tool IDs to names from previous messages + // This is needed because Anthropic's tool_result blocks only contain the ID, + // but Gemini requires the name in functionResponse + const toolIdToName = new Map() + for (const message of messages) { + if (Array.isArray(message.content)) { + for (const block of message.content) { + if (block.type === "tool_use") { + toolIdToName.set(block.id, block.name) + } + } + } + } + + const contents = geminiMessages + .map((message) => convertAnthropicMessageToGemini(message, { includeThoughtSignatures, toolIdToName })) + .flat() const tools: GenerateContentConfig["tools"] = [] - if (this.options.enableUrlContext) { - tools.push({ urlContext: {} }) + + // Google built-in tools (Grounding, URL Context) are currently mutually exclusive + // with function declarations in the Gemini API. If native function calling is + // used (Agent tools), we must prioritize it and skip built-in tools to avoid + // "Tool use with function calling is unsupported" (HTTP 400) errors. + if (metadata?.tools && metadata.tools.length > 0) { + tools.push({ + functionDeclarations: metadata.tools.map((tool) => ({ + name: (tool as any).function.name, + description: (tool as any).function.description, + parametersJsonSchema: (tool as any).function.parameters, + })), + }) + } else { + if (this.options.enableUrlContext) { + tools.push({ urlContext: {} }) + } + + if (this.options.enableGrounding) { + tools.push({ googleSearch: {} }) + } } - if (this.options.enableGrounding) { - tools.push({ googleSearch: {} }) - } + // Determine temperature respecting model capabilities and defaults: + // - If supportsTemperature is explicitly false, ignore user overrides + // and pin to the model's defaultTemperature (or omit if undefined). + // - Otherwise, allow the user setting to override, falling back to model default, + // then to 1 for Gemini provider default. + const supportsTemperature = info.supportsTemperature !== false + const temperatureConfig: number | undefined = supportsTemperature + ? (this.options.modelTemperature ?? info.defaultTemperature ?? 1) + : info.defaultTemperature const config: GenerateContentConfig = { systemInstruction, httpOptions: this.options.googleGeminiBaseUrl ? { baseUrl: this.options.googleGeminiBaseUrl } : undefined, thinkingConfig, - maxOutputTokens: this.options.modelMaxTokens ?? maxTokens ?? undefined, - temperature: this.options.modelTemperature ?? 0, + maxOutputTokens, + temperature: temperatureConfig, ...(tools.length > 0 ? { tools } : {}), } - const params: GenerateContentParameters = { model, contents, config } + if (metadata?.tool_choice) { + const choice = metadata.tool_choice + let mode: FunctionCallingConfigMode + let allowedFunctionNames: string[] | undefined + if (choice === "auto") { + mode = FunctionCallingConfigMode.AUTO + } else if (choice === "none") { + mode = FunctionCallingConfigMode.NONE + } else if (choice === "required") { + // "required" means the model must call at least one tool; Gemini uses ANY for this. + mode = FunctionCallingConfigMode.ANY + } else if (typeof choice === "object" && "function" in choice && choice.type === "function") { + mode = FunctionCallingConfigMode.ANY + allowedFunctionNames = [choice.function.name] + } else { + // Fall back to AUTO for unknown values to avoid unintentionally broadening tool access. + mode = FunctionCallingConfigMode.AUTO + } + + config.toolConfig = { + functionCallingConfig: { + mode, + ...(allowedFunctionNames ? { allowedFunctionNames } : {}), + }, + } + } + + const params: GenerateContentParameters = { model, contents, config } try { const result = await this.client.models.generateContentStream(params) let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined let pendingGroundingMetadata: GroundingMetadata | undefined + let finalResponse: { responseId?: string } | undefined + + let toolCallCounter = 0 for await (const chunk of result) { + // Track the final structured response (per SDK pattern: candidate.finishReason) + if (chunk.candidates && chunk.candidates[0]?.finishReason) { + finalResponse = chunk as { responseId?: string } + } // Process candidates and their parts to separate thoughts from content if (chunk.candidates && chunk.candidates.length > 0) { const candidate = chunk.candidates[0] @@ -105,12 +216,33 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl } if (candidate.content && candidate.content.parts) { - for (const part of candidate.content.parts) { + for (const part of candidate.content.parts as Array<{ + thought?: boolean + text?: string + thoughtSignature?: string + functionCall?: { name: string; args: Record } + }>) { + // Capture thought signatures so they can be persisted into API history. + const thoughtSignature = part.thoughtSignature + // Persist encrypted reasoning when using reasoning. Both effort-based + // and budget-based models require this for active loops. + if (thinkingConfig && thoughtSignature) { + this.lastThoughtSignature = thoughtSignature + } + if (part.thought) { // This is a thinking/reasoning part if (part.text) { yield { type: "reasoning", text: part.text } } + } else if (part.functionCall) { + const callId = `${part.functionCall.name}-${toolCallCounter++}` + yield { + type: "tool_call", + id: callId, + name: part.functionCall.name, + arguments: JSON.stringify(part.functionCall.args), + } } else { // This is regular content if (part.text) { @@ -131,6 +263,12 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl } } + if (finalResponse?.responseId) { + // Capture responseId so Task.addToApiConversationHistory can store it + // alongside the assistant message in api_history.json. + this.lastResponseId = finalResponse.responseId + } + if (pendingGroundingMetadata) { const sources = this.extractGroundingSources(pendingGroundingMetadata) if (sources.length > 0) { @@ -150,7 +288,13 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl outputTokens, cacheReadTokens, reasoningTokens, - totalCost: this.calculateCost({ info, inputTokens, outputTokens, cacheReadTokens }), + totalCost: this.calculateCost({ + info, + inputTokens, + outputTokens, + cacheReadTokens, + reasoningTokens, + }), } } } catch (error) { @@ -166,7 +310,14 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl const modelId = this.options.apiModelId let id = modelId && modelId in geminiModels ? (modelId as GeminiModelId) : geminiDefaultModelId let info: ModelInfo = geminiModels[id] - const params = getModelParams({ format: "gemini", modelId: id, model: info, settings: this.options }) + + const params = getModelParams({ + format: "gemini", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: info.defaultTemperature ?? 1, + }) // The `:thinking` suffix indicates that the model is a "Hybrid" // reasoning model and that reasoning is required to be enabled. @@ -211,7 +362,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl async completePrompt(prompt: string): Promise { try { - const { id: model } = this.getModel() + const { id: model, info } = this.getModel() const tools: GenerateContentConfig["tools"] = [] if (this.options.enableUrlContext) { @@ -220,19 +371,27 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl if (this.options.enableGrounding) { tools.push({ googleSearch: {} }) } + + const supportsTemperature = info.supportsTemperature !== false + const temperatureConfig: number | undefined = supportsTemperature + ? (this.options.modelTemperature ?? info.defaultTemperature ?? 1) + : info.defaultTemperature + const promptConfig: GenerateContentConfig = { httpOptions: this.options.googleGeminiBaseUrl ? { baseUrl: this.options.googleGeminiBaseUrl } : undefined, - temperature: this.options.modelTemperature ?? 0, + temperature: temperatureConfig, ...(tools.length > 0 ? { tools } : {}), } - const result = await this.client.models.generateContent({ + const request = { model, contents: [{ role: "user", parts: [{ text: prompt }] }], config: promptConfig, - }) + } + + const result = await this.client.models.generateContent(request) let text = result.text ?? "" @@ -258,10 +417,13 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl try { const { id: model } = this.getModel() - const response = await this.client.models.countTokens({ + const countTokensRequest = { model, - contents: convertAnthropicContentToGemini(content), - }) + // Token counting does not need encrypted continuation; always drop thoughtSignature. + contents: convertAnthropicContentToGemini(content, { includeThoughtSignatures: false }), + } + + const response = await this.client.models.countTokens(countTokensRequest) if (response.totalTokens === undefined) { console.warn("Gemini token counting returned undefined, using fallback") @@ -275,16 +437,26 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl } } + public getThoughtSignature(): string | undefined { + return this.lastThoughtSignature + } + + public getResponseId(): string | undefined { + return this.lastResponseId + } + public calculateCost({ info, inputTokens, outputTokens, cacheReadTokens = 0, + reasoningTokens = 0, }: { info: ModelInfo inputTokens: number outputTokens: number cacheReadTokens?: number + reasoningTokens?: number }) { // For models with tiered pricing, prices might only be defined in tiers let inputPrice = info.inputPrice @@ -316,23 +488,24 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl // Subtract the cached input tokens from the total input tokens. const uncachedInputTokens = inputTokens - cacheReadTokens + // Bill both completion and reasoning ("thoughts") tokens as output. + const billedOutputTokens = outputTokens + reasoningTokens + let cacheReadCost = cacheReadTokens > 0 ? cacheReadsPrice * (cacheReadTokens / 1_000_000) : 0 const inputTokensCost = inputPrice * (uncachedInputTokens / 1_000_000) - const outputTokensCost = outputPrice * (outputTokens / 1_000_000) + const outputTokensCost = outputPrice * (billedOutputTokens / 1_000_000) const totalCost = inputTokensCost + outputTokensCost + cacheReadCost const trace: Record = { input: { price: inputPrice, tokens: uncachedInputTokens, cost: inputTokensCost }, - output: { price: outputPrice, tokens: outputTokens, cost: outputTokensCost }, + output: { price: outputPrice, tokens: billedOutputTokens, cost: outputTokensCost }, } if (cacheReadTokens > 0) { trace.cacheRead = { price: cacheReadsPrice, tokens: cacheReadTokens, cost: cacheReadCost } } - // console.log(`[GeminiHandler] calculateCost -> ${totalCost}`, trace) - return totalCost } } diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 1cf195a3ba..6926e4d624 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -7,10 +7,9 @@ import { OpenAiNativeModelId, openAiNativeModels, OPENAI_NATIVE_DEFAULT_TEMPERATURE, - GPT5_DEFAULT_TEMPERATURE, type ReasoningEffort, type VerbosityLevel, - type ReasoningEffortWithMinimal, + type ReasoningEffortExtended, type ServiceTier, } from "@roo-code/types" @@ -26,11 +25,6 @@ import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from ". export type OpenAiNativeModel = ReturnType -// GPT-5 specific types - -// Constants for model identification -const GPT5_MODEL_PREFIX = "gpt-5" - export class OpenAiNativeHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions private client: OpenAI @@ -40,6 +34,10 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio private lastResponseOutput: any[] | undefined // Last top-level response id from Responses API (for troubleshooting) private lastResponseId: string | undefined + // Accumulate partial tool calls: call_id -> { name, arguments } + private currentToolCalls: Map = new Map() + // Abort controller for cancelling ongoing requests + private abortController?: AbortController // Event types handled by the shared event processor to avoid duplication private readonly coreHandledEventTypes = new Set([ @@ -53,14 +51,19 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio "response.output_item.added", "response.done", "response.completed", + "response.tool_call_arguments.delta", + "response.function_call_arguments.delta", + "response.tool_call_arguments.done", + "response.function_call_arguments.done", ]) constructor(options: ApiHandlerOptions) { super() this.options = options - // Default to including reasoning.summary: "auto" for GPT‑5 unless explicitly disabled - if (this.options.enableGpt5ReasoningSummary === undefined) { - this.options.enableGpt5ReasoningSummary = true + // Default to including reasoning.summary: "auto" for models that support Responses API + // reasoning summaries unless explicitly disabled. + if (this.options.enableResponsesReasoningSummary === undefined) { + this.options.enableResponsesReasoningSummary = true } const apiKey = this.options.openAiNativeApiKey ?? "not-provided" this.client = new OpenAI({ baseURL: this.options.openAiNativeBaseUrl, apiKey }) @@ -150,6 +153,8 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio this.lastResponseOutput = undefined // Reset last response id for this request this.lastResponseId = undefined + // Reset tool call accumulator + this.currentToolCalls.clear() // Use Responses API for ALL models const { verbosity, reasoning } = this.getModel() @@ -170,9 +175,6 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio metadata, ) - // Temporary debug logging - // console.log("[OpenAI Native] Request body:", requestBody) - // Make the request (pass systemPrompt and messages for potential retry) yield* this.executeRequest(requestBody, model, metadata, systemPrompt, messages) } @@ -182,17 +184,49 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio formattedInput: any, systemPrompt: string, verbosity: any, - reasoningEffort: ReasoningEffortWithMinimal | undefined, + reasoningEffort: ReasoningEffortExtended | undefined, metadata?: ApiHandlerCreateMessageMetadata, ): any { - // Build a request body - // Ensure we explicitly pass max_output_tokens for GPT‑5 based on Roo's reserved model response calculation + // Ensure all properties are in the required array for OpenAI's strict mode + // This recursively processes nested objects and array items + const ensureAllRequired = (schema: any): any => { + if (!schema || typeof schema !== "object" || schema.type !== "object") { + return schema + } + + const result = { ...schema } + + if (result.properties) { + const allKeys = Object.keys(result.properties) + result.required = allKeys + + // Recursively process nested objects + const newProps = { ...result.properties } + for (const key of allKeys) { + const prop = newProps[key] + if (prop.type === "object") { + newProps[key] = ensureAllRequired(prop) + } else if (prop.type === "array" && prop.items?.type === "object") { + newProps[key] = { + ...prop, + items: ensureAllRequired(prop.items), + } + } + } + result.properties = newProps + } + + return result + } + + // Build a request body for the OpenAI Responses API. + // Ensure we explicitly pass max_output_tokens based on Roo's reserved model response calculation // so requests do not default to very large limits (e.g., 120k). - interface Gpt5RequestBody { + interface ResponsesRequestBody { model: string input: Array<{ role: "user" | "assistant"; content: any[] } | { type: string; content: string }> stream: boolean - reasoning?: { effort?: ReasoningEffortWithMinimal; summary?: "auto" } + reasoning?: { effort?: ReasoningEffortExtended; summary?: "auto" } text?: { verbosity: VerbosityLevel } temperature?: number max_output_tokens?: number @@ -200,13 +234,26 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio instructions?: string service_tier?: ServiceTier include?: string[] + /** Prompt cache retention policy: "in_memory" (default) or "24h" for extended caching */ + prompt_cache_retention?: "in_memory" | "24h" + tools?: Array<{ + type: "function" + name: string + description?: string + parameters?: any + strict?: boolean + }> + tool_choice?: any } // Validate requested tier against model support; if not supported, omit. const requestedTier = (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined const allowedTierNames = new Set(model.info.tiers?.map((t) => t.name).filter(Boolean) || []) - const body: Gpt5RequestBody = { + // Decide whether to enable extended prompt cache retention for this request + const promptCacheRetention = this.getPromptCacheRetention(model) + + const body: ResponsesRequestBody = { model: model.id, input: formattedInput, stream: true, @@ -216,22 +263,19 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // Unlike Chat Completions, system/developer roles in input have no special semantics here. // The official way to set system behavior is the top-level `instructions` field. instructions: systemPrompt, - include: ["reasoning.encrypted_content"], + // Only include encrypted reasoning content when reasoning effort is set + ...(reasoningEffort ? { include: ["reasoning.encrypted_content"] } : {}), ...(reasoningEffort ? { reasoning: { ...(reasoningEffort ? { effort: reasoningEffort } : {}), - ...(this.options.enableGpt5ReasoningSummary ? { summary: "auto" as const } : {}), + ...(this.options.enableResponsesReasoningSummary ? { summary: "auto" as const } : {}), }, } : {}), // Only include temperature if the model supports it ...(model.info.supportsTemperature !== false && { - temperature: - this.options.modelTemperature ?? - (model.id.startsWith(GPT5_MODEL_PREFIX) - ? GPT5_DEFAULT_TEMPERATURE - : OPENAI_NATIVE_DEFAULT_TEMPERATURE), + temperature: this.options.modelTemperature ?? OPENAI_NATIVE_DEFAULT_TEMPERATURE, }), // Explicitly include the calculated max output tokens. // Use the per-request reserved output computed by Roo (params.maxTokens from getModelParams). @@ -241,6 +285,21 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio (requestedTier === "default" || allowedTierNames.has(requestedTier)) && { service_tier: requestedTier, }), + // Enable extended prompt cache retention for models that support it. + // This uses the OpenAI Responses API `prompt_cache_retention` parameter. + ...(promptCacheRetention ? { prompt_cache_retention: promptCacheRetention } : {}), + ...(metadata?.tools && { + tools: metadata.tools + .filter((tool) => tool.type === "function") + .map((tool) => ({ + type: "function", + name: tool.function.name, + description: tool.function.description, + parameters: ensureAllRequired(tool.function.parameters), + strict: true, + })), + }), + ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), } // Include text.verbosity only when the model explicitly supports it @@ -258,9 +317,14 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio systemPrompt?: string, messages?: Anthropic.Messages.MessageParam[], ): ApiStream { + // Create AbortController for cancellation + this.abortController = new AbortController() + try { // Use the official SDK - const stream = (await (this.client as any).responses.create(requestBody)) as AsyncIterable + const stream = (await (this.client as any).responses.create(requestBody, { + signal: this.abortController.signal, + })) as AsyncIterable if (typeof (stream as any)[Symbol.asyncIterator] !== "function") { throw new Error( @@ -269,21 +333,27 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio } for await (const event of stream) { + // Check if request was aborted + if (this.abortController.signal.aborted) { + break + } + for await (const outChunk of this.processEvent(event, model)) { yield outChunk } } } catch (sdkErr: any) { // For errors, fallback to manual SSE via fetch - yield* this.makeGpt5ResponsesAPIRequest(requestBody, model, metadata, systemPrompt, messages) + yield* this.makeResponsesApiRequest(requestBody, model, metadata, systemPrompt, messages) + } finally { + this.abortController = undefined } } private formatFullConversation(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): any { // Format the entire conversation history for the Responses API using structured format - // This supports both text and images - // Messages already include reasoning items from API history, so we just need to format them - const formattedMessages: any[] = [] + // The Responses API (like Realtime API) accepts a list of items, which can be messages, function calls, or function call outputs. + const formattedInput: any[] = [] // Do NOT embed the system prompt as a developer message in the Responses API input. // The Responses API treats roles as free-form; use the top-level `instructions` field instead. @@ -293,48 +363,86 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // Check if this is a reasoning item (already formatted in API history) if ((message as any).type === "reasoning") { // Pass through reasoning items as-is - formattedMessages.push(message) + formattedInput.push(message) continue } - const role = message.role === "user" ? "user" : "assistant" - const content: any[] = [] + if (message.role === "user") { + const content: any[] = [] + const toolResults: any[] = [] - if (typeof message.content === "string") { - // For user messages, use input_text; for assistant messages, use output_text - if (role === "user") { + if (typeof message.content === "string") { content.push({ type: "input_text", text: message.content }) - } else { - content.push({ type: "output_text", text: message.content }) - } - } else if (Array.isArray(message.content)) { - // For array content with potential images, format properly - for (const block of message.content) { - if (block.type === "text") { - // For user messages, use input_text; for assistant messages, use output_text - if (role === "user") { - content.push({ type: "input_text", text: (block as any).text }) - } else { - content.push({ type: "output_text", text: (block as any).text }) + } else if (Array.isArray(message.content)) { + for (const block of message.content) { + if (block.type === "text") { + content.push({ type: "input_text", text: block.text }) + } else if (block.type === "image") { + const image = block as Anthropic.Messages.ImageBlockParam + const imageUrl = `data:${image.source.media_type};base64,${image.source.data}` + content.push({ type: "input_image", image_url: imageUrl }) + } else if (block.type === "tool_result") { + // Map Anthropic tool_result to Responses API function_call_output item + const result = + typeof block.content === "string" + ? block.content + : block.content?.map((c) => (c.type === "text" ? c.text : "")).join("") || "" + toolResults.push({ + type: "function_call_output", + call_id: block.tool_use_id, + output: result, + }) } - } else if (block.type === "image") { - const image = block as Anthropic.Messages.ImageBlockParam - // Format image with proper data URL - images are always input_image - const imageUrl = `data:${image.source.media_type};base64,${image.source.data}` - content.push({ type: "input_image", image_url: imageUrl }) } } - } - if (content.length > 0) { - formattedMessages.push({ role, content }) + // Add user message first + if (content.length > 0) { + formattedInput.push({ role: "user", content }) + } + + // Add tool results as separate items + if (toolResults.length > 0) { + formattedInput.push(...toolResults) + } + } else if (message.role === "assistant") { + const content: any[] = [] + const toolCalls: any[] = [] + + if (typeof message.content === "string") { + content.push({ type: "output_text", text: message.content }) + } else if (Array.isArray(message.content)) { + for (const block of message.content) { + if (block.type === "text") { + content.push({ type: "output_text", text: block.text }) + } else if (block.type === "tool_use") { + // Map Anthropic tool_use to Responses API function_call item + toolCalls.push({ + type: "function_call", + call_id: block.id, + name: block.name, + arguments: JSON.stringify(block.input), + }) + } + } + } + + // Add assistant message if it has content + if (content.length > 0) { + formattedInput.push({ role: "assistant", content }) + } + + // Add tool calls as separate items + if (toolCalls.length > 0) { + formattedInput.push(...toolCalls) + } } } - return formattedMessages + return formattedInput } - private async *makeGpt5ResponsesAPIRequest( + private async *makeResponsesApiRequest( requestBody: any, model: OpenAiNativeModel, metadata?: ApiHandlerCreateMessageMetadata, @@ -345,6 +453,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio const baseUrl = this.options.openAiNativeBaseUrl || "https://api.openai.com" const url = `${baseUrl}/v1/responses` + // Create AbortController for cancellation + this.abortController = new AbortController() + try { const response = await fetch(url, { method: "POST", @@ -354,12 +465,13 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio Accept: "text/event-stream", }, body: JSON.stringify(requestBody), + signal: this.abortController.signal, }) if (!response.ok) { const errorText = await response.text() - let errorMessage = `GPT-5 API request failed (${response.status})` + let errorMessage = `OpenAI Responses API request failed (${response.status})` let errorDetails = "" // Try to parse error as JSON for better error messages @@ -429,6 +541,8 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio } // Handle non-Error objects throw new Error(`Unexpected error connecting to Responses API`) + } finally { + this.abortController = undefined } } @@ -449,6 +563,11 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio try { while (true) { + // Check if request was aborted + if (this.abortController?.signal.aborted) { + break + } + const { done, value } = await reader.read() if (done) break @@ -654,11 +773,16 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // Output item completed } // Handle function/tool call events - else if (parsed.type === "response.function_call_arguments.delta") { - // Function call arguments streaming - // We could yield this as a special type if needed for tool usage - } else if (parsed.type === "response.function_call_arguments.done") { - // Function call completed + else if ( + parsed.type === "response.function_call_arguments.delta" || + parsed.type === "response.tool_call_arguments.delta" || + parsed.type === "response.function_call_arguments.done" || + parsed.type === "response.tool_call_arguments.done" + ) { + // Delegated to processEvent (handles accumulation and completion) + for await (const outChunk of this.processEvent(parsed, model)) { + yield outChunk + } } // Handle MCP (Model Context Protocol) tool events else if (parsed.type === "response.mcp_call_arguments.delta") { @@ -815,7 +939,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio } } - // Usage for done/completed is already handled by processGpt5Event in SDK path. + // Usage for done/completed is already handled by processEvent in the SDK path. // For SSE path, usage often arrives separately; avoid double-emitting here. } // These are structural or status events, we can just log them at a lower level or ignore. @@ -939,8 +1063,53 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio return } - // Handle output item additions (SDK or Responses API alternative format) - if (event?.type === "response.output_item.added") { + // Handle tool/function call deltas and completion + if ( + event?.type === "response.tool_call_arguments.delta" || + event?.type === "response.function_call_arguments.delta" + ) { + const callId = event.call_id || event.tool_call_id || event.id + if (callId) { + if (!this.currentToolCalls.has(callId)) { + this.currentToolCalls.set(callId, { name: "", arguments: "" }) + } + const toolCall = this.currentToolCalls.get(callId)! + + // Update name if present (usually in the first delta) + if (event.name || event.function_name) { + toolCall.name = event.name || event.function_name + } + + // Append arguments delta + if (event.delta || event.arguments) { + toolCall.arguments += event.delta || event.arguments + } + } + return + } + + if ( + event?.type === "response.tool_call_arguments.done" || + event?.type === "response.function_call_arguments.done" + ) { + const callId = event.call_id || event.tool_call_id || event.id + if (callId && this.currentToolCalls.has(callId)) { + const toolCall = this.currentToolCalls.get(callId)! + // Yield the complete tool call + yield { + type: "tool_call", + id: callId, + name: toolCall.name, + arguments: toolCall.arguments, + } + // Remove from accumulator + this.currentToolCalls.delete(callId) + } + return + } + + // Handle output item additions/completions (SDK or Responses API alternative format) + if (event?.type === "response.output_item.added" || event?.type === "response.output_item.done") { const item = event?.item if (item) { if (item.type === "text" && item.text) { @@ -954,6 +1123,21 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio yield { type: "text", text: content.text } } } + } else if ( + (item.type === "function_call" || item.type === "tool_call") && + event.type === "response.output_item.done" // Only handle done events for tool calls to ensure arguments are complete + ) { + // Handle complete tool/function call item + const callId = item.call_id || item.tool_call_id || item.id + if (callId && !this.currentToolCalls.has(callId)) { + const args = item.arguments || item.function?.arguments || item.function_arguments + yield { + type: "tool_call", + id: callId, + name: item.name || item.function?.name || item.function_name || "", + arguments: typeof args === "string" ? args : "{}", + } + } } } return @@ -961,6 +1145,19 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // Completion events that may carry usage if (event?.type === "response.done" || event?.type === "response.completed") { + // Yield any pending tool calls that didn't get a 'done' event (fallback) + if (this.currentToolCalls.size > 0) { + for (const [callId, toolCall] of this.currentToolCalls) { + yield { + type: "tool_call", + id: callId, + name: toolCall.name, + arguments: toolCall.arguments || "{}", + } + } + this.currentToolCalls.clear() + } + const usage = event?.response?.usage || event?.usage || undefined const usageData = this.normalizeUsage(usage, model) if (usageData) { @@ -983,20 +1180,27 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio } } - private getReasoningEffort(model: OpenAiNativeModel): ReasoningEffortWithMinimal | undefined { - const { reasoning, info } = model + private getReasoningEffort(model: OpenAiNativeModel): ReasoningEffortExtended | undefined { + // Single source of truth: user setting overrides, else model default (from types). + const selected = (this.options.reasoningEffort as any) ?? (model.info.reasoningEffort as any) + return selected && selected !== "disable" ? (selected as any) : undefined + } - // Check if reasoning effort is configured - if (reasoning && "reasoning_effort" in reasoning) { - const effort = reasoning.reasoning_effort as string - // Support all effort levels - if (effort === "minimal" || effort === "low" || effort === "medium" || effort === "high") { - return effort as ReasoningEffortWithMinimal - } + /** + * Returns the appropriate prompt cache retention policy for the given model, if any. + * + * The policy is driven by ModelInfo.promptCacheRetention so that model-specific details + * live in the shared types layer rather than this provider. When set to "24h" and the + * model supports prompt caching, extended prompt cache retention is requested. + */ + private getPromptCacheRetention(model: OpenAiNativeModel): "24h" | undefined { + if (!model.info.supportsPromptCache) return undefined + + if (model.info.promptCacheRetention === "24h") { + return "24h" } - // Use the model's default from types if available - return info.reasoningEffort as ReasoningEffortWithMinimal | undefined + return undefined } /** @@ -1034,19 +1238,11 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio modelId: id, model: info, settings: this.options, - defaultTemperature: id.startsWith(GPT5_MODEL_PREFIX) - ? GPT5_DEFAULT_TEMPERATURE - : OPENAI_NATIVE_DEFAULT_TEMPERATURE, + defaultTemperature: OPENAI_NATIVE_DEFAULT_TEMPERATURE, }) - // For models using the Responses API, ensure we support reasoning effort - const effort = - (this.options.reasoningEffort as ReasoningEffortWithMinimal | undefined) ?? - (info.reasoningEffort as ReasoningEffortWithMinimal | undefined) - - if (effort) { - ;(params.reasoning as any) = { reasoning_effort: effort } - } + // Reasoning effort inclusion is handled by getModelParams/getOpenAiReasoning. + // Do not re-compute or filter efforts here. // The o3 models are named like "o3-mini-[reasoning-effort]", which are // not valid model ids, so we need to strip the suffix. @@ -1080,6 +1276,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio } async completePrompt(prompt: string): Promise { + // Create AbortController for cancellation + this.abortController = new AbortController() + try { const model = this.getModel() const { verbosity, reasoning } = model @@ -1098,7 +1297,8 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio ], stream: false, // Non-streaming for completePrompt store: false, // Don't store prompt completions - include: ["reasoning.encrypted_content"], + // Only include encrypted reasoning content when reasoning effort is set + ...(reasoningEffort ? { include: ["reasoning.encrypted_content"] } : {}), } // Include service tier if selected and supported @@ -1112,17 +1312,13 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio if (reasoningEffort) { requestBody.reasoning = { effort: reasoningEffort, - ...(this.options.enableGpt5ReasoningSummary ? { summary: "auto" as const } : {}), + ...(this.options.enableResponsesReasoningSummary ? { summary: "auto" as const } : {}), } } // Only include temperature if the model supports it if (model.info.supportsTemperature !== false) { - requestBody.temperature = - this.options.modelTemperature ?? - (model.id.startsWith(GPT5_MODEL_PREFIX) - ? GPT5_DEFAULT_TEMPERATURE - : OPENAI_NATIVE_DEFAULT_TEMPERATURE) + requestBody.temperature = this.options.modelTemperature ?? OPENAI_NATIVE_DEFAULT_TEMPERATURE } // Include max_output_tokens if available @@ -1135,8 +1331,16 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio requestBody.text = { verbosity: (verbosity || "medium") as VerbosityLevel } } + // Enable extended prompt cache retention for eligible models + const promptCacheRetention = this.getPromptCacheRetention(model) + if (promptCacheRetention) { + requestBody.prompt_cache_retention = promptCacheRetention + } + // Make the non-streaming request - const response = await (this.client as any).responses.create(requestBody) + const response = await (this.client as any).responses.create(requestBody, { + signal: this.abortController.signal, + }) // Extract text from the response if (response?.output && Array.isArray(response.output)) { @@ -1162,6 +1366,8 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio throw new Error(`OpenAI Native completion error: ${error.message}`) } throw error + } finally { + this.abortController = undefined } } } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 6b847be2d0..79d65e82e2 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -95,7 +95,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl const ark = modelUrl.includes(".volces.com") if (modelId.includes("o1") || modelId.includes("o3") || modelId.includes("o4")) { - yield* this.handleO3FamilyMessage(modelId, systemPrompt, messages) + yield* this.handleO3FamilyMessage(modelId, systemPrompt, messages, metadata) return } @@ -164,6 +164,8 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl stream: true as const, ...(isGrokXAI ? {} : { stream_options: { include_usage: true } }), ...(reasoning && reasoning), + ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }), + ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), } // Add max_tokens if needed @@ -189,9 +191,11 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl ) let lastUsage + const toolCallAccumulator = new Map() for await (const chunk of stream) { const delta = chunk.choices?.[0]?.delta ?? {} + const finishReason = chunk.choices?.[0]?.finish_reason if (delta.content) { for (const chunk of matcher.update(delta.content)) { @@ -205,6 +209,38 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl text: (delta.reasoning_content as string | undefined) || "", } } + + if (delta.tool_calls) { + for (const toolCall of delta.tool_calls) { + const index = toolCall.index + const existing = toolCallAccumulator.get(index) + + if (existing) { + if (toolCall.function?.arguments) { + existing.arguments += toolCall.function.arguments + } + } else { + toolCallAccumulator.set(index, { + id: toolCall.id || "", + name: toolCall.function?.name || "", + arguments: toolCall.function?.arguments || "", + }) + } + } + } + + if (finishReason === "tool_calls") { + for (const toolCall of toolCallAccumulator.values()) { + yield { + type: "tool_call", + id: toolCall.id, + name: toolCall.name, + arguments: toolCall.arguments, + } + } + toolCallAccumulator.clear() + } + if (chunk.usage) { lastUsage = chunk.usage } @@ -225,6 +261,8 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl : enabledLegacyFormat ? [systemMessage, ...convertToSimpleMessages(messages)] : [systemMessage, ...convertToOpenAiMessages(messages)], + ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }), + ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), } // Add max_tokens if needed @@ -240,9 +278,24 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl throw handleOpenAIError(error, this.providerName) } + const message = response.choices?.[0]?.message + + if (message?.tool_calls) { + for (const toolCall of message.tool_calls) { + if (toolCall.type === "function") { + yield { + type: "tool_call", + id: toolCall.id, + name: toolCall.function.name, + arguments: toolCall.function.arguments, + } + } + } + } + yield { type: "text", - text: response.choices?.[0]?.message.content || "", + text: message?.content || "", } yield this.processUsageMetrics(response.usage, modelInfo) @@ -304,6 +357,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl modelId: string, systemPrompt: string, messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const modelInfo = this.getModel().info const methodIsAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) @@ -324,6 +378,8 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl ...(isGrokXAI ? {} : { stream_options: { include_usage: true } }), reasoning_effort: modelInfo.reasoningEffort as "low" | "medium" | "high" | undefined, temperature: undefined, + ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }), + ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), } // O3 family models do not support the deprecated max_tokens parameter @@ -354,6 +410,8 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl ], reasoning_effort: modelInfo.reasoningEffort as "low" | "medium" | "high" | undefined, temperature: undefined, + ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }), + ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), } // O3 family models do not support the deprecated max_tokens parameter @@ -371,22 +429,73 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl throw handleOpenAIError(error, this.providerName) } + const message = response.choices?.[0]?.message + if (message?.tool_calls) { + for (const toolCall of message.tool_calls) { + if (toolCall.type === "function") { + yield { + type: "tool_call", + id: toolCall.id, + name: toolCall.function.name, + arguments: toolCall.function.arguments, + } + } + } + } + yield { type: "text", - text: response.choices?.[0]?.message.content || "", + text: message?.content || "", } yield this.processUsageMetrics(response.usage) } } private async *handleStreamResponse(stream: AsyncIterable): ApiStream { + const toolCallAccumulator = new Map() + for await (const chunk of stream) { const delta = chunk.choices?.[0]?.delta - if (delta?.content) { - yield { - type: "text", - text: delta.content, + const finishReason = chunk.choices?.[0]?.finish_reason + + if (delta) { + if (delta.content) { + yield { + type: "text", + text: delta.content, + } } + + if (delta.tool_calls) { + for (const toolCall of delta.tool_calls) { + const index = toolCall.index + const existing = toolCallAccumulator.get(index) + + if (existing) { + if (toolCall.function?.arguments) { + existing.arguments += toolCall.function.arguments + } + } else { + toolCallAccumulator.set(index, { + id: toolCall.id || "", + name: toolCall.function?.name || "", + arguments: toolCall.function?.arguments || "", + }) + } + } + } + } + + if (finishReason === "tool_calls") { + for (const toolCall of toolCallAccumulator.values()) { + yield { + type: "tool_call", + id: toolCall.id, + name: toolCall.name, + arguments: toolCall.arguments, + } + } + toolCallAccumulator.clear() } if (chunk.usage) { diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index d16a410b13..fa3aa5e5b0 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -24,7 +24,7 @@ import { getModelEndpoints } from "./fetchers/modelEndpointCache" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" -import type { SingleCompletionHandler } from "../index" +import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from "../index" import { handleOpenAIError } from "./utils/openai-error-handler" // Image generation types @@ -96,11 +96,38 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH const apiKey = this.options.openRouterApiKey ?? "not-provided" this.client = new OpenAI({ baseURL, apiKey, defaultHeaders: DEFAULT_HEADERS }) + + // Load models asynchronously to populate cache before getModel() is called + this.loadDynamicModels().catch((error) => { + console.error("[OpenRouterHandler] Failed to load dynamic models:", error) + }) + } + + private async loadDynamicModels(): Promise { + try { + const [models, endpoints] = await Promise.all([ + getModels({ provider: "openrouter" }), + getModelEndpoints({ + router: "openrouter", + modelId: this.options.openRouterModelId, + endpoint: this.options.openRouterSpecificProvider, + }), + ]) + + this.models = models + this.endpoints = endpoints + } catch (error) { + console.error("[OpenRouterHandler] Error loading dynamic models:", { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }) + } } override async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, ): AsyncGenerator { const model = await this.fetchModel() @@ -159,8 +186,11 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH allow_fallbacks: false, }, }), + parallel_tool_calls: false, // Ensure only one tool call at a time ...(transforms && { transforms }), ...(reasoning && { reasoning }), + ...(metadata?.tools && { tools: metadata.tools }), + ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), } let stream @@ -171,6 +201,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } let lastUsage: CompletionUsage | undefined = undefined + const toolCallAccumulator = new Map() for await (const chunk of stream) { // OpenRouter returns an error object instead of the OpenAI SDK throwing an error. @@ -181,13 +212,52 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } const delta = chunk.choices[0]?.delta + const finishReason = chunk.choices[0]?.finish_reason - if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { - yield { type: "reasoning", text: delta.reasoning } + if (delta) { + if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { + yield { type: "reasoning", text: delta.reasoning } + } + + // Check for tool calls in delta + if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { + for (const toolCall of delta.tool_calls) { + const index = toolCall.index + const existing = toolCallAccumulator.get(index) + + if (existing) { + // Accumulate arguments for existing tool call + if (toolCall.function?.arguments) { + existing.arguments += toolCall.function.arguments + } + } else { + // Start new tool call accumulation + toolCallAccumulator.set(index, { + id: toolCall.id || "", + name: toolCall.function?.name || "", + arguments: toolCall.function?.arguments || "", + }) + } + } + } + + if (delta.content) { + yield { type: "text", text: delta.content } + } } - if (delta?.content) { - yield { type: "text", text: delta.content } + // When finish_reason is 'tool_calls', yield all accumulated tool calls + if (finishReason === "tool_calls" && toolCallAccumulator.size > 0) { + for (const toolCall of toolCallAccumulator.values()) { + yield { + type: "tool_call", + id: toolCall.id, + name: toolCall.name, + arguments: toolCall.arguments, + } + } + // Clear accumulator after yielding + toolCallAccumulator.clear() } if (chunk.usage) { diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 1c0e9ed640..979146b378 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -28,6 +28,16 @@ interface RequestyUsage extends OpenAI.CompletionUsage { total_cost?: number } +type RequestyChatCompletionParamsStreaming = OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & { + requesty?: { + trace_id?: string + extra?: { + mode?: string + } + } + thinking?: AnthropicReasoningParams +} + type RequestyChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & { requesty?: { trace_id?: string @@ -118,12 +128,17 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan ...convertToOpenAiMessages(messages), ] - const completionParams: RequestyChatCompletionParams = { + // Map extended efforts to OpenAI Chat Completions-accepted values (omit unsupported) + const allowedEffort = (["low", "medium", "high"] as const).includes(reasoning_effort as any) + ? (reasoning_effort as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming["reasoning_effort"]) + : undefined + + const completionParams: RequestyChatCompletionParamsStreaming = { messages: openAiMessages, model, max_tokens, temperature, - ...(reasoning_effort && reasoning_effort !== "minimal" && { reasoning_effort }), + ...(allowedEffort && { reasoning_effort: allowedEffort }), ...(thinking && { thinking }), stream: true, stream_options: { include_usage: true }, @@ -132,6 +147,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan let stream try { + // With streaming params type, SDK returns an async iterable stream stream = await this.client.chat.completions.create(completionParams) } catch (error) { throw handleOpenAIError(error, this.providerName) diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index 99965e1246..8c1cee939c 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -1,7 +1,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { rooDefaultModelId } from "@roo-code/types" +import { rooDefaultModelId, getApiProtocol } from "@roo-code/types" import { CloudService } from "@roo-code/cloud" import type { ApiHandlerOptions, ModelRecord } from "../../shared/api" @@ -100,6 +100,8 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { stream: true, stream_options: { include_usage: true }, ...(reasoning && { reasoning }), + ...(metadata?.tools && { tools: metadata.tools }), + ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), } try { @@ -124,9 +126,12 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { ) let lastUsage: RooUsage | undefined = undefined + // Accumulate tool calls by index - similar to how reasoning accumulates + const toolCallAccumulator = new Map() for await (const chunk of stream) { const delta = chunk.choices[0]?.delta + const finishReason = chunk.choices[0]?.finish_reason if (delta) { // Check for reasoning content (similar to OpenRouter) @@ -145,6 +150,28 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } } + // Check for tool calls in delta + if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { + for (const toolCall of delta.tool_calls) { + const index = toolCall.index + const existing = toolCallAccumulator.get(index) + + if (existing) { + // Accumulate arguments for existing tool call + if (toolCall.function?.arguments) { + existing.arguments += toolCall.function.arguments + } + } else { + // Start new tool call accumulation + toolCallAccumulator.set(index, { + id: toolCall.id || "", + name: toolCall.function?.name || "", + arguments: toolCall.function?.arguments || "", + }) + } + } + } + if (delta.content) { yield { type: "text", @@ -153,6 +180,20 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } } + // When finish_reason is 'tool_calls', yield all accumulated tool calls + if (finishReason === "tool_calls" && toolCallAccumulator.size > 0) { + for (const [index, toolCall] of toolCallAccumulator.entries()) { + yield { + type: "tool_call", + id: toolCall.id, + name: toolCall.name, + arguments: toolCall.arguments, + } + } + // Clear accumulator after yielding + toolCallAccumulator.clear() + } + if (chunk.usage) { lastUsage = chunk.usage as RooUsage } @@ -163,12 +204,25 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { const model = this.getModel() const isFreeModel = model.info.isFree ?? false + // Normalize input tokens based on protocol expectations: + // - OpenAI protocol expects TOTAL input tokens (cached + non-cached) + // - Anthropic protocol expects NON-CACHED input tokens (caches passed separately) + const modelId = model.id + const apiProtocol = getApiProtocol("roo", modelId) + + const promptTokens = lastUsage.prompt_tokens || 0 + const cacheWrite = lastUsage.cache_creation_input_tokens || 0 + const cacheRead = lastUsage.prompt_tokens_details?.cached_tokens || 0 + const nonCached = Math.max(0, promptTokens - cacheWrite - cacheRead) + + const inputTokensForDownstream = apiProtocol === "anthropic" ? nonCached : promptTokens + yield { type: "usage", - inputTokens: lastUsage.prompt_tokens || 0, + inputTokens: inputTokensForDownstream, outputTokens: lastUsage.completion_tokens || 0, - cacheWriteTokens: lastUsage.cache_creation_input_tokens, - cacheReadTokens: lastUsage.prompt_tokens_details?.cached_tokens, + cacheWriteTokens: cacheWrite, + cacheReadTokens: cacheRead, totalCost: isFreeModel ? 0 : (lastUsage.cost ?? 0), } } @@ -220,17 +274,21 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } // Return the requested model ID even if not found, with fallback info. + const fallbackInfo = { + maxTokens: 16_384, + contextWindow: 262_144, + supportsImages: false, + supportsReasoningEffort: false, + supportsPromptCache: true, + supportsNativeTools: false, + inputPrice: 0, + outputPrice: 0, + isFree: false, + } + return { id: modelId, - info: { - maxTokens: 16_384, - contextWindow: 262_144, - supportsImages: false, - supportsReasoningEffort: false, - supportsPromptCache: true, - inputPrice: 0, - outputPrice: 0, - }, + info: fallbackInfo, } } } diff --git a/src/api/providers/utils/timeout-config.ts b/src/api/providers/utils/timeout-config.ts index 94ddf4afc1..c9f2f0257c 100644 --- a/src/api/providers/utils/timeout-config.ts +++ b/src/api/providers/utils/timeout-config.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode" +import { Package } from "../../../shared/package" /** * Gets the API request timeout from VSCode configuration with validation. @@ -7,7 +8,7 @@ import * as vscode from "vscode" */ export function getApiRequestTimeout(): number { // Get timeout with validation to ensure it's a valid non-negative number - const configTimeout = vscode.workspace.getConfiguration("roo-cline").get("apiRequestTimeout", 600) + const configTimeout = vscode.workspace.getConfiguration(Package.name).get("apiRequestTimeout", 600) // Validate that it's actually a number and not NaN if (typeof configTimeout !== "number" || isNaN(configTimeout)) { diff --git a/src/api/transform/__tests__/gemini-format.spec.ts b/src/api/transform/__tests__/gemini-format.spec.ts index a9f0c15e9f..e30b01bd73 100644 --- a/src/api/transform/__tests__/gemini-format.spec.ts +++ b/src/api/transform/__tests__/gemini-format.spec.ts @@ -13,10 +13,12 @@ describe("convertAnthropicMessageToGemini", () => { const result = convertAnthropicMessageToGemini(anthropicMessage) - expect(result).toEqual({ - role: "user", - parts: [{ text: "Hello, world!" }], - }) + expect(result).toEqual([ + { + role: "user", + parts: [{ text: "Hello, world!" }], + }, + ]) }) it("should convert assistant role to model role", () => { @@ -27,10 +29,12 @@ describe("convertAnthropicMessageToGemini", () => { const result = convertAnthropicMessageToGemini(anthropicMessage) - expect(result).toEqual({ - role: "model", - parts: [{ text: "I'm an assistant" }], - }) + expect(result).toEqual([ + { + role: "model", + parts: [{ text: "I'm an assistant" }], + }, + ]) }) it("should convert a message with text blocks", () => { @@ -44,10 +48,12 @@ describe("convertAnthropicMessageToGemini", () => { const result = convertAnthropicMessageToGemini(anthropicMessage) - expect(result).toEqual({ - role: "user", - parts: [{ text: "First paragraph" }, { text: "Second paragraph" }], - }) + expect(result).toEqual([ + { + role: "user", + parts: [{ text: "First paragraph" }, { text: "Second paragraph" }], + }, + ]) }) it("should convert a message with an image", () => { @@ -68,18 +74,20 @@ describe("convertAnthropicMessageToGemini", () => { const result = convertAnthropicMessageToGemini(anthropicMessage) - expect(result).toEqual({ - role: "user", - parts: [ - { text: "Check out this image:" }, - { - inlineData: { - data: "base64encodeddata", - mimeType: "image/jpeg", + expect(result).toEqual([ + { + role: "user", + parts: [ + { text: "Check out this image:" }, + { + inlineData: { + data: "base64encodeddata", + mimeType: "image/jpeg", + }, }, - }, - ], - }) + ], + }, + ]) }) it("should throw an error for unsupported image source type", () => { @@ -115,21 +123,27 @@ describe("convertAnthropicMessageToGemini", () => { const result = convertAnthropicMessageToGemini(anthropicMessage) - expect(result).toEqual({ - role: "model", - parts: [ - { text: "Let me calculate that for you." }, - { - functionCall: { - name: "calculator", - args: { operation: "add", numbers: [2, 3] }, + expect(result).toEqual([ + { + role: "model", + parts: [ + { text: "Let me calculate that for you." }, + { + functionCall: { + name: "calculator", + args: { operation: "add", numbers: [2, 3] }, + }, + thoughtSignature: "skip_thought_signature_validator", }, - }, - ], - }) + ], + }, + ]) }) it("should convert a message with tool result as string", () => { + const toolIdToName = new Map() + toolIdToName.set("calculator-123", "calculator") + const anthropicMessage: Anthropic.Messages.MessageParam = { role: "user", content: [ @@ -142,23 +156,25 @@ describe("convertAnthropicMessageToGemini", () => { ], } - const result = convertAnthropicMessageToGemini(anthropicMessage) + const result = convertAnthropicMessageToGemini(anthropicMessage, { toolIdToName }) - expect(result).toEqual({ - role: "user", - parts: [ - { text: "Here's the result:" }, - { - functionResponse: { - name: "calculator", - response: { + expect(result).toEqual([ + { + role: "user", + parts: [ + { text: "Here's the result:" }, + { + functionResponse: { name: "calculator", - content: "The result is 5", + response: { + name: "calculator", + content: "The result is 5", + }, }, }, - }, - ], - }) + ], + }, + ]) }) it("should handle empty tool result content", () => { @@ -176,13 +192,13 @@ describe("convertAnthropicMessageToGemini", () => { const result = convertAnthropicMessageToGemini(anthropicMessage) // Should skip the empty tool result - expect(result).toEqual({ - role: "user", - parts: [], - }) + expect(result).toEqual([]) }) it("should convert a message with tool result as array with text only", () => { + const toolIdToName = new Map() + toolIdToName.set("search-123", "search") + const anthropicMessage: Anthropic.Messages.MessageParam = { role: "user", content: [ @@ -197,25 +213,30 @@ describe("convertAnthropicMessageToGemini", () => { ], } - const result = convertAnthropicMessageToGemini(anthropicMessage) + const result = convertAnthropicMessageToGemini(anthropicMessage, { toolIdToName }) - expect(result).toEqual({ - role: "user", - parts: [ - { - functionResponse: { - name: "search", - response: { + expect(result).toEqual([ + { + role: "user", + parts: [ + { + functionResponse: { name: "search", - content: "First result\n\nSecond result", + response: { + name: "search", + content: "First result\n\nSecond result", + }, }, }, - }, - ], - }) + ], + }, + ]) }) it("should convert a message with tool result as array with text and images", () => { + const toolIdToName = new Map() + toolIdToName.set("search-123", "search") + const anthropicMessage: Anthropic.Messages.MessageParam = { role: "user", content: [ @@ -245,37 +266,42 @@ describe("convertAnthropicMessageToGemini", () => { ], } - const result = convertAnthropicMessageToGemini(anthropicMessage) + const result = convertAnthropicMessageToGemini(anthropicMessage, { toolIdToName }) - expect(result).toEqual({ - role: "user", - parts: [ - { - functionResponse: { - name: "search", - response: { + expect(result).toEqual([ + { + role: "user", + parts: [ + { + functionResponse: { name: "search", - content: "Search results:\n\n(See next part for image)", + response: { + name: "search", + content: "Search results:\n\n(See next part for image)", + }, }, }, - }, - { - inlineData: { - data: "image1data", - mimeType: "image/png", + { + inlineData: { + data: "image1data", + mimeType: "image/png", + }, }, - }, - { - inlineData: { - data: "image2data", - mimeType: "image/jpeg", + { + inlineData: { + data: "image2data", + mimeType: "image/jpeg", + }, }, - }, - ], - }) + ], + }, + ]) }) it("should convert a message with tool result containing only images", () => { + const toolIdToName = new Map() + toolIdToName.set("imagesearch-123", "imagesearch") + const anthropicMessage: Anthropic.Messages.MessageParam = { role: "user", content: [ @@ -296,28 +322,102 @@ describe("convertAnthropicMessageToGemini", () => { ], } - const result = convertAnthropicMessageToGemini(anthropicMessage) + const result = convertAnthropicMessageToGemini(anthropicMessage, { toolIdToName }) - expect(result).toEqual({ - role: "user", - parts: [ - { - functionResponse: { - name: "imagesearch", - response: { + expect(result).toEqual([ + { + role: "user", + parts: [ + { + functionResponse: { name: "imagesearch", - content: "\n\n(See next part for image)", + response: { + name: "imagesearch", + content: "\n\n(See next part for image)", + }, }, }, - }, - { - inlineData: { - data: "onlyimagedata", - mimeType: "image/png", + { + inlineData: { + data: "onlyimagedata", + mimeType: "image/png", + }, }, + ], + }, + ]) + }) + + it("should handle tool names with hyphens using toolIdToName map", () => { + const toolIdToName = new Map() + toolIdToName.set("search-files-123", "search-files") + + const anthropicMessage: Anthropic.Messages.MessageParam = { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "search-files-123", + content: "found files", }, ], - }) + } + + const result = convertAnthropicMessageToGemini(anthropicMessage, { toolIdToName }) + + expect(result).toEqual([ + { + role: "user", + parts: [ + { + functionResponse: { + name: "search-files", + response: { + name: "search-files", + content: "found files", + }, + }, + }, + ], + }, + ]) + }) + + it("should throw error when toolIdToName map is not provided", () => { + const anthropicMessage: Anthropic.Messages.MessageParam = { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "calculator-123", + content: "result is 5", + }, + ], + } + + expect(() => convertAnthropicMessageToGemini(anthropicMessage)).toThrow( + 'Unable to find tool name for tool_use_id "calculator-123"', + ) + }) + + it("should throw error when tool_use_id is not in the map", () => { + const toolIdToName = new Map() + toolIdToName.set("other-tool-456", "other-tool") + + const anthropicMessage: Anthropic.Messages.MessageParam = { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "calculator-123", + content: "result is 5", + }, + ], + } + + expect(() => convertAnthropicMessageToGemini(anthropicMessage, { toolIdToName })).toThrow( + 'Unable to find tool name for tool_use_id "calculator-123"', + ) }) it("should throw an error for unsupported content block type", () => { diff --git a/src/api/transform/__tests__/model-params.spec.ts b/src/api/transform/__tests__/model-params.spec.ts index bd75e7eafb..b2d009b8c5 100644 --- a/src/api/transform/__tests__/model-params.spec.ts +++ b/src/api/transform/__tests__/model-params.spec.ts @@ -545,6 +545,79 @@ describe("getModelParams", () => { expect(result.reasoning).toEqual({ effort: "medium" }) }) + it("should include 'minimal' effort for openai format", () => { + const model: ModelInfo = { + ...baseModel, + // Array capability explicitly includes minimal + supportsReasoningEffort: ["minimal", "low", "medium", "high"] as any, + } + + const result = getModelParams({ + ...openaiParams, + settings: { reasoningEffort: "minimal" as any }, + model, + }) + + expect(result.reasoningEffort).toBe("minimal") + expect(result.reasoning).toEqual({ reasoning_effort: "minimal" }) + }) + + it("should include 'none' effort for openai format", () => { + const model: ModelInfo = { + ...baseModel, + // Array capability explicitly includes none + supportsReasoningEffort: ["none", "low", "medium", "high"] as any, + } + + const result = getModelParams({ + ...openaiParams, + settings: { reasoningEffort: "none" as any }, + model, + }) + + expect(result.reasoningEffort).toBe("none") + expect(result.reasoning).toEqual({ reasoning_effort: "none" }) + }) + + it("should omit reasoning for 'disable' selection", () => { + const model: ModelInfo = { + ...baseModel, + supportsReasoningEffort: true, + } + + const result = getModelParams({ + ...openaiParams, + settings: { reasoningEffort: "disable" as any }, + model, + }) + + expect(result.reasoningEffort).toBeUndefined() + expect(result.reasoning).toBeUndefined() + }) + + it("should include 'minimal' and 'none' for openrouter format", () => { + const model: ModelInfo = { + ...baseModel, + // Array capability explicitly includes both + supportsReasoningEffort: ["none", "minimal", "low", "medium", "high"] as any, + } + + const minimalRes = getModelParams({ + ...openrouterParams, + settings: { reasoningEffort: "minimal" as any }, + model, + }) + expect(minimalRes.reasoningEffort).toBe("minimal") + expect(minimalRes.reasoning).toEqual({ effort: "minimal" }) + + const noneRes = getModelParams({ + ...openrouterParams, + settings: { reasoningEffort: "none" as any }, + model, + }) + expect(noneRes.reasoningEffort).toBe("none") + expect(noneRes.reasoning).toEqual({ effort: "none" }) + }) it("should not use reasoning effort for anthropic format", () => { const model: ModelInfo = { ...baseModel, diff --git a/src/api/transform/__tests__/reasoning.spec.ts b/src/api/transform/__tests__/reasoning.spec.ts index ae565e9628..c1e6c6d5ce 100644 --- a/src/api/transform/__tests__/reasoning.spec.ts +++ b/src/api/transform/__tests__/reasoning.spec.ts @@ -7,11 +7,13 @@ import { getAnthropicReasoning, getOpenAiReasoning, getRooReasoning, + getGeminiReasoning, GetModelReasoningOptions, OpenRouterReasoningParams, AnthropicReasoningParams, OpenAiReasoningParams, RooReasoningParams, + GeminiReasoningParams, } from "../reasoning" describe("reasoning.ts", () => { @@ -529,7 +531,7 @@ describe("reasoning.ts", () => { const result = getOpenAiReasoning(optionsWithoutEffort) - expect(result).toEqual({ reasoning_effort: undefined }) + expect(result).toBeUndefined() }) it("should handle all reasoning effort values", () => { @@ -587,6 +589,61 @@ describe("reasoning.ts", () => { }) }) + describe("Gemini reasoning (effort models)", () => { + it("should return thinkingLevel when effort is set to low or high and budget is not used", () => { + const geminiModel: ModelInfo = { + ...baseModel, + // Effort-only reasoning model (no budget fields) + supportsReasoningEffort: ["low", "high"] as ModelInfo["supportsReasoningEffort"], + reasoningEffort: "low", + } + + const settings: ProviderSettings = { + apiProvider: "gemini", + enableReasoningEffort: true, + reasoningEffort: "high", + } + + const options: GetModelReasoningOptions = { + model: geminiModel, + reasoningBudget: 2048, + reasoningEffort: "high", + settings, + } + + const result = getGeminiReasoning(options) as GeminiReasoningParams | undefined + + // Budget should not be used for effort-only models + expect(result).toEqual({ thinkingLevel: "high", includeThoughts: true }) + }) + + it("should still return thinkingLevel when enableReasoningEffort is false but effort is explicitly set", () => { + const geminiModel: ModelInfo = { + ...baseModel, + // Effort-only reasoning model + supportsReasoningEffort: ["low", "high"] as ModelInfo["supportsReasoningEffort"], + reasoningEffort: "low", + } + + const settings: ProviderSettings = { + apiProvider: "gemini", + // Even with this flag false, an explicit effort selection should win + enableReasoningEffort: false, + reasoningEffort: "high", + } + + const options: GetModelReasoningOptions = { + model: geminiModel, + reasoningBudget: 2048, + reasoningEffort: "high", + settings, + } + + const result = getGeminiReasoning(options) as GeminiReasoningParams | undefined + expect(result).toEqual({ thinkingLevel: "high", includeThoughts: true }) + }) + }) + describe("Integration scenarios", () => { it("should handle model with requiredReasoningBudget across all providers", () => { const modelWithRequired: ModelInfo = { @@ -829,7 +886,7 @@ describe("reasoning.ts", () => { expect(result).toEqual({ enabled: false }) }) - it("should not return reasoning params for minimal effort", () => { + it("should omit reasoning params for minimal effort", () => { const modelWithSupported: ModelInfo = { ...baseModel, supportsReasoningEffort: true, diff --git a/src/api/transform/gemini-format.ts b/src/api/transform/gemini-format.ts index ee22cff32a..ffb8b8f789 100644 --- a/src/api/transform/gemini-format.ts +++ b/src/api/transform/gemini-format.ts @@ -1,12 +1,60 @@ import { Anthropic } from "@anthropic-ai/sdk" import { Content, Part } from "@google/genai" -export function convertAnthropicContentToGemini(content: string | Anthropic.ContentBlockParam[]): Part[] { +type ThoughtSignatureContentBlock = { + type: "thoughtSignature" + thoughtSignature?: string +} + +type ExtendedContentBlockParam = Anthropic.ContentBlockParam | ThoughtSignatureContentBlock +type ExtendedAnthropicContent = string | ExtendedContentBlockParam[] + +function isThoughtSignatureContentBlock(block: ExtendedContentBlockParam): block is ThoughtSignatureContentBlock { + return block.type === "thoughtSignature" +} + +export function convertAnthropicContentToGemini( + content: ExtendedAnthropicContent, + options?: { includeThoughtSignatures?: boolean; toolIdToName?: Map }, +): Part[] { + const includeThoughtSignatures = options?.includeThoughtSignatures ?? true + const toolIdToName = options?.toolIdToName + + // First pass: find thoughtSignature if it exists in the content blocks + let activeThoughtSignature: string | undefined + if (Array.isArray(content)) { + const sigBlock = content.find((block) => isThoughtSignatureContentBlock(block)) as ThoughtSignatureContentBlock + if (sigBlock?.thoughtSignature) { + activeThoughtSignature = sigBlock.thoughtSignature + } + } + + // Determine the signature to attach to function calls. + // If we're in a mode that expects signatures (includeThoughtSignatures is true): + // 1. Use the actual signature if we found one in the history/content. + // 2. Fallback to "skip_thought_signature_validator" if missing (e.g. cross-model history). + let functionCallSignature: string | undefined + if (includeThoughtSignatures) { + functionCallSignature = activeThoughtSignature || "skip_thought_signature_validator" + } + if (typeof content === "string") { return [{ text: content }] } return content.flatMap((block): Part | Part[] => { + // Handle thoughtSignature blocks first + if (isThoughtSignatureContentBlock(block)) { + if (includeThoughtSignatures && typeof block.thoughtSignature === "string") { + // The Google GenAI SDK currently exposes thoughtSignature as an + // extension field on Part; model it structurally without widening + // the upstream type. + return { thoughtSignature: block.thoughtSignature } as Part + } + // Explicitly omit thoughtSignature when not including it. + return [] + } + switch (block.type) { case "text": return { text: block.text } @@ -22,14 +70,26 @@ export function convertAnthropicContentToGemini(content: string | Anthropic.Cont name: block.name, args: block.input as Record, }, - } + // Inject the thoughtSignature into the functionCall part if required. + // This is necessary for Gemini 2.5/3+ thinking models to validate the tool call. + ...(functionCallSignature ? { thoughtSignature: functionCallSignature } : {}), + } as Part case "tool_result": { if (!block.content) { return [] } - // Extract tool name from tool_use_id (e.g., "calculator-123" -> "calculator") - const toolName = block.tool_use_id.split("-")[0] + // Get tool name from the map (built from tool_use blocks in message history). + // The map must contain the tool name - if it doesn't, this indicates a bug + // where the conversation history is incomplete or tool_use blocks are missing. + const toolName = toolIdToName?.get(block.tool_use_id) + if (!toolName) { + throw new Error( + `Unable to find tool name for tool_use_id "${block.tool_use_id}". ` + + `This indicates the conversation history is missing the corresponding tool_use block. ` + + `Available tool IDs: ${Array.from(toolIdToName?.keys() ?? []).join(", ") || "none"}`, + ) + } if (typeof block.content === "string") { return { @@ -70,9 +130,20 @@ export function convertAnthropicContentToGemini(content: string | Anthropic.Cont }) } -export function convertAnthropicMessageToGemini(message: Anthropic.Messages.MessageParam): Content { - return { - role: message.role === "assistant" ? "model" : "user", - parts: convertAnthropicContentToGemini(message.content), +export function convertAnthropicMessageToGemini( + message: Anthropic.Messages.MessageParam, + options?: { includeThoughtSignatures?: boolean; toolIdToName?: Map }, +): Content[] { + const parts = convertAnthropicContentToGemini(message.content, options) + + if (parts.length === 0) { + return [] } + + return [ + { + role: message.role === "assistant" ? "model" : "user", + parts, + }, + ] } diff --git a/src/api/transform/model-params.ts b/src/api/transform/model-params.ts index 933697c0a5..22b43ba8f5 100644 --- a/src/api/transform/model-params.ts +++ b/src/api/transform/model-params.ts @@ -2,7 +2,7 @@ import { type ModelInfo, type ProviderSettings, type VerbosityLevel, - type ReasoningEffortWithMinimal, + type ReasoningEffortExtended, ANTHROPIC_DEFAULT_MAX_TOKENS, } from "@roo-code/types" @@ -39,9 +39,10 @@ type GetModelParamsOptions = { type BaseModelParams = { maxTokens: number | undefined temperature: number | undefined - reasoningEffort: ReasoningEffortWithMinimal | undefined + reasoningEffort: ReasoningEffortExtended | undefined reasoningBudget: number | undefined verbosity: VerbosityLevel | undefined + tools?: boolean } type AnthropicModelParams = { @@ -129,8 +130,16 @@ export function getModelParams({ temperature = 1.0 } else if (shouldUseReasoningEffort({ model, settings })) { // "Traditional" reasoning models use the `reasoningEffort` parameter. - const effort = customReasoningEffort ?? model.reasoningEffort - reasoningEffort = effort as ReasoningEffortWithMinimal + const effort = (customReasoningEffort ?? model.reasoningEffort) as + | ReasoningEffortExtended + | "disable" + | undefined + // Capability and settings checks are handled by shouldUseReasoningEffort. + // Here we simply propagate the resolved effort into the params, while + // still treating "disable" as an omission. + if (effort && effort !== "disable") { + reasoningEffort = effort as ReasoningEffortExtended + } } const params: BaseModelParams = { maxTokens, temperature, reasoningEffort, reasoningBudget, verbosity } @@ -152,6 +161,7 @@ export function getModelParams({ format, ...params, reasoning: getOpenAiReasoning({ model, reasoningBudget, reasoningEffort, settings }), + tools: model.supportsNativeTools, } } else if (format === "gemini") { return { diff --git a/src/api/transform/reasoning.ts b/src/api/transform/reasoning.ts index 8d64fe46b1..07b3f80333 100644 --- a/src/api/transform/reasoning.ts +++ b/src/api/transform/reasoning.ts @@ -2,31 +2,33 @@ import { BetaThinkingConfigParam } from "@anthropic-ai/sdk/resources/beta" import OpenAI from "openai" import type { GenerateContentConfig } from "@google/genai" -import type { ModelInfo, ProviderSettings, ReasoningEffortWithMinimal } from "@roo-code/types" +import type { ModelInfo, ProviderSettings, ReasoningEffortExtended } from "@roo-code/types" import { shouldUseReasoningBudget, shouldUseReasoningEffort } from "../../shared/api" export type OpenRouterReasoningParams = { - effort?: ReasoningEffortWithMinimal + effort?: ReasoningEffortExtended max_tokens?: number exclude?: boolean } export type RooReasoningParams = { enabled?: boolean - effort?: ReasoningEffortWithMinimal + effort?: ReasoningEffortExtended } export type AnthropicReasoningParams = BetaThinkingConfigParam export type OpenAiReasoningParams = { reasoning_effort: OpenAI.Chat.ChatCompletionCreateParams["reasoning_effort"] } -export type GeminiReasoningParams = GenerateContentConfig["thinkingConfig"] +export type GeminiReasoningParams = GenerateContentConfig["thinkingConfig"] & { + thinkingLevel?: "low" | "high" +} export type GetModelReasoningOptions = { model: ModelInfo reasoningBudget: number | undefined - reasoningEffort: ReasoningEffortWithMinimal | undefined + reasoningEffort: ReasoningEffortExtended | "disable" | undefined settings: ProviderSettings } @@ -39,8 +41,8 @@ export const getOpenRouterReasoning = ({ shouldUseReasoningBudget({ model, settings }) ? { max_tokens: reasoningBudget } : shouldUseReasoningEffort({ model, settings }) - ? reasoningEffort - ? { effort: reasoningEffort } + ? reasoningEffort && reasoningEffort !== "disable" + ? { effort: reasoningEffort as ReasoningEffortExtended } : undefined : undefined @@ -50,28 +52,36 @@ export const getRooReasoning = ({ settings, }: GetModelReasoningOptions): RooReasoningParams | undefined => { // Check if model supports reasoning effort - if (!model.supportsReasoningEffort) { - return undefined - } + if (!model.supportsReasoningEffort) return undefined - // If enableReasoningEffort is explicitly false, return enabled: false + // Explicit off switch from settings: always send disabled for back-compat and to + // prevent automatic reasoning when the toggle is turned off. if (settings.enableReasoningEffort === false) { return { enabled: false } } - // If reasoning effort is provided, return it with enabled: true - if (reasoningEffort && reasoningEffort !== "minimal") { - return { enabled: true, effort: reasoningEffort } - } - - // If reasoningEffort is explicitly undefined (None selected), disable reasoning - // This ensures we explicitly tell the backend not to use reasoning - if (reasoningEffort === undefined) { + // For Roo models that support reasoning effort, absence of a selection should be + // treated as an explicit "off" signal so that the backend does not auto-enable + // reasoning. This aligns with the default behavior in tests. + if (!reasoningEffort) { return { enabled: false } } - // Default: no reasoning parameter (reasoning not enabled) - return undefined + // "disable" is a legacy sentinel that means "omit the reasoning field entirely" + // and let the server decide any defaults. + if (reasoningEffort === "disable") { + return undefined + } + + // For Roo, "minimal" is treated as "none" for effort-based reasoning – we omit + // the reasoning field entirely instead of sending an explicit effort. + if (reasoningEffort === "minimal") { + return undefined + } + + // When an effort is provided (e.g. "low" | "medium" | "high" | "none"), enable + // with the selected effort. + return { enabled: true, effort: reasoningEffort as ReasoningEffortExtended } } export const getAnthropicReasoning = ({ @@ -86,24 +96,44 @@ export const getOpenAiReasoning = ({ reasoningEffort, settings, }: GetModelReasoningOptions): OpenAiReasoningParams | undefined => { - if (!shouldUseReasoningEffort({ model, settings })) { - return undefined - } + if (!shouldUseReasoningEffort({ model, settings })) return undefined + if (reasoningEffort === "disable" || !reasoningEffort) return undefined - // If model has reasoning effort capability, return object even if effort is undefined - // This preserves the reasoning_effort field in the API call - if (reasoningEffort === "minimal") { - return undefined + // Include "none" | "minimal" | "low" | "medium" | "high" literally + return { + reasoning_effort: reasoningEffort as OpenAI.Chat.ChatCompletionCreateParams["reasoning_effort"], } - - return { reasoning_effort: reasoningEffort } } export const getGeminiReasoning = ({ model, reasoningBudget, + reasoningEffort, settings, -}: GetModelReasoningOptions): GeminiReasoningParams | undefined => - shouldUseReasoningBudget({ model, settings }) - ? { thinkingBudget: reasoningBudget!, includeThoughts: true } - : undefined +}: GetModelReasoningOptions): GeminiReasoningParams | undefined => { + // Budget-based (2.5) models: use thinkingBudget, not thinkingLevel. + if (shouldUseReasoningBudget({ model, settings })) { + return { thinkingBudget: reasoningBudget!, includeThoughts: true } + } + + // For effort-based Gemini models, rely directly on the selected effort value. + // We intentionally ignore enableReasoningEffort here so that explicitly chosen + // efforts in the UI (e.g. "High" for gemini-3-pro-preview) always translate + // into a thinkingConfig, regardless of legacy boolean flags. + const selectedEffort = (settings.reasoningEffort ?? model.reasoningEffort) as + | ReasoningEffortExtended + | "disable" + | undefined + + // Respect “off” / unset semantics from the effort selector itself. + if (!selectedEffort || selectedEffort === "disable") { + return undefined + } + + // Effort-based models on Google GenAI currently support only explicit low/high levels. + if (selectedEffort !== "low" && selectedEffort !== "high") { + return undefined + } + + return { thinkingLevel: selectedEffort, includeThoughts: true } +} diff --git a/src/api/transform/stream.ts b/src/api/transform/stream.ts index 8484e62595..cd6c3a56a7 100644 --- a/src/api/transform/stream.ts +++ b/src/api/transform/stream.ts @@ -5,6 +5,7 @@ export type ApiStreamChunk = | ApiStreamUsageChunk | ApiStreamReasoningChunk | ApiStreamGroundingChunk + | ApiStreamToolCallChunk | ApiStreamError export interface ApiStreamError { @@ -38,6 +39,13 @@ export interface ApiStreamGroundingChunk { sources: GroundingSource[] } +export interface ApiStreamToolCallChunk { + type: "tool_call" + id: string + name: string + arguments: string +} + export interface GroundingSource { title: string url: string diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts new file mode 100644 index 0000000000..c463d4a5cd --- /dev/null +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -0,0 +1,312 @@ +import { type ToolName, toolNames, type FileEntry } from "@roo-code/types" +import { type ToolUse, type ToolParamName, toolParamNames, type NativeToolArgs } from "../../shared/tools" + +/** + * Helper type to extract properly typed native arguments for a given tool. + * Returns the type from NativeToolArgs if the tool is defined there, otherwise never. + */ +type NativeArgsFor = TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + +/** + * Parser for native tool calls (OpenAI-style function calling). + * Converts native tool call format to ToolUse format for compatibility + * with existing tool execution infrastructure. + * + * For tools with refactored parsers (e.g., read_file), this parser provides + * typed arguments via nativeArgs. Tool-specific handlers should consume + * nativeArgs directly rather than relying on synthesized legacy params. + */ +export class NativeToolCallParser { + /** + * Convert a native tool call chunk to a ToolUse object. + * + * @param toolCall - The native tool call from the API stream + * @returns A properly typed ToolUse object + */ + public static parseToolCall(toolCall: { + id: string + name: TName + arguments: string + }): ToolUse | null { + // Check if this is a dynamic MCP tool (mcp_serverName_toolName) + if (typeof toolCall.name === "string" && toolCall.name.startsWith("mcp_")) { + return this.parseDynamicMcpTool(toolCall) as ToolUse | null + } + + // Validate tool name + if (!toolNames.includes(toolCall.name as ToolName)) { + console.error(`Invalid tool name: ${toolCall.name}`) + console.error(`Valid tool names:`, toolNames) + return null + } + + try { + // Parse the arguments JSON string + const args = JSON.parse(toolCall.arguments) + + // Build legacy params object for backward compatibility with XML protocol and UI. + // Native execution path uses nativeArgs instead, which has proper typing. + const params: Partial> = {} + + for (const [key, value] of Object.entries(args)) { + // Skip complex parameters that have been migrated to nativeArgs. + // For read_file, the 'files' parameter is a FileEntry[] array that can't be + // meaningfully stringified. The properly typed data is in nativeArgs instead. + if (toolCall.name === "read_file" && key === "files") { + continue + } + + // Validate parameter name + if (!toolParamNames.includes(key as ToolParamName)) { + console.warn(`Unknown parameter '${key}' for tool '${toolCall.name}'`) + console.warn(`Valid param names:`, toolParamNames) + continue + } + + // Convert to string for legacy params format + const stringValue = typeof value === "string" ? value : JSON.stringify(value) + params[key as ToolParamName] = stringValue + } + + // Build typed nativeArgs for tools that support it. + // This switch statement serves two purposes: + // 1. Validation: Ensures required parameters are present before constructing nativeArgs + // 2. Transformation: Converts raw JSON to properly typed structures + // + // Each case validates the minimum required parameters and constructs a properly typed + // nativeArgs object. If validation fails, nativeArgs remains undefined and the tool + // will fall back to legacy parameter parsing if supported. + let nativeArgs: NativeArgsFor | undefined = undefined + + switch (toolCall.name) { + case "read_file": + if (args.files && Array.isArray(args.files)) { + nativeArgs = { files: args.files } as NativeArgsFor + } + break + + case "attempt_completion": + if (args.result) { + nativeArgs = { result: args.result } as NativeArgsFor + } + break + + case "execute_command": + if (args.command) { + nativeArgs = { + command: args.command, + cwd: args.cwd, + } as NativeArgsFor + } + break + + case "insert_content": + if (args.path !== undefined && args.line !== undefined && args.content !== undefined) { + nativeArgs = { + path: args.path, + line: typeof args.line === "number" ? args.line : parseInt(String(args.line), 10), + content: args.content, + } as NativeArgsFor + } + break + + case "apply_diff": + if (args.path !== undefined && args.diff !== undefined) { + nativeArgs = { + path: args.path, + diff: args.diff, + } as NativeArgsFor + } + break + + case "ask_followup_question": + if (args.question !== undefined && args.follow_up !== undefined) { + nativeArgs = { + question: args.question, + follow_up: args.follow_up, + } as NativeArgsFor + } + break + + case "browser_action": + if (args.action !== undefined) { + nativeArgs = { + action: args.action, + url: args.url, + coordinate: args.coordinate, + size: args.size, + text: args.text, + } as NativeArgsFor + } + break + + case "codebase_search": + if (args.query !== undefined) { + nativeArgs = { + query: args.query, + path: args.path, + } as NativeArgsFor + } + break + + case "fetch_instructions": + if (args.task !== undefined) { + nativeArgs = { + task: args.task, + } as NativeArgsFor + } + break + + case "generate_image": + if (args.prompt !== undefined && args.path !== undefined) { + nativeArgs = { + prompt: args.prompt, + path: args.path, + image: args.image, + } as NativeArgsFor + } + break + + case "list_code_definition_names": + if (args.path !== undefined) { + nativeArgs = { + path: args.path, + } as NativeArgsFor + } + break + + case "run_slash_command": + if (args.command !== undefined) { + nativeArgs = { + command: args.command, + args: args.args, + } as NativeArgsFor + } + break + + case "search_files": + if (args.path !== undefined && args.regex !== undefined) { + nativeArgs = { + path: args.path, + regex: args.regex, + file_pattern: args.file_pattern, + } as NativeArgsFor + } + break + + case "switch_mode": + if (args.mode_slug !== undefined && args.reason !== undefined) { + nativeArgs = { + mode_slug: args.mode_slug, + reason: args.reason, + } as NativeArgsFor + } + break + + case "update_todo_list": + if (args.todos !== undefined) { + nativeArgs = { + todos: args.todos, + } as NativeArgsFor + } + break + + case "write_to_file": + if (args.path !== undefined && args.content !== undefined && args.line_count !== undefined) { + nativeArgs = { + path: args.path, + content: args.content, + line_count: + typeof args.line_count === "number" + ? args.line_count + : parseInt(String(args.line_count), 10), + } as NativeArgsFor + } + break + + case "use_mcp_tool": + if (args.server_name !== undefined && args.tool_name !== undefined) { + nativeArgs = { + server_name: args.server_name, + tool_name: args.tool_name, + arguments: args.arguments, + } as NativeArgsFor + } + break + + default: + break + } + + const result: ToolUse = { + type: "tool_use" as const, + name: toolCall.name, + params, + partial: false, // Native tool calls are always complete when yielded + nativeArgs, + } + + return result + } catch (error) { + console.error(`Failed to parse tool call arguments:`, error) + console.error(`Error details:`, error instanceof Error ? error.message : String(error)) + return null + } + } + + /** + * Parse dynamic MCP tools (named mcp_serverName_toolName). + * These are generated dynamically by getMcpServerTools() and need to be + * converted back to use_mcp_tool format. + */ + private static parseDynamicMcpTool(toolCall: { + id: string + name: string + arguments: string + }): ToolUse<"use_mcp_tool"> | null { + try { + const args = JSON.parse(toolCall.arguments) + + // Extract server_name and tool_name from the arguments + // The dynamic tool schema includes these as const properties + const serverName = args.server_name + const toolName = args.tool_name + const toolInputProps = args.toolInputProps + + if (!serverName || !toolName) { + console.error(`Missing server_name or tool_name in dynamic MCP tool`) + return null + } + + // Build params for backward compatibility with XML protocol + const params: Partial> = { + server_name: serverName, + tool_name: toolName, + } + + if (toolInputProps) { + params.arguments = JSON.stringify(toolInputProps) + } + + // Build nativeArgs with properly typed structure + const nativeArgs: NativeToolArgs["use_mcp_tool"] = { + server_name: serverName, + tool_name: toolName, + arguments: toolInputProps, + } + + const result: ToolUse<"use_mcp_tool"> = { + type: "tool_use" as const, + name: "use_mcp_tool", + params, + partial: false, + nativeArgs, + } + + return result + } catch (error) { + console.error(`Failed to parse dynamic MCP tool:`, error) + return null + } + } +} diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts new file mode 100644 index 0000000000..b78f184251 --- /dev/null +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts @@ -0,0 +1,205 @@ +// npx vitest src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts + +import { describe, it, expect, beforeEach, vi } from "vitest" +import { Anthropic } from "@anthropic-ai/sdk" +import { presentAssistantMessage } from "../presentAssistantMessage" +import { Task } from "../../task/Task" +import { TOOL_PROTOCOL } from "@roo-code/types" + +// Mock dependencies +vi.mock("../../task/Task") +vi.mock("../../tools/validateToolUse", () => ({ + validateToolUse: vi.fn(), +})) +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureToolUsage: vi.fn(), + captureConsecutiveMistakeError: vi.fn(), + }, + }, +})) + +describe("presentAssistantMessage - Image Handling in Native Tool Calls", () => { + let mockTask: any + + beforeEach(() => { + // Create a mock Task with minimal properties needed for testing + mockTask = { + taskId: "test-task-id", + instanceId: "test-instance", + abort: false, + presentAssistantMessageLocked: false, + presentAssistantMessageHasPendingUpdates: false, + currentStreamingContentIndex: 0, + assistantMessageContent: [], + userMessageContent: [], + didCompleteReadingStream: false, + didRejectTool: false, + didAlreadyUseTool: false, + diffEnabled: false, + consecutiveMistakeCount: 0, + api: { + getModel: () => ({ id: "test-model", info: {} }), + }, + browserSession: { + closeBrowser: vi.fn().mockResolvedValue(undefined), + }, + recordToolUsage: vi.fn(), + toolRepetitionDetector: { + check: vi.fn().mockReturnValue({ allowExecution: true }), + }, + providerRef: { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + }), + }), + }, + say: vi.fn().mockResolvedValue(undefined), + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + } + }) + + it("should preserve images in tool_result for native protocol", async () => { + // Set up a tool_use block with an ID (indicates native protocol) + const toolCallId = "tool_call_123" + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: toolCallId, // ID indicates native protocol + name: "ask_followup_question", + params: { question: "What do you see?" }, + }, + ] + + // Create a mock askApproval that includes images in the response + const imageBlock: Anthropic.ImageBlockParam = { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: "base64ImageData", + }, + } + + mockTask.ask = vi.fn().mockResolvedValue({ + response: "yesButtonClicked", + text: "I see a cat", + images: ["data:image/png;base64,base64ImageData"], + }) + + // Execute presentAssistantMessage + await presentAssistantMessage(mockTask) + + // Verify that userMessageContent was populated + expect(mockTask.userMessageContent.length).toBeGreaterThan(0) + + // Find the tool_result block + const toolResult = mockTask.userMessageContent.find( + (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, + ) + + expect(toolResult).toBeDefined() + expect(toolResult.tool_use_id).toBe(toolCallId) + + // For native protocol, tool_result content should be a string (text only) + expect(typeof toolResult.content).toBe("string") + expect(toolResult.content).toContain("I see a cat") + + // Images should be added as separate blocks AFTER the tool_result + const imageBlocks = mockTask.userMessageContent.filter((item: any) => item.type === "image") + expect(imageBlocks.length).toBeGreaterThan(0) + expect(imageBlocks[0].source.data).toBe("base64ImageData") + }) + + it("should convert to string when no images are present (native protocol)", async () => { + // Set up a tool_use block with an ID (indicates native protocol) + const toolCallId = "tool_call_456" + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: toolCallId, + name: "ask_followup_question", + params: { question: "What is your name?" }, + }, + ] + + // Response with text but NO images + mockTask.ask = vi.fn().mockResolvedValue({ + response: "yesButtonClicked", + text: "My name is Alice", + images: undefined, + }) + + await presentAssistantMessage(mockTask) + + const toolResult = mockTask.userMessageContent.find( + (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, + ) + + expect(toolResult).toBeDefined() + + // When no images, content should be a string + expect(typeof toolResult.content).toBe("string") + }) + + it("should preserve images in content array for XML protocol (existing behavior)", async () => { + // Set up a tool_use block WITHOUT an ID (indicates XML protocol) + mockTask.assistantMessageContent = [ + { + type: "tool_use", + // No ID = XML protocol + name: "ask_followup_question", + params: { question: "What do you see?" }, + }, + ] + + mockTask.ask = vi.fn().mockResolvedValue({ + response: "yesButtonClicked", + text: "I see a dog", + images: ["data:image/png;base64,dogImageData"], + }) + + await presentAssistantMessage(mockTask) + + // For XML protocol, content is added as separate blocks + // Check that both text and image blocks were added + const hasTextBlock = mockTask.userMessageContent.some((item: any) => item.type === "text") + const hasImageBlock = mockTask.userMessageContent.some((item: any) => item.type === "image") + + expect(hasTextBlock).toBe(true) + // XML protocol preserves images as separate blocks in userMessageContent + expect(hasImageBlock).toBe(true) + }) + + it("should handle empty tool result gracefully", async () => { + const toolCallId = "tool_call_789" + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: toolCallId, + name: "attempt_completion", + params: { result: "Task completed" }, + }, + ] + + // Empty response + mockTask.ask = vi.fn().mockResolvedValue({ + response: "yesButtonClicked", + text: undefined, + images: undefined, + }) + + await presentAssistantMessage(mockTask) + + const toolResult = mockTask.userMessageContent.find( + (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, + ) + + expect(toolResult).toBeDefined() + // Should have fallback text + expect(toolResult.content).toBeTruthy() + }) +}) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 2249c008d6..fcf7d25cc9 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -1,41 +1,45 @@ import cloneDeep from "clone-deep" import { serializeError } from "serialize-error" +import { Anthropic } from "@anthropic-ai/sdk" import type { ToolName, ClineAsk, ToolProgressStatus } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { defaultModeSlug, getModeBySlug } from "../../shared/modes" -import type { ToolParamName, ToolResponse } from "../../shared/tools" +import type { ToolParamName, ToolResponse, ToolUse } from "../../shared/tools" +import { Package } from "../../shared/package" -import { fetchInstructionsTool } from "../tools/fetchInstructionsTool" -import { listFilesTool } from "../tools/listFilesTool" -import { getReadFileToolDescription, readFileTool } from "../tools/readFileTool" +import { fetchInstructionsTool } from "../tools/FetchInstructionsTool" +import { listFilesTool } from "../tools/ListFilesTool" +import { readFileTool } from "../tools/ReadFileTool" import { getSimpleReadFileToolDescription, simpleReadFileTool } from "../tools/simpleReadFileTool" -import { shouldUseSingleFileRead } from "@roo-code/types" -import { writeToFileTool } from "../tools/writeToFileTool" -import { applyDiffTool } from "../tools/multiApplyDiffTool" -import { insertContentTool } from "../tools/insertContentTool" -import { listCodeDefinitionNamesTool } from "../tools/listCodeDefinitionNamesTool" -import { searchFilesTool } from "../tools/searchFilesTool" -import { browserActionTool } from "../tools/browserActionTool" -import { executeCommandTool } from "../tools/executeCommandTool" -import { useMcpToolTool } from "../tools/useMcpToolTool" +import { shouldUseSingleFileRead, TOOL_PROTOCOL } from "@roo-code/types" +import { writeToFileTool } from "../tools/WriteToFileTool" +import { applyDiffTool } from "../tools/MultiApplyDiffTool" +import { insertContentTool } from "../tools/InsertContentTool" +import { listCodeDefinitionNamesTool } from "../tools/ListCodeDefinitionNamesTool" +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" -import { askFollowupQuestionTool } from "../tools/askFollowupQuestionTool" -import { switchModeTool } from "../tools/switchModeTool" -import { attemptCompletionTool } from "../tools/attemptCompletionTool" -import { newTaskTool } from "../tools/newTaskTool" +import { askFollowupQuestionTool } from "../tools/AskFollowupQuestionTool" +import { switchModeTool } from "../tools/SwitchModeTool" +import { attemptCompletionTool, AttemptCompletionCallbacks } from "../tools/AttemptCompletionTool" +import { newTaskTool } from "../tools/NewTaskTool" -import { updateTodoListTool } from "../tools/updateTodoListTool" -import { runSlashCommandTool } from "../tools/runSlashCommandTool" -import { generateImageTool } from "../tools/generateImageTool" +import { updateTodoListTool } from "../tools/UpdateTodoListTool" +import { runSlashCommandTool } from "../tools/RunSlashCommandTool" +import { generateImageTool } from "../tools/GenerateImageTool" import { formatResponse } from "../prompts/responses" import { validateToolUse } from "../tools/validateToolUse" import { Task } from "../task/Task" -import { codebaseSearchTool } from "../tools/codebaseSearchTool" +import { codebaseSearchTool } from "../tools/CodebaseSearchTool" import { experiments, EXPERIMENT_IDS } from "../../shared/experiments" -import { applyDiffToolLegacy } from "../tools/applyDiffTool" +import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool" +import { isNativeProtocol } from "@roo-code/types" +import { resolveToolProtocol } from "../../utils/resolveToolProtocol" /** * Processes and presents assistant message content to the user interface. @@ -80,7 +84,18 @@ export async function presentAssistantMessage(cline: Task) { return } - const block = cloneDeep(cline.assistantMessageContent[cline.currentStreamingContentIndex]) // need to create copy bc while stream is updating the array, it could be updating the reference block properties too + let block: any + try { + block = cloneDeep(cline.assistantMessageContent[cline.currentStreamingContentIndex]) // need to create copy bc while stream is updating the array, it could be updating the reference block properties too + } catch (error) { + console.error(`ERROR cloning block:`, error) + console.error( + `Block content:`, + JSON.stringify(cline.assistantMessageContent[cline.currentStreamingContentIndex], null, 2), + ) + cline.presentAssistantMessageLocked = false + return + } switch (block.type) { case "text": { @@ -163,7 +178,12 @@ export async function presentAssistantMessage(cline: Task) { if (shouldUseSingleFileRead(modelId)) { return getSimpleReadFileToolDescription(block.name, block.params) } else { - return getReadFileToolDescription(block.name, block.params) + // Prefer native typed args when available; fall back to legacy params + // Check if nativeArgs exists (native protocol) + if (block.nativeArgs) { + return readFileTool.getReadFileToolDescription(block.name, block.nativeArgs) + } + return readFileTool.getReadFileToolDescription(block.name, block.params) } case "fetch_instructions": return `[${block.name} for '${block.params.task}']` @@ -224,6 +244,8 @@ export async function presentAssistantMessage(cline: Task) { return `[${block.name} for '${block.params.command}'${block.params.args ? ` with args: ${block.params.args}` : ""}]` case "generate_image": return `[${block.name} for '${block.params.path}']` + default: + return `[${block.name}]` } } @@ -255,13 +277,68 @@ export async function presentAssistantMessage(cline: Task) { break } - const pushToolResult = (content: ToolResponse) => { - cline.userMessageContent.push({ type: "text", text: `${toolDescription()} Result:` }) + // Track if we've already pushed a tool result for this tool call (native protocol only) + let hasToolResult = false - if (typeof content === "string") { - cline.userMessageContent.push({ type: "text", text: content || "(tool did not return anything)" }) + // Determine protocol by checking if this tool call has an ID. + // Native protocol tool calls ALWAYS have an ID (set when parsed from tool_call chunks). + // XML protocol tool calls NEVER have an ID (parsed from XML text). + const toolCallId = (block as any).id + const toolProtocol = toolCallId ? TOOL_PROTOCOL.NATIVE : TOOL_PROTOCOL.XML + + const pushToolResult = (content: ToolResponse) => { + if (toolProtocol === TOOL_PROTOCOL.NATIVE) { + // For native protocol, only allow ONE tool_result per tool call + if (hasToolResult) { + console.warn( + `[presentAssistantMessage] Skipping duplicate tool_result for tool_use_id: ${toolCallId}`, + ) + return + } + + // For native protocol, tool_result content must be a string + // Images are added as separate blocks in the user message + let resultContent: string + let imageBlocks: Anthropic.ImageBlockParam[] = [] + + if (typeof content === "string") { + resultContent = content || "(tool did not return anything)" + } else { + // Separate text and image blocks + const textBlocks = content.filter((item) => item.type === "text") + imageBlocks = content.filter((item) => item.type === "image") as Anthropic.ImageBlockParam[] + + // Convert text blocks to string for tool_result + resultContent = + textBlocks.map((item) => (item as Anthropic.TextBlockParam).text).join("\n") || + "(tool did not return anything)" + } + + // Add tool_result with text content only + cline.userMessageContent.push({ + type: "tool_result", + tool_use_id: toolCallId, + content: resultContent, + } as Anthropic.ToolResultBlockParam) + + // Add image blocks separately after tool_result + if (imageBlocks.length > 0) { + cline.userMessageContent.push(...imageBlocks) + } + + hasToolResult = true } else { - cline.userMessageContent.push(...content) + // For XML protocol, add as text blocks (legacy behavior) + cline.userMessageContent.push({ type: "text", text: `${toolDescription()} Result:` }) + + if (typeof content === "string") { + cline.userMessageContent.push({ + type: "text", + text: content || "(tool did not return anything)", + }) + } else { + cline.userMessageContent.push(...content) + } } // Once a tool result has been collected, ignore all other tool @@ -288,9 +365,14 @@ export async function presentAssistantMessage(cline: Task) { // Handle both messageResponse and noButtonClicked with text. if (text) { await cline.say("user_feedback", text, images) - pushToolResult(formatResponse.toolResult(formatResponse.toolDeniedWithFeedback(text), images)) + pushToolResult( + formatResponse.toolResult( + formatResponse.toolDeniedWithFeedback(text, toolProtocol), + images, + ), + ) } else { - pushToolResult(formatResponse.toolDenied()) + pushToolResult(formatResponse.toolDenied(toolProtocol)) } cline.didRejectTool = true return false @@ -299,7 +381,9 @@ export async function presentAssistantMessage(cline: Task) { // Handle yesButtonClicked with text. if (text) { await cline.say("user_feedback", text, images) - pushToolResult(formatResponse.toolResult(formatResponse.toolApprovedWithFeedback(text), images)) + pushToolResult( + formatResponse.toolResult(formatResponse.toolApprovedWithFeedback(text, toolProtocol), images), + ) } return true @@ -322,7 +406,7 @@ export async function presentAssistantMessage(cline: Task) { `Error ${action}:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`, ) - pushToolResult(formatResponse.toolError(errorString)) + pushToolResult(formatResponse.toolError(errorString, toolProtocol)) } // If block is partial, remove partial closing tag so its not @@ -358,7 +442,7 @@ export async function presentAssistantMessage(cline: Task) { if (!block.partial) { cline.recordToolUsage(block.name) - TelemetryService.instance.captureToolUsage(cline.taskId, block.name) + TelemetryService.instance.captureToolUsage(cline.taskId, block.name, toolProtocol) } // Validate tool use before execution. @@ -374,7 +458,7 @@ export async function presentAssistantMessage(cline: Task) { ) } catch (error) { cline.consecutiveMistakeCount++ - pushToolResult(formatResponse.toolError(error.message)) + pushToolResult(formatResponse.toolError(error.message, toolProtocol)) break } @@ -413,6 +497,7 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult( formatResponse.toolError( `Tool call repetition limit reached for ${block.name}. Please try a different approach.`, + toolProtocol, ), ) break @@ -422,12 +507,39 @@ export async function presentAssistantMessage(cline: Task) { switch (block.name) { case "write_to_file": await checkpointSaveAndMark(cline) - await writeToFileTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) break case "update_todo_list": - await updateTodoListTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) break case "apply_diff": { + await checkpointSaveAndMark(cline) + + // Check if this tool call came from native protocol by checking for ID + // Native calls always have IDs, XML calls never do + if (toolProtocol === TOOL_PROTOCOL.NATIVE) { + await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) + break + } + // Get the provider and state to check experiment settings const provider = cline.providerRef.deref() let isMultiFileApplyDiffEnabled = false @@ -441,24 +553,27 @@ export async function presentAssistantMessage(cline: Task) { } if (isMultiFileApplyDiffEnabled) { - await checkpointSaveAndMark(cline) await applyDiffTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) } else { - await checkpointSaveAndMark(cline) - await applyDiffToolLegacy( - cline, - block, + await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { askApproval, handleError, pushToolResult, removeClosingTag, - ) + toolProtocol, + }) } break } case "insert_content": await checkpointSaveAndMark(cline) - await insertContentTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await insertContentTool.handle(cline, block as ToolUse<"insert_content">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) break case "read_file": // Check if this model should use the simplified single-file read tool @@ -471,41 +586,90 @@ export async function presentAssistantMessage(cline: Task) { handleError, pushToolResult, removeClosingTag, + toolProtocol, ) } else { - await readFileTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + // Type assertion is safe here because we're in the "read_file" case + await readFileTool.handle(cline, block as ToolUse<"read_file">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) } break case "fetch_instructions": - await fetchInstructionsTool(cline, block, askApproval, handleError, pushToolResult) - break - case "list_files": - await listFilesTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) - break - case "codebase_search": - await codebaseSearchTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) - break - case "list_code_definition_names": - await listCodeDefinitionNamesTool( - cline, - block, + await fetchInstructionsTool.handle(cline, block as ToolUse<"fetch_instructions">, { askApproval, handleError, pushToolResult, removeClosingTag, - ) + toolProtocol, + }) + break + case "list_files": + await listFilesTool.handle(cline, block as ToolUse<"list_files">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) + break + case "codebase_search": + await codebaseSearchTool.handle(cline, block as ToolUse<"codebase_search">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) + break + case "list_code_definition_names": + await listCodeDefinitionNamesTool.handle(cline, block as ToolUse<"list_code_definition_names">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) break case "search_files": - await searchFilesTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await searchFilesTool.handle(cline, block as ToolUse<"search_files">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) break case "browser_action": - await browserActionTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await browserActionTool.handle(cline, block as ToolUse<"browser_action">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) break case "execute_command": - await executeCommandTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await executeCommandTool.handle(cline, block as ToolUse<"execute_command">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) break case "use_mcp_tool": - await useMcpToolTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) break case "access_mcp_resource": await accessMcpResourceTool( @@ -518,38 +682,67 @@ export async function presentAssistantMessage(cline: Task) { ) break case "ask_followup_question": - await askFollowupQuestionTool( - cline, - block, + await askFollowupQuestionTool.handle(cline, block as ToolUse<"ask_followup_question">, { askApproval, handleError, pushToolResult, removeClosingTag, - ) + toolProtocol, + }) break case "switch_mode": - await switchModeTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) - break - case "new_task": - await newTaskTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) - break - case "attempt_completion": - await attemptCompletionTool( - cline, - block, + await switchModeTool.handle(cline, block as ToolUse<"switch_mode">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) + break + case "new_task": + await newTaskTool.handle(cline, block as ToolUse<"new_task">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) + break + case "attempt_completion": { + const completionCallbacks: AttemptCompletionCallbacks = { askApproval, handleError, pushToolResult, removeClosingTag, - toolDescription, askFinishSubTaskApproval, + toolDescription, + toolProtocol, + } + await attemptCompletionTool.handle( + cline, + block as ToolUse<"attempt_completion">, + completionCallbacks, ) break + } case "run_slash_command": - await runSlashCommandTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await runSlashCommandTool.handle(cline, block as ToolUse<"run_slash_command">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) break case "generate_image": - await generateImageTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await checkpointSaveAndMark(cline) + await generateImageTool.handle(cline, block as ToolUse<"generate_image">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) break } @@ -596,6 +789,12 @@ export async function presentAssistantMessage(cline: Task) { // this function ourselves. presentAssistantMessage(cline) return + } else { + // CRITICAL FIX: If we're out of bounds and the stream is complete, set userMessageContentReady + // This handles the case where assistantMessageContent is empty or becomes empty after processing + if (cline.didCompleteReadingStream) { + cline.userMessageContentReady = true + } } } diff --git a/src/core/sliding-window/__tests__/sliding-window.spec.ts b/src/core/context-management/__tests__/context-management.spec.ts similarity index 95% rename from src/core/sliding-window/__tests__/sliding-window.spec.ts rename to src/core/context-management/__tests__/context-management.spec.ts index 0f2c70c81b..a2c6daec72 100644 --- a/src/core/sliding-window/__tests__/sliding-window.spec.ts +++ b/src/core/context-management/__tests__/context-management.spec.ts @@ -1,4 +1,4 @@ -// npx vitest src/core/sliding-window/__tests__/sliding-window.spec.ts +// cd src && npx vitest run core/context-management/__tests__/context-management.spec.ts import { Anthropic } from "@anthropic-ai/sdk" @@ -9,12 +9,7 @@ import { BaseProvider } from "../../../api/providers/base-provider" import { ApiMessage } from "../../task-persistence/apiMessages" import * as condenseModule from "../../condense" -import { - TOKEN_BUFFER_PERCENTAGE, - estimateTokenCount, - truncateConversation, - truncateConversationIfNeeded, -} from "../index" +import { TOKEN_BUFFER_PERCENTAGE, estimateTokenCount, truncateConversation, manageContext } from "../index" // Create a mock ApiHandler for testing class MockApiHandler extends BaseProvider { @@ -49,7 +44,7 @@ class MockApiHandler extends BaseProvider { const mockApiHandler = new MockApiHandler() const taskId = "test-task-id" -describe("Sliding Window", () => { +describe("Context Management", () => { beforeEach(() => { if (!TelemetryService.hasInstance()) { TelemetryService.createInstance([]) @@ -234,9 +229,9 @@ describe("Sliding Window", () => { }) /** - * Tests for the truncateConversationIfNeeded function + * Tests for the manageContext function */ - describe("truncateConversationIfNeeded", () => { + describe("manageContext", () => { const createModelInfo = (contextWindow: number, maxTokens?: number): ModelInfo => ({ contextWindow, supportsPromptCache: true, @@ -261,7 +256,7 @@ describe("Sliding Window", () => { { ...messages[messages.length - 1], content: "" }, ] - const result = await truncateConversationIfNeeded({ + const result = await manageContext({ messages: messagesWithSmallContent, totalTokens, contextWindow: modelInfo.contextWindow, @@ -302,7 +297,7 @@ describe("Sliding Window", () => { messagesWithSmallContent[4], ] - const result = await truncateConversationIfNeeded({ + const result = await manageContext({ messages: messagesWithSmallContent, totalTokens, contextWindow: modelInfo.contextWindow, @@ -337,7 +332,7 @@ describe("Sliding Window", () => { // Test below threshold const belowThreshold = 69999 - const result1 = await truncateConversationIfNeeded({ + const result1 = await manageContext({ messages: messagesWithSmallContent, totalTokens: belowThreshold, contextWindow: modelInfo1.contextWindow, @@ -351,7 +346,7 @@ describe("Sliding Window", () => { currentProfileId: "default", }) - const result2 = await truncateConversationIfNeeded({ + const result2 = await manageContext({ messages: messagesWithSmallContent, totalTokens: belowThreshold, contextWindow: modelInfo2.contextWindow, @@ -372,7 +367,7 @@ describe("Sliding Window", () => { // Test above threshold const aboveThreshold = 70001 - const result3 = await truncateConversationIfNeeded({ + const result3 = await manageContext({ messages: messagesWithSmallContent, totalTokens: aboveThreshold, contextWindow: modelInfo1.contextWindow, @@ -386,7 +381,7 @@ describe("Sliding Window", () => { currentProfileId: "default", }) - const result4 = await truncateConversationIfNeeded({ + const result4 = await manageContext({ messages: messagesWithSmallContent, totalTokens: aboveThreshold, contextWindow: modelInfo2.contextWindow, @@ -422,7 +417,7 @@ describe("Sliding Window", () => { // Set base tokens so total is well below threshold + buffer even with small content added const dynamicBuffer = modelInfo.contextWindow * TOKEN_BUFFER_PERCENTAGE const baseTokensForSmall = availableTokens - smallContentTokens - dynamicBuffer - 10 - const resultWithSmall = await truncateConversationIfNeeded({ + const resultWithSmall = await manageContext({ messages: messagesWithSmallContent, totalTokens: baseTokensForSmall, contextWindow: modelInfo.contextWindow, @@ -457,7 +452,7 @@ describe("Sliding Window", () => { // Set base tokens so we're just below threshold without content, but over with content const baseTokensForLarge = availableTokens - Math.floor(largeContentTokens / 2) - const resultWithLarge = await truncateConversationIfNeeded({ + const resultWithLarge = await manageContext({ messages: messagesWithLargeContent, totalTokens: baseTokensForLarge, contextWindow: modelInfo.contextWindow, @@ -485,7 +480,7 @@ describe("Sliding Window", () => { // Set base tokens so we're just below threshold without content const baseTokensForVeryLarge = availableTokens - Math.floor(veryLargeContentTokens / 2) - const resultWithVeryLarge = await truncateConversationIfNeeded({ + const resultWithVeryLarge = await manageContext({ messages: messagesWithVeryLargeContent, totalTokens: baseTokensForVeryLarge, contextWindow: modelInfo.contextWindow, @@ -523,7 +518,7 @@ describe("Sliding Window", () => { messagesWithSmallContent[4], ] - const result = await truncateConversationIfNeeded({ + const result = await manageContext({ messages: messagesWithSmallContent, totalTokens, contextWindow: modelInfo.contextWindow, @@ -570,7 +565,7 @@ describe("Sliding Window", () => { { ...messages[messages.length - 1], content: "" }, ] - const result = await truncateConversationIfNeeded({ + const result = await manageContext({ messages: messagesWithSmallContent, totalTokens, contextWindow: modelInfo.contextWindow, @@ -637,7 +632,7 @@ describe("Sliding Window", () => { messagesWithSmallContent[4], ] - const result = await truncateConversationIfNeeded({ + const result = await manageContext({ messages: messagesWithSmallContent, totalTokens, contextWindow: modelInfo.contextWindow, @@ -684,7 +679,7 @@ describe("Sliding Window", () => { messagesWithSmallContent[4], ] - const result = await truncateConversationIfNeeded({ + const result = await manageContext({ messages: messagesWithSmallContent, totalTokens, contextWindow: modelInfo.contextWindow, @@ -741,7 +736,7 @@ describe("Sliding Window", () => { { ...messages[messages.length - 1], content: "" }, ] - const result = await truncateConversationIfNeeded({ + const result = await manageContext({ messages: messagesWithSmallContent, totalTokens, contextWindow, @@ -793,7 +788,7 @@ describe("Sliding Window", () => { { ...messages[messages.length - 1], content: "" }, ] - const result = await truncateConversationIfNeeded({ + const result = await manageContext({ messages: messagesWithSmallContent, totalTokens, contextWindow, @@ -880,7 +875,7 @@ describe("Sliding Window", () => { .spyOn(condenseModule, "summarizeConversation") .mockResolvedValue(mockSummarizeResponse) - const result = await truncateConversationIfNeeded({ + const result = await manageContext({ messages: messagesWithSmallContent, totalTokens, contextWindow, @@ -946,7 +941,7 @@ describe("Sliding Window", () => { .spyOn(condenseModule, "summarizeConversation") .mockResolvedValue(mockSummarizeResponse) - const result = await truncateConversationIfNeeded({ + const result = await manageContext({ messages: messagesWithSmallContent, totalTokens, contextWindow, @@ -1000,7 +995,7 @@ describe("Sliding Window", () => { vi.clearAllMocks() const summarizeSpy = vi.spyOn(condenseModule, "summarizeConversation") - const result = await truncateConversationIfNeeded({ + const result = await manageContext({ messages: messagesWithSmallContent, totalTokens, contextWindow, @@ -1030,10 +1025,10 @@ describe("Sliding Window", () => { }) /** - * Tests for the getMaxTokens function (private but tested through truncateConversationIfNeeded) + * Tests for the getMaxTokens function (private but tested through manageContext) */ describe("getMaxTokens", () => { - // We'll test this indirectly through truncateConversationIfNeeded + // We'll test this indirectly through manageContext const createModelInfo = (contextWindow: number, maxTokens?: number): ModelInfo => ({ contextWindow, supportsPromptCache: true, // Not relevant for getMaxTokens @@ -1061,7 +1056,7 @@ describe("Sliding Window", () => { // Account for the dynamic buffer which is 10% of context window (10,000 tokens) // Below max tokens and buffer - no truncation - const result1 = await truncateConversationIfNeeded({ + const result1 = await manageContext({ messages: messagesWithSmallContent, totalTokens: 39999, // Well below threshold + dynamic buffer contextWindow: modelInfo.contextWindow, @@ -1082,7 +1077,7 @@ describe("Sliding Window", () => { }) // Above max tokens - truncate - const result2 = await truncateConversationIfNeeded({ + const result2 = await manageContext({ messages: messagesWithSmallContent, totalTokens: 50001, // Above threshold contextWindow: modelInfo.contextWindow, @@ -1114,7 +1109,7 @@ describe("Sliding Window", () => { // Account for the dynamic buffer which is 10% of context window (10,000 tokens) // Below max tokens and buffer - no truncation - const result1 = await truncateConversationIfNeeded({ + const result1 = await manageContext({ messages: messagesWithSmallContent, totalTokens: 81807, // Well below threshold + dynamic buffer (91808 - 10000 = 81808) contextWindow: modelInfo.contextWindow, @@ -1135,7 +1130,7 @@ describe("Sliding Window", () => { }) // Above max tokens - truncate - const result2 = await truncateConversationIfNeeded({ + const result2 = await manageContext({ messages: messagesWithSmallContent, totalTokens: 81809, // Above threshold (81808) contextWindow: modelInfo.contextWindow, @@ -1166,7 +1161,7 @@ describe("Sliding Window", () => { ] // Below max tokens and buffer - no truncation - const result1 = await truncateConversationIfNeeded({ + const result1 = await manageContext({ messages: messagesWithSmallContent, totalTokens: 34999, // Well below threshold + buffer contextWindow: modelInfo.contextWindow, @@ -1182,7 +1177,7 @@ describe("Sliding Window", () => { expect(result1.messages).toEqual(messagesWithSmallContent) // Above max tokens - truncate - const result2 = await truncateConversationIfNeeded({ + const result2 = await manageContext({ messages: messagesWithSmallContent, totalTokens: 40001, // Above threshold contextWindow: modelInfo.contextWindow, @@ -1211,7 +1206,7 @@ describe("Sliding Window", () => { // Account for the dynamic buffer which is 10% of context window (20,000 tokens for this test) // Below max tokens and buffer - no truncation - const result1 = await truncateConversationIfNeeded({ + const result1 = await manageContext({ messages: messagesWithSmallContent, totalTokens: 149999, // Well below threshold + dynamic buffer contextWindow: modelInfo.contextWindow, @@ -1227,7 +1222,7 @@ describe("Sliding Window", () => { expect(result1.messages).toEqual(messagesWithSmallContent) // Above max tokens - truncate - const result2 = await truncateConversationIfNeeded({ + const result2 = await manageContext({ messages: messagesWithSmallContent, totalTokens: 170001, // Above threshold contextWindow: modelInfo.contextWindow, diff --git a/src/core/sliding-window/index.ts b/src/core/context-management/index.ts similarity index 78% rename from src/core/sliding-window/index.ts rename to src/core/context-management/index.ts index 1e518c9a56..fa91fc0c9d 100644 --- a/src/core/sliding-window/index.ts +++ b/src/core/context-management/index.ts @@ -8,7 +8,18 @@ import { ApiMessage } from "../task-persistence/apiMessages" import { ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types" /** - * Default percentage of the context window to use as a buffer when deciding when to truncate + * Context Management + * + * This module provides Context Management for conversations, combining: + * - Intelligent condensation of prior messages when approaching configured thresholds + * - Sliding window truncation as a fallback when necessary + * + * Behavior and exports are preserved exactly from the previous sliding-window implementation. + */ + +/** + * Default percentage of the context window to use as a buffer when deciding when to truncate. + * Used by Context Management to determine when to trigger condensation or (fallback) sliding window truncation. */ export const TOKEN_BUFFER_PERCENTAGE = 0.1 @@ -33,6 +44,8 @@ export async function estimateTokenCount( * The first message is always retained, and a specified fraction (rounded to an even number) * of messages from the beginning (excluding the first) is removed. * + * This implements the sliding window truncation behavior. + * * @param {ApiMessage[]} messages - The conversation messages. * @param {number} fracToRemove - The fraction (between 0 and 1) of messages (excluding the first) to remove. * @param {string} taskId - The task ID for the conversation, used for telemetry @@ -50,20 +63,16 @@ export function truncateConversation(messages: ApiMessage[], fracToRemove: numbe } /** - * Conditionally truncates the conversation messages if the total token count - * exceeds the model's limit, considering the size of incoming content. + * Context Management: Conditionally manages the conversation context when approaching limits. * - * @param {ApiMessage[]} messages - The conversation messages. - * @param {number} totalTokens - The total number of tokens in the conversation (excluding the last user message). - * @param {number} contextWindow - The context window size. - * @param {number} maxTokens - The maximum number of tokens allowed. - * @param {ApiHandler} apiHandler - The API handler to use for token counting. - * @param {boolean} autoCondenseContext - Whether to use LLM summarization or sliding window implementation - * @param {string} systemPrompt - The system prompt, used for estimating the new context size after summarizing. - * @returns {ApiMessage[]} The original or truncated conversation messages. + * Attempts intelligent condensation of prior messages when thresholds are reached. + * Falls back to sliding window truncation if condensation is unavailable or fails. + * + * @param {ContextManagementOptions} options - The options for truncation/condensation + * @returns {Promise} The original, condensed, or truncated conversation messages. */ -type TruncateOptions = { +export type ContextManagementOptions = { messages: ApiMessage[] totalTokens: number contextWindow: number @@ -79,16 +88,15 @@ type TruncateOptions = { currentProfileId: string } -type TruncateResponse = SummarizeResponse & { prevContextTokens: number } +export type ContextManagementResult = SummarizeResponse & { prevContextTokens: number } /** - * Conditionally truncates the conversation messages if the total token count - * exceeds the model's limit, considering the size of incoming content. + * Conditionally manages conversation context (condense and fallback truncation). * - * @param {TruncateOptions} options - The options for truncation - * @returns {Promise} The original or truncated conversation messages. + * @param {ContextManagementOptions} options - The options for truncation/condensation + * @returns {Promise} The original, condensed, or truncated conversation messages. */ -export async function truncateConversationIfNeeded({ +export async function manageContext({ messages, totalTokens, contextWindow, @@ -102,7 +110,7 @@ export async function truncateConversationIfNeeded({ condensingApiHandler, profileThresholds, currentProfileId, -}: TruncateOptions): Promise { +}: ContextManagementOptions): Promise { let error: string | undefined let cost = 0 // Calculate the maximum tokens reserved for response diff --git a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts index 1110aa8831..9b346aeea9 100644 --- a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts +++ b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts @@ -17,6 +17,7 @@ import { ApiHandler } from "../../../api/index" import { ClineProvider } from "../../webview/ClineProvider" import { RooIgnoreController } from "../../ignore/RooIgnoreController" import { formatResponse } from "../../prompts/responses" +import { getGitStatus } from "../../../utils/git" import { Task } from "../../task/Task" vi.mock("vscode", () => ({ @@ -48,6 +49,7 @@ vi.mock("../../../services/glob/list-files") vi.mock("../../../integrations/terminal/TerminalRegistry") vi.mock("../../../integrations/terminal/Terminal") vi.mock("../../../utils/path") +vi.mock("../../../utils/git") vi.mock("../../prompts/responses") describe("getEnvironmentDetails", () => { @@ -134,6 +136,7 @@ describe("getEnvironmentDetails", () => { ;(TerminalRegistry.getBackgroundTerminals as Mock).mockReturnValue([]) ;(TerminalRegistry.isProcessHot as Mock).mockReturnValue(false) ;(TerminalRegistry.getUnretrievedOutput as Mock).mockReturnValue("") + ;(getGitStatus as Mock).mockResolvedValue("## main") vi.mocked(pWaitFor).mockResolvedValue(undefined) vi.mocked(delay).mockResolvedValue(undefined) }) @@ -143,9 +146,9 @@ describe("getEnvironmentDetails", () => { expect(result).toContain("") expect(result).toContain("") - expect(result).toContain("# VSCode Visible Files") - expect(result).toContain("# VSCode Open Tabs") + // Visible Files and Open Tabs headers only appear when there's content expect(result).toContain("# Current Time") + expect(result).not.toContain("# Git Status") // Git status is disabled by default (maxGitStatusFiles = 0) expect(result).toContain("# Current Cost") expect(result).toContain("# Current Mode") expect(result).toContain("test-model") @@ -390,4 +393,67 @@ describe("getEnvironmentDetails", () => { const result = await getEnvironmentDetails(cline as Task) expect(result).toContain("REMINDERS") }) + + it("should include git status when maxGitStatusFiles > 0", async () => { + ;(getGitStatus as Mock).mockResolvedValue("## main\nM file1.ts") + mockProvider.getState.mockResolvedValue({ + ...mockState, + maxGitStatusFiles: 10, + }) + + const result = await getEnvironmentDetails(mockCline as Task) + + expect(result).toContain("# Git Status") + expect(result).toContain("## main") + expect(getGitStatus).toHaveBeenCalledWith(mockCwd, 10) + }) + + it("should NOT include git status when maxGitStatusFiles is 0", async () => { + mockProvider.getState.mockResolvedValue({ + ...mockState, + maxGitStatusFiles: 0, + }) + + const result = await getEnvironmentDetails(mockCline as Task) + + expect(result).not.toContain("# Git Status") + expect(getGitStatus).not.toHaveBeenCalled() + }) + + it("should NOT include git status when maxGitStatusFiles is undefined (defaults to 0)", async () => { + mockProvider.getState.mockResolvedValue({ + ...mockState, + maxGitStatusFiles: undefined, + }) + + const result = await getEnvironmentDetails(mockCline as Task) + + expect(result).not.toContain("# Git Status") + expect(getGitStatus).not.toHaveBeenCalled() + }) + + it("should handle git status returning null gracefully when enabled", async () => { + ;(getGitStatus as Mock).mockResolvedValue(null) + mockProvider.getState.mockResolvedValue({ + ...mockState, + maxGitStatusFiles: 10, + }) + + const result = await getEnvironmentDetails(mockCline as Task) + + expect(result).not.toContain("# Git Status") + expect(getGitStatus).toHaveBeenCalledWith(mockCwd, 10) + }) + + it("should pass maxFiles parameter to getGitStatus", async () => { + ;(getGitStatus as Mock).mockResolvedValue("## main") + mockProvider.getState.mockResolvedValue({ + ...mockState, + maxGitStatusFiles: 5, + }) + + await getEnvironmentDetails(mockCline as Task) + + expect(getGitStatus).toHaveBeenCalledWith(mockCwd, 5) + }) }) diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index 30d9cd0b0d..bf0e3c8392 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -17,6 +17,7 @@ import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" import { Terminal } from "../../integrations/terminal/Terminal" import { arePathsEqual } from "../../utils/path" import { formatResponse } from "../prompts/responses" +import { getGitStatus } from "../../utils/git" import { Task } from "../task/Task" import { formatReminderSection } from "./reminder" @@ -34,8 +35,6 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo // It could be useful for cline to know if the user went from one or no // file to another between messages, so we always include this context. - details += "\n\n# VSCode Visible Files" - const visibleFilePaths = vscode.window.visibleTextEditors ?.map((editor) => editor.document?.uri?.fsPath) .filter(Boolean) @@ -48,12 +47,10 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo : visibleFilePaths.map((p) => p.toPosix()).join("\n") if (allowedVisibleFiles) { + details += "\n\n# VSCode Visible Files" details += `\n${allowedVisibleFiles}` - } else { - details += "\n(No visible files)" } - details += "\n\n# VSCode Open Tabs" const { maxOpenTabsContext } = state ?? {} const maxTabs = maxOpenTabsContext ?? 20 const openTabPaths = vscode.window.tabGroups.all @@ -70,9 +67,8 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo : openTabPaths.map((p) => p.toPosix()).join("\n") if (allowedOpenTabs) { + details += "\n\n# VSCode Open Tabs" details += `\n${allowedOpenTabs}` - } else { - details += "\n(No open tabs)" } // Get task-specific and background terminals. @@ -191,7 +187,7 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo } // Get settings for time and cost display - const { includeCurrentTime = true, includeCurrentCost = true } = state ?? {} + const { includeCurrentTime = true, includeCurrentCost = true, maxGitStatusFiles = 0 } = state ?? {} // Add current time information with timezone (if enabled). if (includeCurrentTime) { @@ -205,6 +201,14 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo details += `\n\n# Current Time\nCurrent time in ISO 8601 UTC format: ${now.toISOString()}\nUser time zone: ${timeZone}, UTC${timeZoneOffsetStr}` } + // Add git status information (if enabled with maxGitStatusFiles > 0). + if (maxGitStatusFiles > 0) { + const gitStatus = await getGitStatus(cline.cwd, maxGitStatusFiles) + if (gitStatus) { + details += `\n\n# Git Status\n${gitStatus}` + } + } + // Add context tokens information (if enabled). if (includeCurrentCost) { const { totalCost } = getApiMetrics(cline.clineMessages) diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap index f9fc502764..dbab96eca1 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap @@ -4,7 +4,7 @@ You are Roo, an experienced technical leader who is inquisitive and an excellent MARKDOWN RULES -ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in +ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in attempt_completion ==== @@ -409,7 +409,7 @@ CAPABILITIES - When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. - You can use search_files to perform regex searches across files in a specified directory, outputting context-rich results that include surrounding lines. This is particularly useful for understanding code patterns, finding specific implementations, or identifying areas that need refactoring. - You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task. - - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. + - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file or insert_content tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. - You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance. ==== @@ -429,6 +429,7 @@ RULES - Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - When using the search_files tool, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. Leverage the search_files tool in combination with other tools for more comprehensive analysis. For example, use it to find specific code patterns, then use read_file to examine the full context of interesting matches before using write_to_file to make informed changes. - When creating a new project (such as an app, website, or any software project), organize all new files within a dedicated project directory unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the project logically, adhering to best practices for the specific type of project being created. Unless otherwise specified, new projects should be easily run without additional setup, for example most projects can be built in HTML, CSS, and JavaScript - which you can open in a browser. + - For editing files, you have access to these tools: write_to_file (for creating new files or complete file rewrites), insert_content (for adding lines to files). - The insert_content tool adds lines of text to files at a specific line number, such as adding a new function to a JavaScript file or inserting a new route in a Python file. Use line number 0 to append at the end of the file, or any positive number to insert before that line. - You should always prefer using other editing tools over write_to_file when making changes to existing files since write_to_file is much slower and cannot handle large files. diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap index 059f98d73b..e49d2bc0a7 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap @@ -4,7 +4,7 @@ You are Roo, a knowledgeable technical assistant focused on answering questions MARKDOWN RULES -ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in +ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in attempt_completion ==== @@ -342,7 +342,6 @@ CAPABILITIES - When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. - You can use search_files to perform regex searches across files in a specified directory, outputting context-rich results that include surrounding lines. This is particularly useful for understanding code patterns, finding specific implementations, or identifying areas that need refactoring. - You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task. - - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. - You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance. ==== @@ -360,12 +359,9 @@ RULES - You cannot `cd` into a different directory to complete a task. You are stuck operating from '/test/path', so be sure to pass in the correct 'path' parameter when using tools that require a path. - Do not use the ~ character or $HOME to refer to the home directory. - Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. -- When using the search_files tool, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. Leverage the search_files tool in combination with other tools for more comprehensive analysis. For example, use it to find specific code patterns, then use read_file to examine the full context of interesting matches before using write_to_file to make informed changes. -- When creating a new project (such as an app, website, or any software project), organize all new files within a dedicated project directory unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the project logically, adhering to best practices for the specific type of project being created. Unless otherwise specified, new projects should be easily run without additional setup, for example most projects can be built in HTML, CSS, and JavaScript - which you can open in a browser. -- For editing files, you have access to these tools: write_to_file (for creating new files or complete file rewrites), insert_content (for adding lines to files). -- The insert_content tool adds lines of text to files at a specific line number, such as adding a new function to a JavaScript file or inserting a new route in a Python file. Use line number 0 to append at the end of the file, or any positive number to insert before that line. -- You should always prefer using other editing tools over write_to_file when making changes to existing files since write_to_file is much slower and cannot handle large files. -- When using the write_to_file tool to modify a file, use the tool directly with the desired content. You do not need to display the content before using the tool. ALWAYS provide the COMPLETE file content in your response. This is NON-NEGOTIABLE. Partial updates or placeholders like '// rest of code unchanged' are STRICTLY FORBIDDEN. You MUST include ALL parts of the file, even if they haven't been modified. Failure to do so will result in incomplete or broken code, severely impacting the user's project. +- When using the search_files tool, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. + + - Some modes have restrictions on which files they can edit. If you attempt to edit a restricted file, the operation will be rejected with a FileRestrictionError that will specify which file patterns are allowed for the current mode. - Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write. * For example, in architect mode trying to edit app.js would be rejected because architect mode can only edit files matching "\.md$" diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap index e45deefb6a..3581a924c9 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap @@ -4,7 +4,7 @@ You are Roo, an experienced technical leader who is inquisitive and an excellent MARKDOWN RULES -ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in +ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in attempt_completion ==== @@ -408,7 +408,7 @@ CAPABILITIES - When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. - You can use search_files to perform regex searches across files in a specified directory, outputting context-rich results that include surrounding lines. This is particularly useful for understanding code patterns, finding specific implementations, or identifying areas that need refactoring. - You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task. - - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. + - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file or insert_content tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. - You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance. ==== @@ -428,6 +428,7 @@ RULES - Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - When using the search_files tool, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. Leverage the search_files tool in combination with other tools for more comprehensive analysis. For example, use it to find specific code patterns, then use read_file to examine the full context of interesting matches before using write_to_file to make informed changes. - When creating a new project (such as an app, website, or any software project), organize all new files within a dedicated project directory unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the project logically, adhering to best practices for the specific type of project being created. Unless otherwise specified, new projects should be easily run without additional setup, for example most projects can be built in HTML, CSS, and JavaScript - which you can open in a browser. + - For editing files, you have access to these tools: write_to_file (for creating new files or complete file rewrites), insert_content (for adding lines to files). - The insert_content tool adds lines of text to files at a specific line number, such as adding a new function to a JavaScript file or inserting a new route in a Python file. Use line number 0 to append at the end of the file, or any positive number to insert before that line. - You should always prefer using other editing tools over write_to_file when making changes to existing files since write_to_file is much slower and cannot handle large files. diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-enabled.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-enabled.snap index a6afbec406..0e0e9200ec 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-enabled.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/mcp-server-creation-enabled.snap @@ -4,7 +4,7 @@ You are Roo, an experienced technical leader who is inquisitive and an excellent MARKDOWN RULES -ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in +ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in attempt_completion ==== @@ -475,7 +475,7 @@ CAPABILITIES - When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. - You can use search_files to perform regex searches across files in a specified directory, outputting context-rich results that include surrounding lines. This is particularly useful for understanding code patterns, finding specific implementations, or identifying areas that need refactoring. - You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task. - - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. + - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file or insert_content tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. - You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance. - You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively. @@ -497,6 +497,7 @@ RULES - Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - When using the search_files tool, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. Leverage the search_files tool in combination with other tools for more comprehensive analysis. For example, use it to find specific code patterns, then use read_file to examine the full context of interesting matches before using write_to_file to make informed changes. - When creating a new project (such as an app, website, or any software project), organize all new files within a dedicated project directory unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the project logically, adhering to best practices for the specific type of project being created. Unless otherwise specified, new projects should be easily run without additional setup, for example most projects can be built in HTML, CSS, and JavaScript - which you can open in a browser. + - For editing files, you have access to these tools: write_to_file (for creating new files or complete file rewrites), insert_content (for adding lines to files). - The insert_content tool adds lines of text to files at a specific line number, such as adding a new function to a JavaScript file or inserting a new route in a Python file. Use line number 0 to append at the end of the file, or any positive number to insert before that line. - You should always prefer using other editing tools over write_to_file when making changes to existing files since write_to_file is much slower and cannot handle large files. diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/partial-reads-enabled.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/partial-reads-enabled.snap index 0956dcde38..44a34544dc 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/partial-reads-enabled.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/partial-reads-enabled.snap @@ -4,7 +4,7 @@ You are Roo, an experienced technical leader who is inquisitive and an excellent MARKDOWN RULES -ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in +ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in attempt_completion ==== @@ -414,7 +414,7 @@ CAPABILITIES - When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. - You can use search_files to perform regex searches across files in a specified directory, outputting context-rich results that include surrounding lines. This is particularly useful for understanding code patterns, finding specific implementations, or identifying areas that need refactoring. - You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task. - - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. + - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file or insert_content tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. - You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance. ==== @@ -434,6 +434,7 @@ RULES - Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - When using the search_files tool, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. Leverage the search_files tool in combination with other tools for more comprehensive analysis. For example, use it to find specific code patterns, then use read_file to examine the full context of interesting matches before using write_to_file to make informed changes. - When creating a new project (such as an app, website, or any software project), organize all new files within a dedicated project directory unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the project logically, adhering to best practices for the specific type of project being created. Unless otherwise specified, new projects should be easily run without additional setup, for example most projects can be built in HTML, CSS, and JavaScript - which you can open in a browser. + - For editing files, you have access to these tools: write_to_file (for creating new files or complete file rewrites), insert_content (for adding lines to files). - The insert_content tool adds lines of text to files at a specific line number, such as adding a new function to a JavaScript file or inserting a new route in a Python file. Use line number 0 to append at the end of the file, or any positive number to insert before that line. - You should always prefer using other editing tools over write_to_file when making changes to existing files since write_to_file is much slower and cannot handle large files. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap index f9fc502764..dbab96eca1 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap @@ -4,7 +4,7 @@ You are Roo, an experienced technical leader who is inquisitive and an excellent MARKDOWN RULES -ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in +ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in attempt_completion ==== @@ -409,7 +409,7 @@ CAPABILITIES - When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. - You can use search_files to perform regex searches across files in a specified directory, outputting context-rich results that include surrounding lines. This is particularly useful for understanding code patterns, finding specific implementations, or identifying areas that need refactoring. - You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task. - - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. + - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file or insert_content tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. - You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance. ==== @@ -429,6 +429,7 @@ RULES - Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - When using the search_files tool, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. Leverage the search_files tool in combination with other tools for more comprehensive analysis. For example, use it to find specific code patterns, then use read_file to examine the full context of interesting matches before using write_to_file to make informed changes. - When creating a new project (such as an app, website, or any software project), organize all new files within a dedicated project directory unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the project logically, adhering to best practices for the specific type of project being created. Unless otherwise specified, new projects should be easily run without additional setup, for example most projects can be built in HTML, CSS, and JavaScript - which you can open in a browser. + - For editing files, you have access to these tools: write_to_file (for creating new files or complete file rewrites), insert_content (for adding lines to files). - The insert_content tool adds lines of text to files at a specific line number, such as adding a new function to a JavaScript file or inserting a new route in a Python file. Use line number 0 to append at the end of the file, or any positive number to insert before that line. - You should always prefer using other editing tools over write_to_file when making changes to existing files since write_to_file is much slower and cannot handle large files. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-computer-use-support.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-computer-use-support.snap index 3420d1d133..03e66365c7 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-computer-use-support.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-computer-use-support.snap @@ -4,7 +4,7 @@ You are Roo, an experienced technical leader who is inquisitive and an excellent MARKDOWN RULES -ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in +ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in attempt_completion ==== @@ -462,7 +462,7 @@ CAPABILITIES - When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. - You can use search_files to perform regex searches across files in a specified directory, outputting context-rich results that include surrounding lines. This is particularly useful for understanding code patterns, finding specific implementations, or identifying areas that need refactoring. - You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task. - - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. + - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file or insert_content tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. - You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance. - You can use the browser_action tool to interact with websites (including html files and locally running development servers) through a Puppeteer-controlled browser when you feel it is necessary in accomplishing the user's task. 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. This tool may be useful 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. You can analyze the provided screenshots to ensure correct rendering or identify errors, and review console logs for runtime issues. - For example, if asked to add a component to a react website, you might create the necessary files, use execute_command to run the site locally, then use browser_action to launch the browser, navigate to the local server, and verify the component renders & functions correctly before closing the browser. @@ -484,6 +484,7 @@ RULES - Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - When using the search_files tool, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. Leverage the search_files tool in combination with other tools for more comprehensive analysis. For example, use it to find specific code patterns, then use read_file to examine the full context of interesting matches before using write_to_file to make informed changes. - When creating a new project (such as an app, website, or any software project), organize all new files within a dedicated project directory unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the project logically, adhering to best practices for the specific type of project being created. Unless otherwise specified, new projects should be easily run without additional setup, for example most projects can be built in HTML, CSS, and JavaScript - which you can open in a browser. + - For editing files, you have access to these tools: write_to_file (for creating new files or complete file rewrites), insert_content (for adding lines to files). - The insert_content tool adds lines of text to files at a specific line number, such as adding a new function to a JavaScript file or inserting a new route in a Python file. Use line number 0 to append at the end of the file, or any positive number to insert before that line. - You should always prefer using other editing tools over write_to_file when making changes to existing files since write_to_file is much slower and cannot handle large files. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-false.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-false.snap index f9fc502764..dbab96eca1 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-false.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-false.snap @@ -4,7 +4,7 @@ You are Roo, an experienced technical leader who is inquisitive and an excellent MARKDOWN RULES -ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in +ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in attempt_completion ==== @@ -409,7 +409,7 @@ CAPABILITIES - When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. - You can use search_files to perform regex searches across files in a specified directory, outputting context-rich results that include surrounding lines. This is particularly useful for understanding code patterns, finding specific implementations, or identifying areas that need refactoring. - You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task. - - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. + - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file or insert_content tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. - You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance. ==== @@ -429,6 +429,7 @@ RULES - Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - When using the search_files tool, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. Leverage the search_files tool in combination with other tools for more comprehensive analysis. For example, use it to find specific code patterns, then use read_file to examine the full context of interesting matches before using write_to_file to make informed changes. - When creating a new project (such as an app, website, or any software project), organize all new files within a dedicated project directory unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the project logically, adhering to best practices for the specific type of project being created. Unless otherwise specified, new projects should be easily run without additional setup, for example most projects can be built in HTML, CSS, and JavaScript - which you can open in a browser. + - For editing files, you have access to these tools: write_to_file (for creating new files or complete file rewrites), insert_content (for adding lines to files). - The insert_content tool adds lines of text to files at a specific line number, such as adding a new function to a JavaScript file or inserting a new route in a Python file. Use line number 0 to append at the end of the file, or any positive number to insert before that line. - You should always prefer using other editing tools over write_to_file when making changes to existing files since write_to_file is much slower and cannot handle large files. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap index 530598739c..9bb8c3e462 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap @@ -4,7 +4,7 @@ You are Roo, an experienced technical leader who is inquisitive and an excellent MARKDOWN RULES -ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in +ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in attempt_completion ==== @@ -497,7 +497,7 @@ CAPABILITIES - When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. - You can use search_files to perform regex searches across files in a specified directory, outputting context-rich results that include surrounding lines. This is particularly useful for understanding code patterns, finding specific implementations, or identifying areas that need refactoring. - You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task. - - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the apply_diff or write_to_file tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. + - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the apply_diff, write_to_file, or insert_content tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. - You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance. ==== @@ -517,6 +517,7 @@ RULES - Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - When using the search_files tool, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. Leverage the search_files tool in combination with other tools for more comprehensive analysis. For example, use it to find specific code patterns, then use read_file to examine the full context of interesting matches before using apply_diff or write_to_file to make informed changes. - When creating a new project (such as an app, website, or any software project), organize all new files within a dedicated project directory unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the project logically, adhering to best practices for the specific type of project being created. Unless otherwise specified, new projects should be easily run without additional setup, for example most projects can be built in HTML, CSS, and JavaScript - which you can open in a browser. + - For editing files, you have access to these tools: apply_diff (for surgical edits - targeted changes to specific lines or functions), write_to_file (for creating new files or complete file rewrites), insert_content (for adding lines to files). - The insert_content tool adds lines of text to files at a specific line number, such as adding a new function to a JavaScript file or inserting a new route in a Python file. Use line number 0 to append at the end of the file, or any positive number to insert before that line. - You should always prefer using other editing tools over write_to_file when making changes to existing files since write_to_file is much slower and cannot handle large files. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-undefined.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-undefined.snap index f9fc502764..dbab96eca1 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-undefined.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-undefined.snap @@ -4,7 +4,7 @@ You are Roo, an experienced technical leader who is inquisitive and an excellent MARKDOWN RULES -ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in +ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in attempt_completion ==== @@ -409,7 +409,7 @@ CAPABILITIES - When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. - You can use search_files to perform regex searches across files in a specified directory, outputting context-rich results that include surrounding lines. This is particularly useful for understanding code patterns, finding specific implementations, or identifying areas that need refactoring. - You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task. - - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. + - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file or insert_content tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. - You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance. ==== @@ -429,6 +429,7 @@ RULES - Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - When using the search_files tool, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. Leverage the search_files tool in combination with other tools for more comprehensive analysis. For example, use it to find specific code patterns, then use read_file to examine the full context of interesting matches before using write_to_file to make informed changes. - When creating a new project (such as an app, website, or any software project), organize all new files within a dedicated project directory unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the project logically, adhering to best practices for the specific type of project being created. Unless otherwise specified, new projects should be easily run without additional setup, for example most projects can be built in HTML, CSS, and JavaScript - which you can open in a browser. + - For editing files, you have access to these tools: write_to_file (for creating new files or complete file rewrites), insert_content (for adding lines to files). - The insert_content tool adds lines of text to files at a specific line number, such as adding a new function to a JavaScript file or inserting a new route in a Python file. Use line number 0 to append at the end of the file, or any positive number to insert before that line. - You should always prefer using other editing tools over write_to_file when making changes to existing files since write_to_file is much slower and cannot handle large files. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-different-viewport-size.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-different-viewport-size.snap index f9fc502764..dbab96eca1 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-different-viewport-size.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-different-viewport-size.snap @@ -4,7 +4,7 @@ You are Roo, an experienced technical leader who is inquisitive and an excellent MARKDOWN RULES -ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in +ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in attempt_completion ==== @@ -409,7 +409,7 @@ CAPABILITIES - When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. - You can use search_files to perform regex searches across files in a specified directory, outputting context-rich results that include surrounding lines. This is particularly useful for understanding code patterns, finding specific implementations, or identifying areas that need refactoring. - You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task. - - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. + - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file or insert_content tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. - You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance. ==== @@ -429,6 +429,7 @@ RULES - Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - When using the search_files tool, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. Leverage the search_files tool in combination with other tools for more comprehensive analysis. For example, use it to find specific code patterns, then use read_file to examine the full context of interesting matches before using write_to_file to make informed changes. - When creating a new project (such as an app, website, or any software project), organize all new files within a dedicated project directory unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the project logically, adhering to best practices for the specific type of project being created. Unless otherwise specified, new projects should be easily run without additional setup, for example most projects can be built in HTML, CSS, and JavaScript - which you can open in a browser. + - For editing files, you have access to these tools: write_to_file (for creating new files or complete file rewrites), insert_content (for adding lines to files). - The insert_content tool adds lines of text to files at a specific line number, such as adding a new function to a JavaScript file or inserting a new route in a Python file. Use line number 0 to append at the end of the file, or any positive number to insert before that line. - You should always prefer using other editing tools over write_to_file when making changes to existing files since write_to_file is much slower and cannot handle large files. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap index a6afbec406..0e0e9200ec 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap @@ -4,7 +4,7 @@ You are Roo, an experienced technical leader who is inquisitive and an excellent MARKDOWN RULES -ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in +ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in attempt_completion ==== @@ -475,7 +475,7 @@ CAPABILITIES - When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. - You can use search_files to perform regex searches across files in a specified directory, outputting context-rich results that include surrounding lines. This is particularly useful for understanding code patterns, finding specific implementations, or identifying areas that need refactoring. - You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task. - - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. + - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file or insert_content tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. - You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance. - You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively. @@ -497,6 +497,7 @@ RULES - Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - When using the search_files tool, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. Leverage the search_files tool in combination with other tools for more comprehensive analysis. For example, use it to find specific code patterns, then use read_file to examine the full context of interesting matches before using write_to_file to make informed changes. - When creating a new project (such as an app, website, or any software project), organize all new files within a dedicated project directory unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the project logically, adhering to best practices for the specific type of project being created. Unless otherwise specified, new projects should be easily run without additional setup, for example most projects can be built in HTML, CSS, and JavaScript - which you can open in a browser. + - For editing files, you have access to these tools: write_to_file (for creating new files or complete file rewrites), insert_content (for adding lines to files). - The insert_content tool adds lines of text to files at a specific line number, such as adding a new function to a JavaScript file or inserting a new route in a Python file. Use line number 0 to append at the end of the file, or any positive number to insert before that line. - You should always prefer using other editing tools over write_to_file when making changes to existing files since write_to_file is much slower and cannot handle large files. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap index f9fc502764..dbab96eca1 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap @@ -4,7 +4,7 @@ You are Roo, an experienced technical leader who is inquisitive and an excellent MARKDOWN RULES -ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in +ALL responses MUST show ANY `language construct` OR filename reference as clickable, exactly as [`filename OR language.declaration()`](relative/file/path.ext:line); line is required for `syntax` and optional for filename links. This applies to ALL markdown responses and ALSO those in attempt_completion ==== @@ -409,7 +409,7 @@ CAPABILITIES - When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. - You can use search_files to perform regex searches across files in a specified directory, outputting context-rich results that include surrounding lines. This is particularly useful for understanding code patterns, finding specific implementations, or identifying areas that need refactoring. - You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task. - - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. + - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the write_to_file or insert_content tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. - You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance. ==== @@ -429,6 +429,7 @@ RULES - Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - When using the search_files tool, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. Leverage the search_files tool in combination with other tools for more comprehensive analysis. For example, use it to find specific code patterns, then use read_file to examine the full context of interesting matches before using write_to_file to make informed changes. - When creating a new project (such as an app, website, or any software project), organize all new files within a dedicated project directory unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the project logically, adhering to best practices for the specific type of project being created. Unless otherwise specified, new projects should be easily run without additional setup, for example most projects can be built in HTML, CSS, and JavaScript - which you can open in a browser. + - For editing files, you have access to these tools: write_to_file (for creating new files or complete file rewrites), insert_content (for adding lines to files). - The insert_content tool adds lines of text to files at a specific line number, such as adding a new function to a JavaScript file or inserting a new route in a Python file. Use line number 0 to append at the end of the file, or any positive number to insert before that line. - You should always prefer using other editing tools over write_to_file when making changes to existing files since write_to_file is much slower and cannot handle large files. diff --git a/src/core/prompts/__tests__/mode-aware-sections.spec.ts b/src/core/prompts/__tests__/mode-aware-sections.spec.ts new file mode 100644 index 0000000000..a436055b56 --- /dev/null +++ b/src/core/prompts/__tests__/mode-aware-sections.spec.ts @@ -0,0 +1,229 @@ +import { getCapabilitiesSection } from "../sections/capabilities" +import { getRulesSection } from "../sections/rules" +import type { DiffStrategy, DiffResult, DiffItem } from "../../../shared/tools" + +describe("Mode-aware system prompt sections", () => { + const cwd = "/test/path" + const mcpHub = undefined + const mockDiffStrategy: DiffStrategy = { + getName: () => "MockStrategy", + getToolDescription: () => "apply_diff tool description", + async applyDiff(_originalContent: string, _diffContents: string | DiffItem[]): Promise { + return { success: true, content: "mock result" } + }, + } + + describe("getCapabilitiesSection", () => { + it('should include editing tools in "code" mode', () => { + const result = getCapabilitiesSection(cwd, false, "code", undefined, undefined, mcpHub, mockDiffStrategy) + + expect(result).toContain("apply_diff") + expect(result).toContain("write_to_file") + expect(result).toContain("insert_content") + }) + + it('should NOT include editing tools in "ask" mode', () => { + const result = getCapabilitiesSection(cwd, false, "ask", undefined, undefined, mcpHub, mockDiffStrategy) + + // Ask mode doesn't have the "edit" group, so editing tools shouldn't be mentioned + expect(result).not.toContain("apply_diff") + expect(result).not.toContain("write_to_file") + expect(result).not.toContain("insert_content") + }) + + it('should include editing tools in "architect" mode', () => { + const result = getCapabilitiesSection( + cwd, + false, + "architect", + undefined, + undefined, + mcpHub, + mockDiffStrategy, + ) + + // Architect mode has write_to_file (for markdown files) + expect(result).toContain("write_to_file") + }) + }) + + describe("getRulesSection", () => { + it('should include editing instructions in "code" mode', () => { + const result = getRulesSection( + cwd, + false, + "code", + undefined, + undefined, + mockDiffStrategy, + undefined, + undefined, + ) + + expect(result).toContain("For editing files") + expect(result).toContain("apply_diff") + expect(result).toContain("write_to_file") + expect(result).toContain("insert_content") + }) + + it('should NOT include editing instructions in "ask" mode', () => { + const result = getRulesSection( + cwd, + false, + "ask", + undefined, + undefined, + mockDiffStrategy, + undefined, + undefined, + ) + + // Ask mode has no editing tools, so shouldn't mention them + expect(result).not.toContain("For editing files") + expect(result).not.toContain("apply_diff") + expect(result).not.toContain("write_to_file") + expect(result).not.toContain("insert_content") + }) + + it('should include editing instructions in "debug" mode', () => { + const result = getRulesSection( + cwd, + false, + "debug", + undefined, + undefined, + mockDiffStrategy, + undefined, + undefined, + ) + + // Debug mode has editing tools + expect(result).toContain("For editing files") + expect(result).toContain("write_to_file") + }) + + it("should filter editing tools from search_files description in ask mode", () => { + const result = getRulesSection( + cwd, + false, + "ask", + undefined, + undefined, + mockDiffStrategy, + undefined, + undefined, + ) + + // In ask mode, the search_files description shouldn't mention editing tools + expect(result).toContain("When using the search_files tool") + expect(result).not.toContain("before using apply_diff") + expect(result).not.toContain("before using write_to_file") + }) + + it("should include editing tools in search_files description in code mode", () => { + const result = getRulesSection( + cwd, + false, + "code", + undefined, + undefined, + mockDiffStrategy, + undefined, + undefined, + ) + + // In code mode, the search_files description should mention editing tools + expect(result).toContain("When using the search_files tool") + expect(result).toContain("before using apply_diff or write_to_file") + }) + }) + + describe("browser_action filtering", () => { + it("should include browser_action mentions when enabled and mode supports it", () => { + const capabilities = getCapabilitiesSection( + cwd, + true, // supportsComputerUse + "code", + undefined, + undefined, + mcpHub, + mockDiffStrategy, + undefined, + { browserToolEnabled: true } as any, + ) + + const rules = getRulesSection( + cwd, + true, // supportsComputerUse + "code", + undefined, + undefined, + mockDiffStrategy, + undefined, + { browserToolEnabled: true } as any, + ) + + expect(capabilities).toContain("use the browser") + expect(capabilities).toContain("browser_action tool") + expect(rules).toContain("browser_action") + }) + + it("should NOT include browser_action mentions when disabled in settings", () => { + const capabilities = getCapabilitiesSection( + cwd, + true, // supportsComputerUse + "code", + undefined, + undefined, + mcpHub, + mockDiffStrategy, + undefined, + { browserToolEnabled: false } as any, + ) + + const rules = getRulesSection( + cwd, + true, // supportsComputerUse + "code", + undefined, + undefined, + mockDiffStrategy, + undefined, + { browserToolEnabled: false } as any, + ) + + expect(capabilities).not.toContain("use the browser") + expect(capabilities).not.toContain("browser_action tool") + expect(rules).not.toContain("browser_action") + }) + + it("should NOT include browser_action mentions when mode doesn't support browser", () => { + const capabilities = getCapabilitiesSection( + cwd, + true, // supportsComputerUse + "orchestrator", // orchestrator mode has no groups, including browser + undefined, + undefined, + mcpHub, + mockDiffStrategy, + undefined, + { browserToolEnabled: true } as any, + ) + + const rules = getRulesSection( + cwd, + true, // supportsComputerUse + "orchestrator", + undefined, + undefined, + mockDiffStrategy, + undefined, + { browserToolEnabled: true } as any, + ) + + expect(capabilities).not.toContain("use the browser") + expect(capabilities).not.toContain("browser_action tool") + expect(rules).not.toContain("browser_action") + }) + }) +}) diff --git a/src/core/prompts/__tests__/sections.spec.ts b/src/core/prompts/__tests__/sections.spec.ts index 68458631ea..eb9cd4addf 100644 --- a/src/core/prompts/__tests__/sections.spec.ts +++ b/src/core/prompts/__tests__/sections.spec.ts @@ -41,17 +41,18 @@ describe("getCapabilitiesSection", () => { } it("includes apply_diff in capabilities when diffStrategy is provided", () => { - const result = getCapabilitiesSection(cwd, false, mcpHub, mockDiffStrategy) + const result = getCapabilitiesSection(cwd, false, "code", undefined, undefined, mcpHub, mockDiffStrategy) - expect(result).toContain("apply_diff or") - expect(result).toContain("then use the apply_diff or write_to_file tool") + expect(result).toContain("apply_diff") + expect(result).toContain("write_to_file") + expect(result).toContain("insert_content") }) it("excludes apply_diff from capabilities when diffStrategy is undefined", () => { - const result = getCapabilitiesSection(cwd, false, mcpHub, undefined) + const result = getCapabilitiesSection(cwd, false, "code", undefined, undefined, mcpHub, undefined) - expect(result).not.toContain("apply_diff or") - expect(result).toContain("then use the write_to_file tool") - expect(result).not.toContain("apply_diff or write_to_file") + expect(result).not.toContain("apply_diff") + expect(result).toContain("write_to_file") + expect(result).toContain("insert_content") }) }) diff --git a/src/core/prompts/__tests__/toolProtocolResolver.spec.ts b/src/core/prompts/__tests__/toolProtocolResolver.spec.ts deleted file mode 100644 index 0fe1522663..0000000000 --- a/src/core/prompts/__tests__/toolProtocolResolver.spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -// npx vitest core/prompts/__tests__/toolProtocolResolver.spec.ts - -import { describe, it, expect } from "vitest" -import { resolveToolProtocol } from "../toolProtocolResolver" - -describe("toolProtocolResolver", () => { - it("should default to xml protocol", () => { - expect(resolveToolProtocol()).toBe("xml") - }) -}) diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index 1c1212e70f..6535f25f5c 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -3,22 +3,64 @@ import * as path from "path" import * as diff from "diff" import { RooIgnoreController, LOCK_TEXT_SYMBOL } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" -import { resolveToolProtocol, isNativeProtocol } from "./toolProtocolResolver" -import { ToolProtocol } from "@roo-code/types" +import { ToolProtocol, isNativeProtocol, TOOL_PROTOCOL } from "@roo-code/types" export const formatResponse = { - toolDenied: () => `The user denied this operation.`, + toolDenied: (protocol?: ToolProtocol) => { + if (isNativeProtocol(protocol ?? TOOL_PROTOCOL.XML)) { + return JSON.stringify({ + status: "denied", + message: "The user denied this operation.", + }) + } + return `The user denied this operation.` + }, - toolDeniedWithFeedback: (feedback?: string) => - `The user denied this operation and provided the following feedback:\n\n${feedback}\n`, + toolDeniedWithFeedback: (feedback?: string, protocol?: ToolProtocol) => { + if (isNativeProtocol(protocol ?? TOOL_PROTOCOL.XML)) { + return JSON.stringify({ + status: "denied", + message: "The user denied this operation and provided the following feedback", + feedback: feedback, + }) + } + return `The user denied this operation and provided the following feedback:\n\n${feedback}\n` + }, - toolApprovedWithFeedback: (feedback?: string) => - `The user approved this operation and provided the following context:\n\n${feedback}\n`, + toolApprovedWithFeedback: (feedback?: string, protocol?: ToolProtocol) => { + if (isNativeProtocol(protocol ?? TOOL_PROTOCOL.XML)) { + return JSON.stringify({ + status: "approved", + message: "The user approved this operation and provided the following context", + feedback: feedback, + }) + } + return `The user approved this operation and provided the following context:\n\n${feedback}\n` + }, - toolError: (error?: string) => `The tool execution failed with the following error:\n\n${error}\n`, + toolError: (error?: string, protocol?: ToolProtocol) => { + if (isNativeProtocol(protocol ?? TOOL_PROTOCOL.XML)) { + return JSON.stringify({ + status: "error", + message: "The tool execution failed", + error: error, + }) + } + return `The tool execution failed with the following error:\n\n${error}\n` + }, - rooIgnoreError: (path: string) => - `Access to ${path} is blocked by the .rooignore file settings. You must try to continue in the task without using this file, or ask the user to update the .rooignore file.`, + rooIgnoreError: (path: string, protocol?: ToolProtocol) => { + if (isNativeProtocol(protocol ?? TOOL_PROTOCOL.XML)) { + return JSON.stringify({ + status: "error", + type: "access_denied", + message: "Access blocked by .rooignore", + path: path, + suggestion: "Try to continue without this file, or ask the user to update the .rooignore file", + }) + } + return `Access to ${path} is blocked by the .rooignore file settings. You must try to continue in the task without using this file, or ask the user to update the .rooignore file.` + }, noToolsUsed: (protocol?: ToolProtocol) => { const instructions = getToolInstructionsReminder(protocol) @@ -35,8 +77,16 @@ Otherwise, if you have not completed the task and do not need additional informa (This is an automated message, so do not respond to it conversationally.)` }, - tooManyMistakes: (feedback?: string) => - `You seem to be having trouble proceeding. The user has provided the following feedback to help guide you:\n\n${feedback}\n`, + tooManyMistakes: (feedback?: string, protocol?: ToolProtocol) => { + if (isNativeProtocol(protocol ?? TOOL_PROTOCOL.XML)) { + return JSON.stringify({ + status: "guidance", + message: "You seem to be having trouble proceeding", + feedback: feedback, + }) + } + return `You seem to be having trouble proceeding. The user has provided the following feedback to help guide you:\n\n${feedback}\n` + }, missingToolParameterError: (paramName: string, protocol?: ToolProtocol) => { const instructions = getToolInstructionsReminder(protocol) @@ -83,15 +133,46 @@ Otherwise, if you have not completed the task and do not need additional informa return `${isNewFile ? newFileGuidance : existingFileGuidance}\n${instructions}` }, - invalidMcpToolArgumentError: (serverName: string, toolName: string) => - `Invalid JSON argument used with ${serverName} for ${toolName}. Please retry with a properly formatted JSON argument.`, + invalidMcpToolArgumentError: (serverName: string, toolName: string, protocol?: ToolProtocol) => { + if (isNativeProtocol(protocol ?? TOOL_PROTOCOL.XML)) { + return JSON.stringify({ + status: "error", + type: "invalid_argument", + message: "Invalid JSON argument", + server: serverName, + tool: toolName, + suggestion: "Please retry with a properly formatted JSON argument", + }) + } + return `Invalid JSON argument used with ${serverName} for ${toolName}. Please retry with a properly formatted JSON argument.` + }, - unknownMcpToolError: (serverName: string, toolName: string, availableTools: string[]) => { + unknownMcpToolError: (serverName: string, toolName: string, availableTools: string[], protocol?: ToolProtocol) => { + if (isNativeProtocol(protocol ?? TOOL_PROTOCOL.XML)) { + return JSON.stringify({ + status: "error", + type: "unknown_tool", + message: "Tool does not exist on server", + server: serverName, + tool: toolName, + available_tools: availableTools.length > 0 ? availableTools : [], + suggestion: "Please use one of the available tools or check if the server is properly configured", + }) + } const toolsList = availableTools.length > 0 ? availableTools.join(", ") : "No tools available" return `Tool '${toolName}' does not exist on server '${serverName}'.\n\nAvailable tools on this server: ${toolsList}\n\nPlease use one of the available tools or check if the server is properly configured.` }, - unknownMcpServerError: (serverName: string, availableServers: string[]) => { + unknownMcpServerError: (serverName: string, availableServers: string[], protocol?: ToolProtocol) => { + if (isNativeProtocol(protocol ?? TOOL_PROTOCOL.XML)) { + return JSON.stringify({ + status: "error", + type: "unknown_server", + message: "Server is not configured", + server: serverName, + available_servers: availableServers.length > 0 ? availableServers : [], + }) + } const serversList = availableServers.length > 0 ? availableServers.join(", ") : "No servers available" return `Server '${serverName}' is not configured. Available servers: ${serversList}` }, @@ -245,10 +326,10 @@ Always ensure you provide all required parameters for the tool you wish to use.` /** * Gets the appropriate tool use instructions reminder based on the protocol. * - * @param protocol - Optional tool protocol, falls back to default if not provided + * @param protocol - Optional tool protocol, defaults to XML if not provided * @returns The tool use instructions reminder text */ function getToolInstructionsReminder(protocol?: ToolProtocol): string { - const effectiveProtocol = protocol ?? resolveToolProtocol() + const effectiveProtocol = protocol ?? TOOL_PROTOCOL.XML return isNativeProtocol(effectiveProtocol) ? toolUseInstructionsReminderNative : toolUseInstructionsReminder } diff --git a/src/core/prompts/sections/capabilities.ts b/src/core/prompts/sections/capabilities.ts index e2d27db5bb..55c7c1f5c3 100644 --- a/src/core/prompts/sections/capabilities.ts +++ b/src/core/prompts/sections/capabilities.ts @@ -1,20 +1,60 @@ import { DiffStrategy } from "../../../shared/tools" import { McpHub } from "../../../services/mcp/McpHub" import { CodeIndexManager } from "../../../services/code-index/manager" +import type { ModeConfig, ToolName } from "@roo-code/types" +import { getAvailableToolsInGroup } from "../tools/filter-tools-for-mode" +import type { SystemPromptSettings } from "../types" export function getCapabilitiesSection( cwd: string, supportsComputerUse: boolean, + mode: string, + customModes: ModeConfig[] | undefined, + experiments: Record | undefined, mcpHub?: McpHub, diffStrategy?: DiffStrategy, codeIndexManager?: CodeIndexManager, + settings?: SystemPromptSettings, ): string { + // Get available tools from relevant groups + const availableEditTools = getAvailableToolsInGroup( + "edit", + mode, + customModes, + experiments, + codeIndexManager, + settings, + ) + const availableBrowserTools = getAvailableToolsInGroup( + "browser", + mode, + customModes, + experiments, + codeIndexManager, + settings, + ) + + // Build the tool list for the example, filtering for main editing tools + const editingToolsExample = (["apply_diff", "write_to_file", "insert_content"] as const).filter((tool) => { + if (tool === "apply_diff") return diffStrategy && availableEditTools.includes(tool as ToolName) + return availableEditTools.includes(tool as ToolName) + }) + + const editingToolsText = + editingToolsExample.length === 1 + ? `the ${editingToolsExample[0]}` + : editingToolsExample.length === 2 + ? `the ${editingToolsExample[0]} or ${editingToolsExample[1]}` + : `the ${editingToolsExample.slice(0, -1).join(", ")}, or ${editingToolsExample[editingToolsExample.length - 1]}` + + const hasBrowserAction = supportsComputerUse && availableBrowserTools.includes("browser_action") + return `==== CAPABILITIES - You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search${ - supportsComputerUse ? ", use the browser" : "" + hasBrowserAction ? ", use the browser" : "" }, read and write files, and ask follow-up questions. These tools help you effectively accomplish a wide range of tasks, such as writing code, making edits or improvements to existing files, understanding the current state of a project, performing system operations, and much more. - When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('${cwd}') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop.${ codeIndexManager && @@ -26,10 +66,14 @@ CAPABILITIES : "" } - You can use search_files to perform regex searches across files in a specified directory, outputting context-rich results that include surrounding lines. This is particularly useful for understanding code patterns, finding specific implementations, or identifying areas that need refactoring. -- You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task. - - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use ${diffStrategy ? "the apply_diff or write_to_file" : "the write_to_file"} tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed. +- You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task.${ + editingToolsExample.length > 0 + ? ` + - For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use ${editingToolsText} tool to apply the changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed.` + : "" + } - You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.${ - supportsComputerUse + hasBrowserAction ? "\n- You can use the browser_action tool to interact with websites (including html files and locally running development servers) through a Puppeteer-controlled browser when you feel it is necessary in accomplishing the user's task. 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. This tool may be useful 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. You can analyze the provided screenshots to ensure correct rendering or identify errors, and review console logs for runtime issues.\n - For example, if asked to add a component to a react website, you might create the necessary files, use execute_command to run the site locally, then use browser_action to launch the browser, navigate to the local server, and verify the component renders & functions correctly before closing the browser." : "" }${ diff --git a/src/core/prompts/sections/custom-instructions.ts b/src/core/prompts/sections/custom-instructions.ts index 4e5e5d19ac..a81d4bf943 100644 --- a/src/core/prompts/sections/custom-instructions.ts +++ b/src/core/prompts/sections/custom-instructions.ts @@ -6,7 +6,7 @@ import { Dirent } from "fs" import { isLanguage } from "@roo-code/types" import type { SystemPromptSettings } from "../types" -import { getEffectiveProtocol, isNativeProtocol } from "../toolProtocolResolver" +import { getEffectiveProtocol, isNativeProtocol } from "@roo-code/types" import { LANGUAGES } from "../../../shared/language" import { getRooDirectoriesForCwd, getGlobalRooDirectory } from "../../../services/roo-config" @@ -369,7 +369,7 @@ export async function addCustomInstructions( const joinedSections = sections.join("\n\n") - const effectiveProtocol = getEffectiveProtocol(options.settings) + const effectiveProtocol = getEffectiveProtocol(options.settings?.toolProtocol) return joinedSections ? ` diff --git a/src/core/prompts/sections/markdown-formatting.ts b/src/core/prompts/sections/markdown-formatting.ts index 87f922e94a..0e47385632 100644 --- a/src/core/prompts/sections/markdown-formatting.ts +++ b/src/core/prompts/sections/markdown-formatting.ts @@ -3,5 +3,5 @@ export function markdownFormattingSection(): string { MARKDOWN RULES -ALL responses MUST show ANY \`language construct\` OR filename reference as clickable, exactly as [\`filename OR language.declaration()\`](relative/file/path.ext:line); line is required for \`syntax\` and optional for filename links. This applies to ALL markdown responses and ALSO those in ` +ALL responses MUST show ANY \`language construct\` OR filename reference as clickable, exactly as [\`filename OR language.declaration()\`](relative/file/path.ext:line); line is required for \`syntax\` and optional for filename links. This applies to ALL markdown responses and ALSO those in attempt_completion` } diff --git a/src/core/prompts/sections/mcp-servers.ts b/src/core/prompts/sections/mcp-servers.ts index 643233ab6f..678099922f 100644 --- a/src/core/prompts/sections/mcp-servers.ts +++ b/src/core/prompts/sections/mcp-servers.ts @@ -5,6 +5,7 @@ export async function getMcpServersSection( mcpHub?: McpHub, diffStrategy?: DiffStrategy, enableMcpServerCreation?: boolean, + includeToolDescriptions: boolean = true, ): Promise { if (!mcpHub) { return "" @@ -16,17 +17,20 @@ export async function getMcpServersSection( .getServers() .filter((server) => server.status === "connected") .map((server) => { - const tools = server.tools - ?.filter((tool) => tool.enabledForPrompt !== false) - ?.map((tool) => { - const schemaStr = tool.inputSchema - ? ` Input Schema: + // Only include tool descriptions when using XML protocol + const tools = includeToolDescriptions + ? server.tools + ?.filter((tool) => tool.enabledForPrompt !== false) + ?.map((tool) => { + const schemaStr = tool.inputSchema + ? ` Input Schema: ${JSON.stringify(tool.inputSchema, null, 2).split("\n").join("\n ")}` - : "" + : "" - return `- ${tool.name}: ${tool.description}\n${schemaStr}` - }) - .join("\n\n") + return `- ${tool.name}: ${tool.description}\n${schemaStr}` + }) + .join("\n\n") + : undefined const templates = server.resourceTemplates ?.map((template) => `- ${template.uriTemplate} (${template.name}): ${template.description}`) diff --git a/src/core/prompts/sections/modes.ts b/src/core/prompts/sections/modes.ts index 5a0219c7a1..1925405aa8 100644 --- a/src/core/prompts/sections/modes.ts +++ b/src/core/prompts/sections/modes.ts @@ -5,7 +5,10 @@ import type { ModeConfig } from "@roo-code/types" import { getAllModesWithPrompts } from "../../../shared/modes" import { ensureSettingsDirectoryExists } from "../../../utils/globalContext" -export async function getModesSection(context: vscode.ExtensionContext): Promise { +export async function getModesSection( + context: vscode.ExtensionContext, + skipXmlExamples: boolean = false, +): Promise { // Make sure path gets created await ensureSettingsDirectoryExists(context) @@ -31,12 +34,18 @@ ${allModes }) .join("\n")}` - modesContent += ` + if (!skipXmlExamples) { + modesContent += ` If the user asks you to create or edit a new mode for this project, you should read the instructions by using the fetch_instructions tool, like this: create_mode ` + } else { + modesContent += ` +If the user asks you to create or edit a new mode for this project, you should read the instructions by using the fetch_instructions tool. +` + } return modesContent } diff --git a/src/core/prompts/sections/rules.ts b/src/core/prompts/sections/rules.ts index 0eceb2dc98..9e98773ed2 100644 --- a/src/core/prompts/sections/rules.ts +++ b/src/core/prompts/sections/rules.ts @@ -1,43 +1,77 @@ import { DiffStrategy } from "../../../shared/tools" import { CodeIndexManager } from "../../../services/code-index/manager" import type { SystemPromptSettings } from "../types" -import { getEffectiveProtocol, isNativeProtocol } from "../toolProtocolResolver" +import { getEffectiveProtocol, isNativeProtocol } from "@roo-code/types" +import type { ModeConfig, ToolName } from "@roo-code/types" +import { getAvailableToolsInGroup } from "../tools/filter-tools-for-mode" + +function getEditingInstructions( + mode: string, + customModes: ModeConfig[] | undefined, + experiments: Record | undefined, + codeIndexManager: CodeIndexManager | undefined, + settings: SystemPromptSettings | undefined, + diffStrategy?: DiffStrategy, +): string { + // Get available editing tools from the edit group + const availableEditTools = getAvailableToolsInGroup( + "edit", + mode, + customModes, + experiments, + codeIndexManager, + settings, + ) + + // Filter for the main editing tools we care about + const hasApplyDiff = diffStrategy && availableEditTools.includes("apply_diff" as ToolName) + const hasWriteToFile = availableEditTools.includes("write_to_file" as ToolName) + const hasInsertContent = availableEditTools.includes("insert_content" as ToolName) + + // If no editing tools are available, return empty string + if (availableEditTools.length === 0) { + return "" + } -function getEditingInstructions(diffStrategy?: DiffStrategy): string { const instructions: string[] = [] const availableTools: string[] = [] // Collect available editing tools - if (diffStrategy) { - availableTools.push( - "apply_diff (for surgical edits - targeted changes to specific lines or functions)", - "write_to_file (for creating new files or complete file rewrites)", - ) - } else { + if (hasApplyDiff) { + availableTools.push("apply_diff (for surgical edits - targeted changes to specific lines or functions)") + } + if (hasWriteToFile) { availableTools.push("write_to_file (for creating new files or complete file rewrites)") } - - availableTools.push("insert_content (for adding lines to files)") + if (hasInsertContent) { + availableTools.push("insert_content (for adding lines to files)") + } // Base editing instruction mentioning all available tools - if (availableTools.length > 1) { + if (availableTools.length > 0) { instructions.push(`- For editing files, you have access to these tools: ${availableTools.join(", ")}.`) } - // Additional details for experimental features - instructions.push( - "- The insert_content tool adds lines of text to files at a specific line number, such as adding a new function to a JavaScript file or inserting a new route in a Python file. Use line number 0 to append at the end of the file, or any positive number to insert before that line.", - ) + // Additional details for insert_content + if (hasInsertContent) { + instructions.push( + "- The insert_content tool adds lines of text to files at a specific line number, such as adding a new function to a JavaScript file or inserting a new route in a Python file. Use line number 0 to append at the end of the file, or any positive number to insert before that line.", + ) + } - if (availableTools.length > 1) { + // Preference instruction if multiple tools are available + if (availableTools.length > 1 && hasWriteToFile) { instructions.push( "- You should always prefer using other editing tools over write_to_file when making changes to existing files since write_to_file is much slower and cannot handle large files.", ) } - instructions.push( - "- When using the write_to_file tool to modify a file, use the tool directly with the desired content. You do not need to display the content before using the tool. ALWAYS provide the COMPLETE file content in your response. This is NON-NEGOTIABLE. Partial updates or placeholders like '// rest of code unchanged' are STRICTLY FORBIDDEN. You MUST include ALL parts of the file, even if they haven't been modified. Failure to do so will result in incomplete or broken code, severely impacting the user's project.", - ) + // Write to file instructions + if (hasWriteToFile) { + instructions.push( + "- When using the write_to_file tool to modify a file, use the tool directly with the desired content. You do not need to display the content before using the tool. ALWAYS provide the COMPLETE file content in your response. This is NON-NEGOTIABLE. Partial updates or placeholders like '// rest of code unchanged' are STRICTLY FORBIDDEN. You MUST include ALL parts of the file, even if they haven't been modified. Failure to do so will result in incomplete or broken code, severely impacting the user's project.", + ) + } return instructions.join("\n") } @@ -45,6 +79,9 @@ function getEditingInstructions(diffStrategy?: DiffStrategy): string { export function getRulesSection( cwd: string, supportsComputerUse: boolean, + mode: string, + customModes: ModeConfig[] | undefined, + experiments: Record | undefined, diffStrategy?: DiffStrategy, codeIndexManager?: CodeIndexManager, settings?: SystemPromptSettings, @@ -59,8 +96,41 @@ export function getRulesSection( ? "- **CRITICAL: For ANY exploration of code you haven't examined yet in this conversation, you MUST use the `codebase_search` tool FIRST before using search_files or other file exploration tools.** This requirement applies throughout the entire conversation, not just when starting a task. The codebase_search tool uses semantic search to find relevant code based on meaning, not just keywords, making it much more effective for understanding how features are implemented. Even if you've already explored some parts of the codebase, any new area or functionality you need to understand requires using codebase_search first.\n" : "" + // Get available tools from relevant groups + const availableEditTools = getAvailableToolsInGroup( + "edit", + mode, + customModes, + experiments, + codeIndexManager, + settings, + ) + const availableBrowserTools = getAvailableToolsInGroup( + "browser", + mode, + customModes, + experiments, + codeIndexManager, + settings, + ) + + // Check which editing tools are available for the search_files tool description + const hasApplyDiff = diffStrategy && availableEditTools.includes("apply_diff" as ToolName) + const hasWriteToFile = availableEditTools.includes("write_to_file" as ToolName) + const hasBrowserAction = supportsComputerUse && availableBrowserTools.includes("browser_action" as ToolName) + + // Build editing tools reference for search_files description + let editingToolsRef = "" + if (hasApplyDiff && hasWriteToFile) { + editingToolsRef = "apply_diff or write_to_file" + } else if (hasApplyDiff) { + editingToolsRef = "apply_diff" + } else if (hasWriteToFile) { + editingToolsRef = "write_to_file" + } + // Determine whether to use XML tool references based on protocol - const effectiveProtocol = getEffectiveProtocol(settings) + const effectiveProtocol = getEffectiveProtocol(settings?.toolProtocol) return `==== @@ -71,9 +141,19 @@ RULES - You cannot \`cd\` into a different directory to complete a task. You are stuck operating from '${cwd.toPosix()}', so be sure to pass in the correct 'path' parameter when using tools that require a path. - Do not use the ~ character or $HOME to refer to the home directory. - Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '${cwd.toPosix()}', and if so prepend with \`cd\`'ing into that directory && then executing the command (as one command since you are stuck operating from '${cwd.toPosix()}'). For example, if you needed to run \`npm install\` in a project outside of '${cwd.toPosix()}', you would need to prepend with a \`cd\` i.e. pseudocode for this would be \`cd (path to project) && (command, in this case npm install)\`. -${codebaseSearchRule}- When using the search_files tool${isCodebaseSearchAvailable ? " (after codebase_search)" : ""}, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. Leverage the search_files tool in combination with other tools for more comprehensive analysis. For example, use it to find specific code patterns, then use read_file to examine the full context of interesting matches before using ${diffStrategy ? "apply_diff or write_to_file" : "write_to_file"} to make informed changes. -- When creating a new project (such as an app, website, or any software project), organize all new files within a dedicated project directory unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the project logically, adhering to best practices for the specific type of project being created. Unless otherwise specified, new projects should be easily run without additional setup, for example most projects can be built in HTML, CSS, and JavaScript - which you can open in a browser. -${getEditingInstructions(diffStrategy)} +${codebaseSearchRule}${ + editingToolsRef + ? `- When using the search_files tool${isCodebaseSearchAvailable ? " (after codebase_search)" : ""}, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. Leverage the search_files tool in combination with other tools for more comprehensive analysis. For example, use it to find specific code patterns, then use read_file to examine the full context of interesting matches before using ${editingToolsRef} to make informed changes. +` + : `- When using the search_files tool${isCodebaseSearchAvailable ? " (after codebase_search)" : ""}, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. +` + }${ + hasWriteToFile + ? `- When creating a new project (such as an app, website, or any software project), organize all new files within a dedicated project directory unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the project logically, adhering to best practices for the specific type of project being created. Unless otherwise specified, new projects should be easily run without additional setup, for example most projects can be built in HTML, CSS, and JavaScript - which you can open in a browser. +` + : "" + } +${getEditingInstructions(mode, customModes, experiments, codeIndexManager, settings, diffStrategy)} - Some modes have restrictions on which files they can edit. If you attempt to edit a restricted file, the operation will be rejected with a FileRestrictionError that will specify which file patterns are allowed for the current mode. - Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write. * For example, in architect mode trying to edit app.js would be rejected because architect mode can only edit files matching "\\.md$" @@ -83,7 +163,7 @@ ${getEditingInstructions(diffStrategy)} - When executing commands, if you don't see the expected output, assume the terminal executed the command successfully and proceed with the task. The user's terminal may be unable to stream the output back properly. If you absolutely need to see the actual terminal output, use the ask_followup_question tool to request the user to copy and paste it back to you. - The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it. - Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation.${ - supportsComputerUse + hasBrowserAction ? '\n- The user may ask generic non-development tasks, such as "what\'s the latest news" or "look up the weather in San Diego", in which case you might use the browser_action 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.' : "" } @@ -94,7 +174,7 @@ ${getEditingInstructions(diffStrategy)} - Before executing commands, check the "Actively Running Terminals" section in environment_details. If present, consider how these active processes might impact your task. For example, if a local development server is already running, you wouldn't need to start it again. If no active terminals are listed, proceed with command execution as normal. - MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations. - It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to make a todo app, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc.${ - supportsComputerUse + hasBrowserAction ? " Then if you want to test your work, you might use browser_action to launch the site, wait for the user's response confirming the site was launched along with a screenshot, then perhaps e.g., click a button to test functionality if needed, wait for the user's response confirming the button was clicked along with a screenshot of the new state, before finally closing the browser." : "" }` diff --git a/src/core/prompts/sections/tool-use-guidelines.ts b/src/core/prompts/sections/tool-use-guidelines.ts index 6258a1f13a..c5651264a0 100644 --- a/src/core/prompts/sections/tool-use-guidelines.ts +++ b/src/core/prompts/sections/tool-use-guidelines.ts @@ -1,6 +1,6 @@ import { ToolProtocol, TOOL_PROTOCOL } from "@roo-code/types" import { CodeIndexManager } from "../../../services/code-index/manager" -import { isNativeProtocol } from "../toolProtocolResolver" +import { isNativeProtocol } from "@roo-code/types" export function getToolUseGuidelinesSection( codeIndexManager?: CodeIndexManager, diff --git a/src/core/prompts/sections/tool-use.ts b/src/core/prompts/sections/tool-use.ts index e3f54a7d18..9ece848fb4 100644 --- a/src/core/prompts/sections/tool-use.ts +++ b/src/core/prompts/sections/tool-use.ts @@ -1,5 +1,4 @@ -import { ToolProtocol, TOOL_PROTOCOL } from "@roo-code/types" -import { isNativeProtocol } from "../toolProtocolResolver" +import { ToolProtocol, TOOL_PROTOCOL, isNativeProtocol } from "@roo-code/types" export function getSharedToolUseSection(protocol: ToolProtocol = TOOL_PROTOCOL.XML): string { if (isNativeProtocol(protocol)) { diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 3d45f7fcf0..9230619ebd 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -16,7 +16,7 @@ import { CodeIndexManager } from "../../services/code-index/manager" import { PromptVariables, loadSystemPromptFile } from "./sections/custom-system-prompt" import { getToolDescriptionsForMode } from "./tools" -import { getEffectiveProtocol, isNativeProtocol } from "./toolProtocolResolver" +import { getEffectiveProtocol, isNativeProtocol } from "@roo-code/types" import { getRulesSection, getSystemInfoSection, @@ -29,7 +29,6 @@ import { addCustomInstructions, markdownFormattingSection, } from "./sections" -import { TOOL_PROTOCOL } from "@roo-code/types" // Helper function to get prompt component, filtering out empty objects export function getPromptComponent( @@ -81,17 +80,22 @@ async function generatePrompt( const hasMcpServers = mcpHub && mcpHub.getServers().length > 0 const shouldIncludeMcp = hasMcpGroup && hasMcpServers - const [modesSection, mcpServersSection] = await Promise.all([ - getModesSection(context), - shouldIncludeMcp - ? getMcpServersSection(mcpHub, effectiveDiffStrategy, enableMcpServerCreation) - : Promise.resolve(""), - ]) - const codeIndexManager = CodeIndexManager.getInstance(context, cwd) // Determine the effective protocol (defaults to 'xml') - const effectiveProtocol = getEffectiveProtocol(settings) + const effectiveProtocol = getEffectiveProtocol(settings?.toolProtocol) + + const [modesSection, mcpServersSection] = await Promise.all([ + getModesSection(context), + shouldIncludeMcp + ? getMcpServersSection( + mcpHub, + effectiveDiffStrategy, + enableMcpServerCreation, + !isNativeProtocol(effectiveProtocol), + ) + : Promise.resolve(""), + ]) // Build tools catalog section only for XML protocol const toolsCatalog = isNativeProtocol(effectiveProtocol) @@ -122,11 +126,11 @@ ${getToolUseGuidelinesSection(codeIndexManager, effectiveProtocol)} ${mcpServersSection} -${getCapabilitiesSection(cwd, supportsComputerUse, shouldIncludeMcp ? mcpHub : undefined, effectiveDiffStrategy, codeIndexManager)} +${getCapabilitiesSection(cwd, supportsComputerUse, mode, customModeConfigs, experiments, shouldIncludeMcp ? mcpHub : undefined, effectiveDiffStrategy, codeIndexManager, settings)} ${modesSection} -${getRulesSection(cwd, supportsComputerUse, effectiveDiffStrategy, codeIndexManager, settings)} +${getRulesSection(cwd, supportsComputerUse, mode, customModeConfigs, experiments, effectiveDiffStrategy, codeIndexManager, settings)} ${getSystemInfoSection(cwd)} diff --git a/src/core/prompts/toolProtocolResolver.ts b/src/core/prompts/toolProtocolResolver.ts deleted file mode 100644 index 1cd87f7251..0000000000 --- a/src/core/prompts/toolProtocolResolver.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ToolProtocol, TOOL_PROTOCOL } from "@roo-code/types" -import type { SystemPromptSettings } from "./types" - -/** - * Current tool protocol setting. - * This is code-only and not exposed through VS Code settings. - * To switch protocols, edit this constant directly in the source code. - */ -const CURRENT_TOOL_PROTOCOL: ToolProtocol = TOOL_PROTOCOL.XML // change to TOOL_PROTOCOL.NATIVE to enable native protocol - -/** - * Resolves the effective tool protocol. - * - * @returns The effective tool protocol (defaults to "xml") - */ -export function resolveToolProtocol(): ToolProtocol { - return CURRENT_TOOL_PROTOCOL -} - -/** - * Gets the effective protocol from settings or falls back to the default. - * - * @param settings - Optional system prompt settings - * @returns The effective tool protocol - */ -export function getEffectiveProtocol(settings?: SystemPromptSettings): ToolProtocol { - return settings?.toolProtocol || resolveToolProtocol() -} - -/** - * Checks if the protocol is native (non-XML). - * - * @param protocol - The tool protocol to check - * @returns True if protocol is native - */ -export function isNativeProtocol(protocol: ToolProtocol): boolean { - return protocol === TOOL_PROTOCOL.NATIVE -} 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 new file mode 100644 index 0000000000..86aa39b6bf --- /dev/null +++ b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts @@ -0,0 +1,319 @@ +import { describe, it, expect } from "vitest" +import type OpenAI from "openai" +import type { ModeConfig } from "@roo-code/types" +import { filterNativeToolsForMode, filterMcpToolsForMode } from "../filter-tools-for-mode" + +describe("filterNativeToolsForMode", () => { + const mockNativeTools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "read_file", + description: "Read files", + parameters: {}, + }, + }, + { + type: "function", + function: { + name: "write_to_file", + description: "Write files", + parameters: {}, + }, + }, + { + type: "function", + function: { + name: "apply_diff", + description: "Apply diff", + parameters: {}, + }, + }, + { + type: "function", + function: { + name: "execute_command", + description: "Execute command", + parameters: {}, + }, + }, + { + type: "function", + function: { + name: "browser_action", + description: "Browser action", + parameters: {}, + }, + }, + { + type: "function", + function: { + name: "ask_followup_question", + description: "Ask question", + parameters: {}, + }, + }, + { + type: "function", + function: { + name: "attempt_completion", + description: "Complete task", + parameters: {}, + }, + }, + ] + + it("should filter tools for architect mode (read, browser, mcp only)", () => { + const architectMode: ModeConfig = { + slug: "architect", + name: "Architect", + roleDefinition: "Test", + groups: ["read", "browser", "mcp"] as const, + } + + const filtered = filterNativeToolsForMode(mockNativeTools, "architect", [architectMode], {}, undefined, {}) + + const toolNames = filtered.map((t) => ("function" in t ? t.function.name : "")) + + // Should include read tools + expect(toolNames).toContain("read_file") + + // Should NOT include edit tools + expect(toolNames).not.toContain("write_to_file") + expect(toolNames).not.toContain("apply_diff") + + // Should NOT include command tools + expect(toolNames).not.toContain("execute_command") + + // Should include browser tools + expect(toolNames).toContain("browser_action") + + // Should ALWAYS include always-available tools + expect(toolNames).toContain("ask_followup_question") + expect(toolNames).toContain("attempt_completion") + }) + + it("should filter tools for code mode (all groups)", () => { + const codeMode: ModeConfig = { + slug: "code", + name: "Code", + roleDefinition: "Test", + groups: ["read", "edit", "browser", "command", "mcp"] as const, + } + + const filtered = filterNativeToolsForMode(mockNativeTools, "code", [codeMode], {}, undefined, {}) + + const toolNames = filtered.map((t) => ("function" in t ? t.function.name : "")) + + // Should include all tools (code mode has all groups) + expect(toolNames).toContain("read_file") + expect(toolNames).toContain("write_to_file") + expect(toolNames).toContain("apply_diff") + expect(toolNames).toContain("execute_command") + expect(toolNames).toContain("browser_action") + expect(toolNames).toContain("ask_followup_question") + expect(toolNames).toContain("attempt_completion") + }) + + it("should always include always-available tools regardless of mode groups", () => { + const restrictiveMode: ModeConfig = { + slug: "restrictive", + name: "Restrictive", + roleDefinition: "Test", + groups: [] as const, // No groups + } + + const filtered = filterNativeToolsForMode(mockNativeTools, "restrictive", [restrictiveMode], {}, undefined, {}) + + const toolNames = filtered.map((t) => ("function" in t ? t.function.name : "")) + + // Should still include always-available tools + expect(toolNames).toContain("ask_followup_question") + expect(toolNames).toContain("attempt_completion") + + // Should NOT include any other tools + expect(toolNames).not.toContain("read_file") + expect(toolNames).not.toContain("write_to_file") + expect(toolNames).not.toContain("execute_command") + }) + + it("should handle undefined mode by using default mode", () => { + const filtered = filterNativeToolsForMode(mockNativeTools, undefined, undefined, {}, undefined, {}) + + // Should return some tools (default mode is code which has all groups) + expect(filtered.length).toBeGreaterThan(0) + + const toolNames = filtered.map((t) => ("function" in t ? t.function.name : "")) + expect(toolNames).toContain("ask_followup_question") + expect(toolNames).toContain("attempt_completion") + }) + + it("should exclude codebase_search when codeIndexManager is not configured", () => { + const codeMode: ModeConfig = { + slug: "code", + name: "Code", + roleDefinition: "Test", + groups: ["read", "edit", "browser", "command", "mcp"] as const, + } + + const mockCodebaseSearchTool: OpenAI.Chat.ChatCompletionTool = { + type: "function", + function: { + name: "codebase_search", + description: "Search codebase", + parameters: {}, + }, + } + + const toolsWithCodebaseSearch = [...mockNativeTools, mockCodebaseSearchTool] + + // Without codeIndexManager + const filtered = filterNativeToolsForMode(toolsWithCodebaseSearch, "code", [codeMode], {}, undefined, {}) + const toolNames = filtered.map((t) => ("function" in t ? t.function.name : "")) + expect(toolNames).not.toContain("codebase_search") + }) + + it("should exclude update_todo_list when todoListEnabled is false", () => { + const codeMode: ModeConfig = { + slug: "code", + name: "Code", + roleDefinition: "Test", + groups: ["read", "edit", "browser", "command", "mcp"] as const, + } + + const mockTodoTool: OpenAI.Chat.ChatCompletionTool = { + type: "function", + function: { + name: "update_todo_list", + description: "Update todo list", + parameters: {}, + }, + } + + const toolsWithTodo = [...mockNativeTools, mockTodoTool] + + const filtered = filterNativeToolsForMode(toolsWithTodo, "code", [codeMode], {}, undefined, { + todoListEnabled: false, + }) + const toolNames = filtered.map((t) => ("function" in t ? t.function.name : "")) + expect(toolNames).not.toContain("update_todo_list") + }) + + it("should exclude generate_image when experiment is not enabled", () => { + const codeMode: ModeConfig = { + slug: "code", + name: "Code", + roleDefinition: "Test", + groups: ["read", "edit", "browser", "command", "mcp"] as const, + } + + const mockImageTool: OpenAI.Chat.ChatCompletionTool = { + type: "function", + function: { + name: "generate_image", + description: "Generate image", + parameters: {}, + }, + } + + const toolsWithImage = [...mockNativeTools, mockImageTool] + + const filtered = filterNativeToolsForMode( + toolsWithImage, + "code", + [codeMode], + { imageGeneration: false }, + undefined, + {}, + ) + const toolNames = filtered.map((t) => ("function" in t ? t.function.name : "")) + expect(toolNames).not.toContain("generate_image") + }) + + it("should exclude run_slash_command when experiment is not enabled", () => { + const codeMode: ModeConfig = { + slug: "code", + name: "Code", + roleDefinition: "Test", + groups: ["read", "edit", "browser", "command", "mcp"] as const, + } + + const mockSlashCommandTool: OpenAI.Chat.ChatCompletionTool = { + type: "function", + function: { + name: "run_slash_command", + description: "Run slash command", + parameters: {}, + }, + } + + const toolsWithSlashCommand = [...mockNativeTools, mockSlashCommandTool] + + const filtered = filterNativeToolsForMode( + toolsWithSlashCommand, + "code", + [codeMode], + { runSlashCommand: false }, + undefined, + {}, + ) + const toolNames = filtered.map((t) => ("function" in t ? t.function.name : "")) + expect(toolNames).not.toContain("run_slash_command") + }) +}) + +describe("filterMcpToolsForMode", () => { + const mockMcpTools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "mcp_server1_tool1", + description: "MCP tool 1", + parameters: {}, + }, + }, + { + type: "function", + function: { + name: "mcp_server1_tool2", + description: "MCP tool 2", + parameters: {}, + }, + }, + ] + + it("should include MCP tools when mode has mcp group", () => { + const modeWithMcp: ModeConfig = { + slug: "test-with-mcp", + name: "Test", + roleDefinition: "Test", + groups: ["read", "mcp"] as const, + } + + const filtered = filterMcpToolsForMode(mockMcpTools, "test-with-mcp", [modeWithMcp], {}) + + expect(filtered).toHaveLength(2) + expect(filtered).toEqual(mockMcpTools) + }) + + it("should exclude MCP tools when mode does not have mcp group", () => { + const modeWithoutMcp: ModeConfig = { + slug: "test-no-mcp", + name: "Test", + roleDefinition: "Test", + groups: ["read", "edit"] as const, + } + + const filtered = filterMcpToolsForMode(mockMcpTools, "test-no-mcp", [modeWithoutMcp], {}) + + expect(filtered).toHaveLength(0) + }) + + it("should handle undefined mode by using default mode", () => { + // Default mode (code) has mcp group + const filtered = filterMcpToolsForMode(mockMcpTools, undefined, undefined, {}) + + // Should include MCP tools since default mode has mcp group + expect(filtered.length).toBeGreaterThan(0) + }) +}) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts new file mode 100644 index 0000000000..45bfb31615 --- /dev/null +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -0,0 +1,206 @@ +import type OpenAI from "openai" +import type { ModeConfig, ToolName, ToolGroup } from "@roo-code/types" +import { getModeBySlug, getToolsForMode, isToolAllowedForMode } from "../../../shared/modes" +import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS } from "../../../shared/tools" +import { defaultModeSlug } from "../../../shared/modes" +import type { CodeIndexManager } from "../../../services/code-index/manager" + +/** + * Filters native tools based on mode restrictions. + * This ensures native tools are filtered the same way XML tools are filtered in the system prompt. + * + * @param nativeTools - Array of all available native tools + * @param mode - Current mode slug + * @param customModes - Custom mode configurations + * @param experiments - Experiment flags + * @param codeIndexManager - Code index manager for codebase_search feature check + * @param settings - Additional settings for tool filtering + * @returns Filtered array of tools allowed for the mode + */ +export function filterNativeToolsForMode( + nativeTools: OpenAI.Chat.ChatCompletionTool[], + mode: string | undefined, + customModes: ModeConfig[] | undefined, + experiments: Record | undefined, + codeIndexManager?: CodeIndexManager, + settings?: Record, +): OpenAI.Chat.ChatCompletionTool[] { + // Get mode configuration and all tools for this mode + const modeSlug = mode ?? defaultModeSlug + let modeConfig = getModeBySlug(modeSlug, customModes) + + // Fallback to default mode if current mode config is not found + // This ensures the agent always has functional tools even if a custom mode is deleted + // or configuration becomes corrupted + if (!modeConfig) { + modeConfig = getModeBySlug(defaultModeSlug, customModes)! + } + + // Get all tools for this mode (including always-available tools) + const allToolsForMode = getToolsForMode(modeConfig.groups) + + // Filter to only tools that pass permission checks + const allowedToolNames = new Set( + allToolsForMode.filter((tool) => + isToolAllowedForMode( + tool as ToolName, + modeSlug, + customModes ?? [], + undefined, + undefined, + experiments ?? {}, + ), + ), + ) + + // Conditionally exclude codebase_search if feature is disabled or not configured + if ( + !codeIndexManager || + !(codeIndexManager.isFeatureEnabled && codeIndexManager.isFeatureConfigured && codeIndexManager.isInitialized) + ) { + allowedToolNames.delete("codebase_search") + } + + // Conditionally exclude update_todo_list if disabled in settings + if (settings?.todoListEnabled === false) { + allowedToolNames.delete("update_todo_list") + } + + // Conditionally exclude generate_image if experiment is not enabled + if (!experiments?.imageGeneration) { + allowedToolNames.delete("generate_image") + } + + // Conditionally exclude run_slash_command if experiment is not enabled + if (!experiments?.runSlashCommand) { + allowedToolNames.delete("run_slash_command") + } + + // Conditionally exclude browser_action if disabled in settings + if (settings?.browserToolEnabled === false) { + allowedToolNames.delete("browser_action") + } + + // Filter native tools based on allowed tool names + return nativeTools.filter((tool) => { + // Handle both ChatCompletionTool and ChatCompletionCustomTool + if ("function" in tool && tool.function) { + return allowedToolNames.has(tool.function.name) + } + return false + }) +} + +/** + * Checks if a specific tool is allowed in the current mode. + * This is useful for dynamically filtering system prompt content. + * + * @param toolName - Name of the tool to check + * @param mode - Current mode slug + * @param customModes - Custom mode configurations + * @param experiments - Experiment flags + * @param codeIndexManager - Code index manager for codebase_search feature check + * @param settings - Additional settings for tool filtering + * @returns true if the tool is allowed in the mode, false otherwise + */ +export function isToolAllowedInMode( + toolName: ToolName, + mode: string | undefined, + customModes: ModeConfig[] | undefined, + experiments: Record | undefined, + codeIndexManager?: CodeIndexManager, + settings?: Record, +): boolean { + const modeSlug = mode ?? defaultModeSlug + + // Check if it's an always-available tool + if (ALWAYS_AVAILABLE_TOOLS.includes(toolName)) { + // But still check for conditional exclusions + if (toolName === "codebase_search") { + return !!( + codeIndexManager && + codeIndexManager.isFeatureEnabled && + codeIndexManager.isFeatureConfigured && + codeIndexManager.isInitialized + ) + } + if (toolName === "update_todo_list") { + return settings?.todoListEnabled !== false + } + if (toolName === "generate_image") { + return experiments?.imageGeneration === true + } + if (toolName === "run_slash_command") { + return experiments?.runSlashCommand === true + } + 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 + return isToolAllowedForMode(toolName, modeSlug, customModes ?? [], undefined, undefined, experiments ?? {}) +} + +/** + * Gets the list of available tools from a specific tool group for the current mode. + * This is useful for dynamically building system prompt content based on available tools. + * + * @param groupName - Name of the tool group to check + * @param mode - Current mode slug + * @param customModes - Custom mode configurations + * @param experiments - Experiment flags + * @param codeIndexManager - Code index manager for codebase_search feature check + * @param settings - Additional settings for tool filtering + * @returns Array of tool names that are available from the group + */ +export function getAvailableToolsInGroup( + groupName: ToolGroup, + mode: string | undefined, + customModes: ModeConfig[] | undefined, + experiments: Record | undefined, + codeIndexManager?: CodeIndexManager, + settings?: Record, +): ToolName[] { + const toolGroup = TOOL_GROUPS[groupName] + if (!toolGroup) { + return [] + } + + return toolGroup.tools.filter((tool) => + isToolAllowedInMode(tool as ToolName, mode, customModes, experiments, codeIndexManager, settings), + ) as ToolName[] +} + +/** + * Filters MCP tools based on whether use_mcp_tool is allowed in the current mode. + * + * @param mcpTools - Array of MCP tools + * @param mode - Current mode slug + * @param customModes - Custom mode configurations + * @param experiments - Experiment flags + * @returns Filtered array of MCP tools if use_mcp_tool is allowed, empty array otherwise + */ +export function filterMcpToolsForMode( + mcpTools: OpenAI.Chat.ChatCompletionTool[], + mode: string | undefined, + customModes: ModeConfig[] | undefined, + experiments: Record | undefined, +): OpenAI.Chat.ChatCompletionTool[] { + const modeSlug = mode ?? defaultModeSlug + + // MCP tools are always in the mcp group, check if use_mcp_tool is allowed + const isMcpAllowed = isToolAllowedForMode( + "use_mcp_tool", + modeSlug, + customModes ?? [], + undefined, + undefined, + experiments ?? {}, + ) + + return isMcpAllowed ? mcpTools : [] +} diff --git a/src/core/prompts/tools/native-tools/apply_diff.ts b/src/core/prompts/tools/native-tools/apply_diff.ts index 2c7351d4cf..19ef831884 100644 --- a/src/core/prompts/tools/native-tools/apply_diff.ts +++ b/src/core/prompts/tools/native-tools/apply_diff.ts @@ -19,12 +19,12 @@ Apply precise, targeted modifications to an existing file using one or more sear description: ` A string containing one or more search/replace blocks defining the changes. The ':start_line:' is required and indicates the starting line number of the original content. You must not add a start line for the replacement content. Each block must follow this format: <<<<<<< SEARCH - :start_line:[line_number] - ------- - [exact content to find] - ======= - [new content to replace with] - >>>>>>> REPLACE +:start_line:[line_number] +------- +[exact content to find] +======= +[new content to replace with] +>>>>>>> REPLACE `, }, }, @@ -33,61 +33,3 @@ A string containing one or more search/replace blocks defining the changes. The }, }, } satisfies OpenAI.Chat.ChatCompletionTool - -//@ts-ignore Preparing for when we enable multi-file diffs -export const apply_diff_multi_file = { - type: "function", - function: { - name: "apply_diff", - description: - "Apply precise, targeted modifications to one or more files by searching for specific sections of content and replacing them. This tool is for surgical edits only and supports making changes across multiple files in a single request. The 'SEARCH' block must exactly match the existing content, including whitespace and indentation. You must use this tool to edit multiple files in a single operation whenever possible.", - parameters: { - type: "object", - properties: { - files: { - type: "array", - description: "A list of file modification operations to perform.", - items: { - type: "object", - properties: { - path: { - type: "string", - description: - "The path of the file to modify, relative to the current workspace directory.", - }, - diffs: { - type: "array", - description: - "A list of diffs to apply to the file. Each diff is a distinct search/replace operation.", - items: { - type: "object", - properties: { - content: { - type: "string", - description: ` -The search/replace block defining the changes. The SEARCH block must exactly match the content to be replaced. Format: -'<<<<<<< SEARCH -[content_to_find] -======= -[content_to_replace_with] ->>>>>>> REPLACE - `, - }, - start_line: { - type: "integer", - description: - "The line number in the original file where the SEARCH block begins.", - }, - }, - required: ["content", "start_line"], - }, - }, - }, - required: ["path", "diffs"], - }, - }, - }, - required: ["files"], - }, - }, -} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/prompts/tools/native-tools/edit_file.ts b/src/core/prompts/tools/native-tools/edit_file.ts deleted file mode 100644 index f4dcfeeaa4..0000000000 --- a/src/core/prompts/tools/native-tools/edit_file.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type OpenAI from "openai" - -export default { - type: "function", - function: { - name: "edit_file", - description: - "Use this tool to make an edit to a file. A less intelligent apply model will read your request, so be clear about the change while minimizing unchanged code. Specify each edit sequentially and replace omitted sections with // ... existing code ... placeholders. Provide enough surrounding context to avoid ambiguity, always use the placeholder when skipping existing content, show before-and-after context when deleting, and gather all edits for the file in a single request.", - strict: true, - parameters: { - type: "object", - properties: { - target_file: { - type: "string", - description: "Full path of the file to modify", - }, - instructions: { - type: "string", - description: "Single first-person sentence summarizing the edit to guide the apply model", - }, - code_edit: { - type: "string", - description: - "Only the edited lines using // ... existing code ... wherever unchanged content is omitted", - }, - }, - required: ["target_file", "instructions", "code_edit"], - 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 a25a67931b..c12a681704 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -3,7 +3,6 @@ import askFollowupQuestion from "./ask_followup_question" import attemptCompletion from "./attempt_completion" import browserAction from "./browser_action" import codebaseSearch from "./codebase_search" -import editFile from "./edit_file" import executeCommand from "./execute_command" import fetchInstructions from "./fetch_instructions" import generateImage from "./generate_image" @@ -11,26 +10,23 @@ import insertContent from "./insert_content" import listCodeDefinitionNames from "./list_code_definition_names" import listFiles from "./list_files" import newTask from "./new_task" -import { read_file_single, read_file_multi } from "./read_file" +import { read_file } from "./read_file" import runSlashCommand from "./run_slash_command" -import searchAndReplace from "./search_and_replace" import searchFiles from "./search_files" import switchMode from "./switch_mode" import updateTodoList from "./update_todo_list" import writeToFile from "./write_to_file" -import { apply_diff_single_file, apply_diff_multi_file } from "./apply_diff" +import { apply_diff_single_file } from "./apply_diff" export { getMcpServerTools } from "./mcp_server" export { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "./converters" export const nativeTools = [ apply_diff_single_file, - apply_diff_multi_file, askFollowupQuestion, attemptCompletion, browserAction, codebaseSearch, - editFile, executeCommand, fetchInstructions, generateImage, @@ -38,10 +34,8 @@ export const nativeTools = [ listCodeDefinitionNames, listFiles, newTask, - read_file_single, - read_file_multi, + read_file, runSlashCommand, - searchAndReplace, searchFiles, switchMode, updateTodoList, diff --git a/src/core/prompts/tools/native-tools/list_files.ts b/src/core/prompts/tools/native-tools/list_files.ts index 6f48ec3e88..b8069837ff 100644 --- a/src/core/prompts/tools/native-tools/list_files.ts +++ b/src/core/prompts/tools/native-tools/list_files.ts @@ -15,7 +15,7 @@ export default { description: "Directory path to inspect, relative to the workspace", }, recursive: { - type: ["boolean"], + type: "boolean", description: "Set true to list contents recursively; false to show only the top level", }, }, diff --git a/src/core/prompts/tools/native-tools/mcp_server.ts b/src/core/prompts/tools/native-tools/mcp_server.ts index 628102da48..e174e0f077 100644 --- a/src/core/prompts/tools/native-tools/mcp_server.ts +++ b/src/core/prompts/tools/native-tools/mcp_server.ts @@ -63,7 +63,7 @@ export function getMcpServerTools(mcpHub?: McpHub): OpenAI.Chat.ChatCompletionTo const toolDefinition: OpenAI.Chat.ChatCompletionTool = { type: "function", function: { - name: `${server.name}___${tool.name}`, + name: `mcp_${server.name}_${tool.name}`, description: tool.description, parameters: parameters, }, diff --git a/src/core/prompts/tools/native-tools/read_file.ts b/src/core/prompts/tools/native-tools/read_file.ts index 147809617e..6118f58587 100644 --- a/src/core/prompts/tools/native-tools/read_file.ts +++ b/src/core/prompts/tools/native-tools/read_file.ts @@ -1,6 +1,6 @@ import type OpenAI from "openai" -export const read_file_multi = { +export const read_file = { type: "function", function: { name: "read_file", @@ -30,7 +30,7 @@ export const read_file_multi = { }, }, }, - required: ["path", "line_ranges"], + required: ["path"], additionalProperties: false, }, minItems: 1, @@ -41,24 +41,3 @@ export const read_file_multi = { }, }, } satisfies OpenAI.Chat.ChatCompletionTool - -export const read_file_single = { - type: "function", - function: { - name: "read_file", - description: - 'Request to read the contents of a file. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when discussing code.', - strict: true, - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "Path to the file to read, relative to the workspace", - }, - }, - required: ["path"], - additionalProperties: false, - }, - }, -} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/prompts/tools/native-tools/search_and_replace.ts b/src/core/prompts/tools/native-tools/search_and_replace.ts deleted file mode 100644 index 730cebc897..0000000000 --- a/src/core/prompts/tools/native-tools/search_and_replace.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type OpenAI from "openai" - -export default { - type: "function", - function: { - name: "search_and_replace", - description: - "Find and replace text within a file using literal strings or regular expressions. Supports optional line ranges, regex mode, and case-insensitive matching, and shows a diff preview before applying changes.", - strict: true, - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "File path to modify, relative to the workspace", - }, - search: { - type: "string", - description: "Text or pattern to search for", - }, - replace: { - type: "string", - description: "Replacement text to insert for each match", - }, - start_line: { - type: ["integer", "null"], - description: "Optional starting line (1-based) to limit replacements", - }, - end_line: { - type: ["integer", "null"], - description: "Optional ending line (1-based) to limit replacements", - }, - use_regex: { - type: ["boolean", "null"], - description: "Set true to treat the search parameter as a regular expression", - }, - ignore_case: { - type: ["boolean", "null"], - description: "Set true to ignore case when matching", - }, - }, - required: ["path", "search", "replace", "start_line", "end_line", "use_regex", "ignore_case"], - additionalProperties: false, - }, - }, -} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/prompts/types.ts b/src/core/prompts/types.ts index 99642ebf35..7aea853410 100644 --- a/src/core/prompts/types.ts +++ b/src/core/prompts/types.ts @@ -6,6 +6,7 @@ import { ToolProtocol } from "@roo-code/types" export interface SystemPromptSettings { maxConcurrentFileReads: number todoListEnabled: boolean + browserToolEnabled?: boolean useAgentRules: boolean newTaskRequireTodos: boolean toolProtocol?: ToolProtocol diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4f2bdd72da..84919f88a4 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -5,9 +5,12 @@ import crypto from "crypto" import EventEmitter from "events" import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" import delay from "delay" import pWaitFor from "p-wait-for" import { serializeError } from "serialize-error" +import { Package } from "../../shared/package" +import { formatToolInvocation } from "../tools/helpers/toolResultFormatting" import { type TaskLike, @@ -33,14 +36,17 @@ import { isIdleAsk, isInteractiveAsk, isResumableAsk, + isNativeProtocol, QueuedMessage, DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, MAX_CHECKPOINT_TIMEOUT_SECONDS, MIN_CHECKPOINT_TIMEOUT_SECONDS, + TOOL_PROTOCOL, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { CloudService, BridgeOrchestrator } from "@roo-code/cloud" +import { resolveToolProtocol } from "../../utils/resolveToolProtocol" // api import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api" @@ -56,7 +62,7 @@ import { ClineApiReqCancelReason, ClineApiReqInfo } from "../../shared/Extension import { getApiMetrics, hasTokenUsageChanged } from "../../shared/getApiMetrics" import { ClineAskResponse } from "../../shared/WebviewMessage" import { defaultModeSlug, getModeBySlug, getGroupName } from "../../shared/modes" -import { DiffStrategy } from "../../shared/tools" +import { DiffStrategy, type ToolUse } from "../../shared/tools" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { getModelMaxOutputTokens } from "../../shared/api" @@ -80,17 +86,19 @@ import { getWorkspacePath } from "../../utils/path" // prompts import { formatResponse } from "../prompts/responses" import { SYSTEM_PROMPT } from "../prompts/system" -import { resolveToolProtocol } from "../prompts/toolProtocolResolver" +import { nativeTools, getMcpServerTools } from "../prompts/tools/native-tools" +import { filterNativeToolsForMode, filterMcpToolsForMode } from "../prompts/tools/filter-tools-for-mode" // core modules import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" -import { restoreTodoListForTask } from "../tools/updateTodoListTool" +import { restoreTodoListForTask } from "../tools/UpdateTodoListTool" import { FileContextTracker } from "../context-tracking/FileContextTracker" import { RooIgnoreController } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message" import { AssistantMessageParser } from "../assistant-message/AssistantMessageParser" -import { truncateConversationIfNeeded } from "../sliding-window" +import { NativeToolCallParser } from "../assistant-message/NativeToolCallParser" +import { manageContext } from "../context-management" import { ClineProvider } from "../webview/ClineProvider" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" import { MultiFileSearchReplaceDiffStrategy } from "../diff/strategies/multi-file-search-replace" @@ -222,7 +230,7 @@ export class Task extends EventEmitter implements TaskLike { private pauseInterval: NodeJS.Timeout | undefined // API - readonly apiConfiguration: ProviderSettings + apiConfiguration: ProviderSettings api: ApiHandler private static lastGlobalApiRequestTime?: number private autoApprovalHandler: AutoApprovalHandler @@ -289,12 +297,13 @@ export class Task extends EventEmitter implements TaskLike { assistantMessageContent: AssistantMessageContent[] = [] presentAssistantMessageLocked = false presentAssistantMessageHasPendingUpdates = false - userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = [] + userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolResultBlockParam)[] = [] userMessageContentReady = false didRejectTool = false didAlreadyUseTool = false didCompleteReadingStream = false - assistantMessageParser: AssistantMessageParser + assistantMessageParser?: AssistantMessageParser + private providerProfileChangeListener?: (config: { name: string; provider?: string }) => void // Token Usage Cache private tokenUsageSnapshot?: TokenUsage @@ -312,6 +321,7 @@ export class Task extends EventEmitter implements TaskLike { task, images, historyItem, + experiments: experimentsConfig, startTask = true, rootTask, parentTask, @@ -399,8 +409,11 @@ export class Task extends EventEmitter implements TaskLike { TelemetryService.instance.captureTaskCreated(this.taskId) } - // Initialize the assistant message parser. - this.assistantMessageParser = new AssistantMessageParser() + // Initialize the assistant message parser only for XML protocol. + // For native protocol, tool calls come as tool_call chunks, not XML. + // experiments is always provided via TaskOptions (defaults to experimentDefault in provider) + const toolProtocol = resolveToolProtocol(this.apiConfiguration, this.api.getModel().info) + this.assistantMessageParser = toolProtocol !== "native" ? new AssistantMessageParser() : undefined this.messageQueueService = new MessageQueueService() @@ -411,6 +424,9 @@ export class Task extends EventEmitter implements TaskLike { this.messageQueueService.on("stateChanged", this.messageQueueStateChangedHandler) + // Listen for provider profile changes to update parser state + this.setupProviderProfileChangeListener(provider) + // Only set up diff strategy if diff is enabled. if (this.diffEnabled) { // Default to old strategy, will be updated if experiment is enabled. @@ -483,6 +499,36 @@ export class Task extends EventEmitter implements TaskLike { } } + /** + * Sets up a listener for provider profile changes to automatically update the parser state. + * This ensures the XML/native protocol parser stays synchronized with the current model. + * + * @private + * @param provider - The ClineProvider instance to listen to + */ + private setupProviderProfileChangeListener(provider: ClineProvider): void { + // Only set up listener if provider has the on method (may not exist in test mocks) + if (typeof provider.on !== "function") { + return + } + + this.providerProfileChangeListener = async () => { + try { + const newState = await provider.getState() + if (newState?.apiConfiguration) { + await this.updateApiConfiguration(newState.apiConfiguration) + } + } catch (error) { + console.error( + `[Task#${this.taskId}.${this.instanceId}] Failed to update API configuration on profile change:`, + error, + ) + } + } + + provider.on(RooCodeEventName.ProviderProfileChanged, this.providerProfileChangeListener) + } + /** * Wait for the task mode to be initialized before proceeding. * This method ensures that any operations depending on the task mode @@ -596,33 +642,69 @@ export class Task extends EventEmitter implements TaskLike { } private async addToApiConversationHistory(message: Anthropic.MessageParam) { - // Capture the encrypted_content from the provider (e.g., OpenAI Responses API) if present. + // Capture the encrypted_content / thought signatures from the provider (e.g., OpenAI Responses API, Google GenAI) if present. // We only persist data reported by the current response body. const handler = this.api as ApiHandler & { getResponseId?: () => string | undefined getEncryptedContent?: () => { encrypted_content: string; id?: string } | undefined + getThoughtSignature?: () => string | undefined } if (message.role === "assistant") { const responseId = handler.getResponseId?.() const reasoningData = handler.getEncryptedContent?.() + const thoughtSignature = handler.getThoughtSignature?.() - // If we have encrypted_content, add it as a reasoning item before the assistant message - if (reasoningData?.encrypted_content) { - this.apiConversationHistory.push({ - type: "reasoning", - summary: [], - encrypted_content: reasoningData.encrypted_content, - ...(reasoningData.id ? { id: reasoningData.id } : {}), - ts: Date.now(), - } as any) - } - - const messageWithTs = { + // Start from the original assistant message + const messageWithTs: any = { ...message, ...(responseId ? { id: responseId } : {}), ts: Date.now(), } + + // If we have encrypted_content, embed it as the first content block on the assistant message. + // This keeps reasoning + assistant atomic for context management while still allowing providers + // to receive a separate reasoning item when we build the request. + if (reasoningData?.encrypted_content) { + const reasoningBlock = { + type: "reasoning", + summary: [] as any[], + encrypted_content: reasoningData.encrypted_content, + ...(reasoningData.id ? { id: reasoningData.id } : {}), + } + + if (typeof messageWithTs.content === "string") { + messageWithTs.content = [ + reasoningBlock, + { type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam, + ] + } else if (Array.isArray(messageWithTs.content)) { + messageWithTs.content = [reasoningBlock, ...messageWithTs.content] + } else if (!messageWithTs.content) { + messageWithTs.content = [reasoningBlock] + } + } + + // If we have a thought signature, append it as a dedicated content block + // so it can be round-tripped in api_history.json and re-sent on subsequent calls. + if (thoughtSignature) { + const thoughtSignatureBlock = { + type: "thoughtSignature", + thoughtSignature, + } + + if (typeof messageWithTs.content === "string") { + messageWithTs.content = [ + { type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam, + thoughtSignatureBlock, + ] + } else if (Array.isArray(messageWithTs.content)) { + messageWithTs.content = [...messageWithTs.content, thoughtSignatureBlock] + } else if (!messageWithTs.content) { + messageWithTs.content = [thoughtSignatureBlock] + } + } + this.apiConversationHistory.push(messageWithTs) } else { const messageWithTs = { ...message, ts: Date.now() } @@ -933,7 +1015,7 @@ export class Task extends EventEmitter implements TaskLike { } } - // Wait for askResponse to be set. + // Wait for askResponse to be set await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 }) if (this.lastMessageTs !== askTs) { @@ -1003,6 +1085,47 @@ export class Task extends EventEmitter implements TaskLike { this.handleWebviewAskResponse("noButtonClicked", text, images) } + /** + * Updates the API configuration and reinitializes the parser based on the new tool protocol. + * This should be called when switching between models/profiles with different tool protocols + * to prevent the parser from being left in an inconsistent state. + * + * @param newApiConfiguration - The new API configuration to use + */ + public async updateApiConfiguration(newApiConfiguration: ProviderSettings): Promise { + // Determine the previous protocol before updating + const previousProtocol = this.apiConfiguration + ? resolveToolProtocol(this.apiConfiguration, this.api.getModel().info) + : undefined + + this.apiConfiguration = newApiConfiguration + this.api = buildApiHandler(newApiConfiguration) + + // Determine the new tool protocol + const newProtocol = resolveToolProtocol(this.apiConfiguration, this.api.getModel().info) + const shouldUseXmlParser = newProtocol === "xml" + + // Only make changes if the protocol actually changed + if (previousProtocol === newProtocol) { + console.log( + `[Task#${this.taskId}.${this.instanceId}] Tool protocol unchanged (${newProtocol}), no parser update needed`, + ) + return + } + + // Handle protocol transitions + if (shouldUseXmlParser && !this.assistantMessageParser) { + // Switching from native → XML: create parser + this.assistantMessageParser = new AssistantMessageParser() + console.log(`[Task#${this.taskId}.${this.instanceId}] Switched native → xml: initialized XML parser`) + } else if (!shouldUseXmlParser && this.assistantMessageParser) { + // Switching from XML → native: remove parser + this.assistantMessageParser.reset() + this.assistantMessageParser = undefined + console.log(`[Task#${this.taskId}.${this.instanceId}] Switched xml → native: removed XML parser`) + } + } + public async submitUserMessage( text: string, images?: string[], @@ -1026,6 +1149,13 @@ export class Task extends EventEmitter implements TaskLike { if (providerProfile) { await provider.setProviderProfile(providerProfile) + + // Update this task's API configuration to match the new profile + // This ensures the parser state is synchronized with the selected model + const newState = await provider.getState() + if (newState?.apiConfiguration) { + await this.updateApiConfiguration(newState.apiConfiguration) + } } this.emit(RooCodeEventName.TaskUserMessage, this.taskId) @@ -1238,7 +1368,10 @@ export class Task extends EventEmitter implements TaskLike { relPath ? ` for '${relPath.toPosix()}'` : "" } without value for required parameter '${paramName}'. Retrying...`, ) - return formatResponse.toolError(formatResponse.missingToolParameterError(paramName)) + const modelInfo = this.api.getModel().info + const state = await this.providerRef.deref()?.getState() + const toolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo) + return formatResponse.toolError(formatResponse.missingToolParameterError(paramName, toolProtocol)) } // Lifecycle @@ -1376,40 +1509,49 @@ export class Task extends EventEmitter implements TaskLike { // even if it goes out of sync with cline messages. let existingApiConversationHistory: ApiMessage[] = await this.getSavedApiConversationHistory() - // v2.0 xml tags refactor caveat: since we don't use tools anymore, we need to replace all tool use blocks with a text block since the API disallows conversations with tool uses and no tool schema - const conversationWithoutToolBlocks = existingApiConversationHistory.map((message) => { - if (Array.isArray(message.content)) { - const newContent = message.content.map((block) => { - if (block.type === "tool_use") { - // It's important we convert to the new tool schema - // format so the model doesn't get confused about how to - // invoke tools. - const inputAsXml = Object.entries(block.input as Record) - .map(([key, value]) => `<${key}>\n${value}\n`) - .join("\n") - return { - type: "text", - text: `<${block.name}>\n${inputAsXml}\n`, - } as Anthropic.Messages.TextBlockParam - } else if (block.type === "tool_result") { - // Convert block.content to text block array, removing images - const contentAsTextBlocks = Array.isArray(block.content) - ? block.content.filter((item) => item.type === "text") - : [{ type: "text", text: block.content }] - const textContent = contentAsTextBlocks.map((item) => item.text).join("\n\n") - const toolName = findToolName(block.tool_use_id, existingApiConversationHistory) - return { - type: "text", - text: `[${toolName} Result]\n\n${textContent}`, - } as Anthropic.Messages.TextBlockParam - } - return block - }) - return { ...message, content: newContent } - } - return message - }) - existingApiConversationHistory = conversationWithoutToolBlocks + // v2.0 xml tags refactor caveat: since we don't use tools anymore for XML protocol, + // we need to replace all tool use blocks with a text block since the API disallows + // conversations with tool uses and no tool schema. + // For native protocol, we preserve tool_use and tool_result blocks as they're expected by the API. + const state = await this.providerRef.deref()?.getState() + const protocol = resolveToolProtocol(this.apiConfiguration, this.api.getModel().info) + const useNative = isNativeProtocol(protocol) + + // Only convert tool blocks to text for XML protocol + // For native protocol, the API expects proper tool_use/tool_result structure + if (!useNative) { + const conversationWithoutToolBlocks = existingApiConversationHistory.map((message) => { + if (Array.isArray(message.content)) { + const newContent = message.content.map((block) => { + if (block.type === "tool_use") { + // Format tool invocation based on protocol + const params = block.input as Record + const formattedText = formatToolInvocation(block.name, params, protocol) + + return { + type: "text", + text: formattedText, + } as Anthropic.Messages.TextBlockParam + } else if (block.type === "tool_result") { + // Convert block.content to text block array, removing images + const contentAsTextBlocks = Array.isArray(block.content) + ? block.content.filter((item) => item.type === "text") + : [{ type: "text", text: block.content }] + const textContent = contentAsTextBlocks.map((item) => item.text).join("\n\n") + const toolName = findToolName(block.tool_use_id, existingApiConversationHistory) + return { + type: "text", + text: `[${toolName} Result]\n\n${textContent}`, + } as Anthropic.Messages.TextBlockParam + } + return block + }) + return { ...message, content: newContent } + } + return message + }) + existingApiConversationHistory = conversationWithoutToolBlocks + } // FIXME: remove tool use blocks altogether @@ -1570,6 +1712,19 @@ export class Task extends EventEmitter implements TaskLike { public dispose(): void { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) + // Remove provider profile change listener + try { + if (this.providerProfileChangeListener) { + const provider = this.providerRef.deref() + if (provider) { + provider.off(RooCodeEventName.ProviderProfileChanged, this.providerProfileChangeListener) + } + this.providerProfileChangeListener = undefined + } + } catch (error) { + console.error("Error removing provider profile change listener:", error) + } + // Dispose message queue and remove event listeners. try { if (this.messageQueueStateChangedHandler) { @@ -1748,7 +1903,10 @@ export class Task extends EventEmitter implements TaskLike { // the user hits max requests and denies resetting the count. break } else { - nextUserContent = [{ type: "text", text: formatResponse.noToolsUsed() }] + const modelInfo = this.api.getModel().info + const state = await this.providerRef.deref()?.getState() + const toolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo) + nextUserContent = [{ type: "text", text: formatResponse.noToolsUsed(toolProtocol) }] this.consecutiveMistakeCount++ } } @@ -1762,6 +1920,7 @@ export class Task extends EventEmitter implements TaskLike { userContent: Anthropic.Messages.ContentBlockParam[] includeFileDetails: boolean retryAttempt?: number + userMessageWasRemoved?: boolean // Track if user message was removed due to empty response } const stack: StackItem[] = [{ userContent, includeFileDetails, retryAttempt: 0 }] @@ -1862,8 +2021,14 @@ export class Task extends EventEmitter implements TaskLike { // results. const finalUserContent = [...parsedUserContent, { type: "text" as const, text: environmentDetails }] - await this.addToApiConversationHistory({ role: "user", content: finalUserContent }) - TelemetryService.instance.captureConversationMessage(this.taskId, "user") + // Only add user message to conversation history if: + // 1. This is the first attempt (retryAttempt === 0), OR + // 2. The message was removed in a previous iteration (userMessageWasRemoved === true) + // This prevents consecutive user messages while allowing re-add when needed + if ((currentItem.retryAttempt ?? 0) === 0 || currentItem.userMessageWasRemoved) { + await this.addToApiConversationHistory({ role: "user", content: finalUserContent }) + TelemetryService.instance.captureConversationMessage(this.taskId, "user") + } // Since we sent off a placeholder api_req_started message to update the // webview while waiting to actually start the API request (to load @@ -1968,10 +2133,14 @@ export class Task extends EventEmitter implements TaskLike { this.didAlreadyUseTool = false this.presentAssistantMessageLocked = false this.presentAssistantMessageHasPendingUpdates = false - this.assistantMessageParser.reset() + this.assistantMessageParser?.reset() await this.diffViewProvider.reset() + // Determine protocol once per API request to avoid repeated calls in the streaming loop + const streamProtocol = resolveToolProtocol(this.apiConfiguration, this.api.getModel().info) + const shouldUseXmlParser = streamProtocol === "xml" + // Yields only if the first chunk is successful, otherwise will // allow the user to retry the request (most likely due to rate // limit error, which gets thrown on the first chunk). @@ -2024,21 +2193,73 @@ export class Task extends EventEmitter implements TaskLike { pendingGroundingSources.push(...chunk.sources) } break + case "tool_call": { + // Convert native tool call to ToolUse format + const toolUse = NativeToolCallParser.parseToolCall({ + id: chunk.id, + name: chunk.name as ToolName, + arguments: chunk.arguments, + }) + + if (!toolUse) { + console.error(`Failed to parse tool call for task ${this.taskId}:`, chunk) + break + } + + // Store the tool call ID on the ToolUse object for later reference + // This is needed to create tool_result blocks that reference the correct tool_use_id + toolUse.id = chunk.id + + // Add the tool use to assistant message content + this.assistantMessageContent.push(toolUse) + + // Mark that we have new content to process + this.userMessageContentReady = false + + // Present the tool call to user + presentAssistantMessage(this) + break + } case "text": { assistantMessage += chunk.text - // Parse raw assistant message chunk into content blocks. - const prevLength = this.assistantMessageContent.length - this.assistantMessageContent = this.assistantMessageParser.processChunk(chunk.text) + // Use the protocol determined at the start of streaming + // Don't rely solely on parser existence - parser might exist from previous state + if (shouldUseXmlParser && this.assistantMessageParser) { + // XML protocol: Parse raw assistant message chunk into content blocks + const prevLength = this.assistantMessageContent.length + this.assistantMessageContent = this.assistantMessageParser.processChunk(chunk.text) - if (this.assistantMessageContent.length > prevLength) { - // New content we need to present, reset to - // false in case previous content set this to true. - this.userMessageContentReady = false + if (this.assistantMessageContent.length > prevLength) { + // New content we need to present, reset to + // false in case previous content set this to true. + this.userMessageContentReady = false + } + + // Present content to user. + presentAssistantMessage(this) + } else { + // Native protocol: Text chunks are plain text, not XML tool calls + // Create or update a text content block directly + const lastBlock = + this.assistantMessageContent[this.assistantMessageContent.length - 1] + + if (lastBlock?.type === "text" && lastBlock.partial) { + // Update existing partial text block + lastBlock.content = assistantMessage + } else { + // Create new text block + this.assistantMessageContent.push({ + type: "text", + content: assistantMessage, + partial: true, + }) + this.userMessageContentReady = false + } + + // Present content to user + presentAssistantMessage(this) } - - // Present content to user. - presentAssistantMessage(this) break } } @@ -2329,9 +2550,14 @@ export class Task extends EventEmitter implements TaskLike { // Can't just do this b/c a tool could be in the middle of executing. // this.assistantMessageContent.forEach((e) => (e.partial = false)) - // Now that the stream is complete, finalize any remaining partial content blocks - this.assistantMessageParser.finalizeContentBlocks() - this.assistantMessageContent = this.assistantMessageParser.getContentBlocks() + // Now that the stream is complete, finalize any remaining partial content blocks (XML protocol only) + // Use the protocol determined at the start of streaming + if (shouldUseXmlParser && this.assistantMessageParser) { + this.assistantMessageParser.finalizeContentBlocks() + const parsedBlocks = this.assistantMessageParser.getContentBlocks() + // For XML protocol: Use only parsed blocks (includes both text and tool_use parsed from XML) + this.assistantMessageContent = parsedBlocks + } if (partialBlocks.length > 0) { // If there is content to update then it will complete and @@ -2364,8 +2590,8 @@ export class Task extends EventEmitter implements TaskLike { await this.saveClineMessages() await this.providerRef.deref()?.postStateToWebview() - // Reset parser after each complete conversation round - this.assistantMessageParser.reset() + // Reset parser after each complete conversation round (XML protocol only) + this.assistantMessageParser?.reset() // Now add to apiConversationHistory. // Need to save assistant responses to file before proceeding to @@ -2373,7 +2599,11 @@ export class Task extends EventEmitter implements TaskLike { // able to save the assistant's response. let didEndLoop = false - if (assistantMessage.length > 0) { + // Check if we have any content to process (text or tool uses) + const hasTextContent = assistantMessage.length > 0 + const hasToolUses = this.assistantMessageContent.some((block) => block.type === "tool_use") + + if (hasTextContent || hasToolUses) { // Display grounding sources to the user if they exist if (pendingGroundingSources.length > 0) { const citationLinks = pendingGroundingSources.map((source, i) => `[${i + 1}](${source.url})`) @@ -2391,9 +2621,38 @@ export class Task extends EventEmitter implements TaskLike { finalAssistantMessage = `${reasoningMessage}\n${assistantMessage}` } + // Build the assistant message content array + const assistantContent: Array = [] + + // Add text content if present + if (finalAssistantMessage) { + assistantContent.push({ + type: "text" as const, + text: finalAssistantMessage, + }) + } + + // Add tool_use blocks with their IDs for native protocol + const toolUseBlocks = this.assistantMessageContent.filter((block) => block.type === "tool_use") + for (const toolUse of toolUseBlocks) { + // Get the tool call ID that was stored during parsing + const toolCallId = (toolUse as any).id + if (toolCallId) { + // nativeArgs is already in the correct API format for all tools + const input = toolUse.nativeArgs || toolUse.params + + assistantContent.push({ + type: "tool_use" as const, + id: toolCallId, + name: toolUse.name, + input, + }) + } + } + await this.addToApiConversationHistory({ role: "assistant", - content: [{ type: "text", text: finalAssistantMessage }], + content: assistantContent, }) TelemetryService.instance.captureConversationMessage(this.taskId, "assistant") @@ -2421,7 +2680,10 @@ export class Task extends EventEmitter implements TaskLike { const didToolUse = this.assistantMessageContent.some((block) => block.type === "tool_use") if (!didToolUse) { - this.userMessageContent.push({ type: "text", text: formatResponse.noToolsUsed() }) + const modelInfo = this.api.getModel().info + const state = await this.providerRef.deref()?.getState() + const toolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo) + this.userMessageContent.push({ type: "text", text: formatResponse.noToolsUsed(toolProtocol) }) this.consecutiveMistakeCount++ } @@ -2441,8 +2703,24 @@ export class Task extends EventEmitter implements TaskLike { // or tool_use content blocks from API which we should assume is // an error. + // IMPORTANT: For native tool protocol, we already added the user message to + // apiConversationHistory at line 1876. Since the assistant failed to respond, + // we need to remove that message before retrying to avoid having two consecutive + // user messages (which would cause tool_result validation errors). + let state = await this.providerRef.deref()?.getState() + if ( + isNativeProtocol(resolveToolProtocol(this.apiConfiguration, this.api.getModel().info)) && + this.apiConversationHistory.length > 0 + ) { + const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1] + if (lastMessage.role === "user") { + // Remove the last user message that we added earlier + this.apiConversationHistory.pop() + } + } + // Check if we should auto-retry or prompt the user - const state = await this.providerRef.deref()?.getState() + // Reuse the state variable from above if (state?.autoApprovalEnabled && state?.alwaysApproveResubmit) { // Auto-retry with backoff - don't persist failure message when retrying const errorMsg = @@ -2463,10 +2741,12 @@ export class Task extends EventEmitter implements TaskLike { } // Push the same content back onto the stack to retry, incrementing the retry attempt counter + // Mark that user message was removed so it gets re-added on retry stack.push({ userContent: currentUserContent, includeFileDetails: false, retryAttempt: (currentItem.retryAttempt ?? 0) + 1, + userMessageWasRemoved: true, }) // Continue to retry the request @@ -2491,7 +2771,18 @@ export class Task extends EventEmitter implements TaskLike { // Continue to retry the request continue } else { - // User declined to retry - persist error and failure message + // User declined to retry + // For native protocol, re-add the user message we removed + // Reuse the state variable from above + if ( + isNativeProtocol(resolveToolProtocol(this.apiConfiguration, this.api.getModel().info)) + ) { + await this.addToApiConversationHistory({ + role: "user", + content: currentUserContent, + }) + } + await this.say( "error", "Unexpected API Response: The language model did not provide any assistant messages. This may indicate an issue with the API or the model's output.", @@ -2582,6 +2873,9 @@ export class Task extends EventEmitter implements TaskLike { const canUseBrowserTool = modelSupportsBrowser && modeSupportsBrowser && (browserToolEnabled ?? true) + // Resolve the tool protocol based on profile, model, and provider settings + const toolProtocol = resolveToolProtocol(apiConfiguration ?? this.apiConfiguration, modelInfo) + return SYSTEM_PROMPT( provider.context, this.cwd, @@ -2602,11 +2896,13 @@ export class Task extends EventEmitter implements TaskLike { { maxConcurrentFileReads: maxConcurrentFileReads ?? 5, todoListEnabled: apiConfiguration?.todoListEnabled ?? true, - useAgentRules: vscode.workspace.getConfiguration("roo-cline").get("useAgentRules") ?? true, + browserToolEnabled: browserToolEnabled ?? true, + useAgentRules: + vscode.workspace.getConfiguration(Package.name).get("useAgentRules") ?? true, newTaskRequireTodos: vscode.workspace - .getConfiguration("roo-cline") + .getConfiguration(Package.name) .get("newTaskRequireTodos", false), - toolProtocol: resolveToolProtocol(), + toolProtocol, }, undefined, // todoList this.api.getModel().id, @@ -2647,7 +2943,7 @@ export class Task extends EventEmitter implements TaskLike { ) // Force aggressive truncation by keeping only 75% of the conversation history - const truncateResult = await truncateConversationIfNeeded({ + const truncateResult = await manageContext({ messages: this.apiConversationHistory, totalTokens: contextTokens || 0, maxTokens, @@ -2761,7 +3057,7 @@ export class Task extends EventEmitter implements TaskLike { // Get the current profile ID using the helper method const currentProfileId = this.getCurrentProfileId(state) - const truncateResult = await truncateConversationIfNeeded({ + const truncateResult = await manageContext({ messages: this.apiConversationHistory, totalTokens: contextTokens, maxTokens, @@ -2797,33 +3093,9 @@ export class Task extends EventEmitter implements TaskLike { } } - // Properly type cleaned conversation history to include either standard Anthropic messages - // or provider-specific reasoning items (for encrypted continuity). - type ReasoningItemForRequest = { - type: "reasoning" - encrypted_content: string - id?: string - summary?: any[] - } - type CleanConversationMessage = Anthropic.Messages.MessageParam | ReasoningItemForRequest - const messagesSinceLastSummary = getMessagesSinceLastSummary(this.apiConversationHistory) - const cleanConversationHistory: CleanConversationMessage[] = maybeRemoveImageBlocks( - messagesSinceLastSummary, - this.api, - ).map((msg: ApiMessage): CleanConversationMessage => { - // Pass through reasoning items as-is (including id if present) - if (msg.type === "reasoning") { - return { - type: "reasoning", - summary: msg.summary, - encrypted_content: msg.encrypted_content!, - ...(msg.id ? { id: msg.id } : {}), - } - } - // For regular messages, just return role and content - return { role: msg.role!, content: msg.content as Anthropic.Messages.ContentBlockParam[] | string } - }) + const messagesWithoutImages = maybeRemoveImageBlocks(messagesSinceLastSummary, this.api) + const cleanConversationHistory = this.buildCleanConversationHistory(messagesWithoutImages as ApiMessage[]) // Check auto-approval limits const approvalResult = await this.autoApprovalHandler.checkAutoApprovalLimits( @@ -2837,9 +3109,52 @@ export class Task extends EventEmitter implements TaskLike { throw new Error("Auto-approval limit reached and user did not approve continuation") } + // Determine if we should include native tools based on: + // 1. Tool protocol is set to NATIVE + // 2. Model supports native tools + const modelInfo = this.api.getModel().info + const toolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo) + const shouldIncludeTools = toolProtocol === TOOL_PROTOCOL.NATIVE && (modelInfo.supportsNativeTools ?? false) + + // Build complete tools array: native tools + dynamic MCP tools, filtered by mode restrictions + let allTools: OpenAI.Chat.ChatCompletionTool[] = [] + if (shouldIncludeTools) { + const provider = this.providerRef.deref() + const mcpHub = provider?.getMcpHub() + + // Get CodeIndexManager for feature checking + const { CodeIndexManager } = await import("../../services/code-index/manager") + const codeIndexManager = CodeIndexManager.getInstance(provider!.context, this.cwd) + + // Build settings object for tool filtering + // Include browserToolEnabled to filter browser_action when disabled by user + const filterSettings = { + todoListEnabled: apiConfiguration?.todoListEnabled ?? true, + browserToolEnabled: state?.browserToolEnabled ?? true, + } + + // Filter native tools based on mode restrictions (similar to XML tool filtering) + const filteredNativeTools = filterNativeToolsForMode( + nativeTools, + mode, + state?.customModes, + state?.experiments, + codeIndexManager, + filterSettings, + ) + + // Filter MCP tools based on mode restrictions + const mcpTools = getMcpServerTools(mcpHub) + const filteredMcpTools = filterMcpToolsForMode(mcpTools, mode, state?.customModes, state?.experiments) + + allTools = [...filteredNativeTools, ...filteredMcpTools] + } + const metadata: ApiHandlerCreateMessageMetadata = { mode: mode, taskId: this.taskId, + // Include tools and tool protocol when using native protocol and model supports it + ...(shouldIncludeTools ? { tools: allTools, tool_choice: "auto", toolProtocol } : {}), } // The provider accepts reasoning items alongside standard messages; cast to the expected parameter type. @@ -3012,6 +3327,91 @@ export class Task extends EventEmitter implements TaskLike { return checkpointSave(this, force, suppressMessage) } + private buildCleanConversationHistory( + messages: ApiMessage[], + ): Array< + Anthropic.Messages.MessageParam | { type: "reasoning"; encrypted_content: string; id?: string; summary?: any[] } + > { + type ReasoningItemForRequest = { + type: "reasoning" + encrypted_content: string + id?: string + summary?: any[] + } + + const cleanConversationHistory: (Anthropic.Messages.MessageParam | ReasoningItemForRequest)[] = [] + + for (const msg of messages) { + // Legacy path: standalone reasoning items stored as separate messages + if (msg.type === "reasoning" && msg.encrypted_content) { + cleanConversationHistory.push({ + type: "reasoning", + summary: msg.summary, + encrypted_content: msg.encrypted_content!, + ...(msg.id ? { id: msg.id } : {}), + }) + continue + } + + // Preferred path: assistant message with embedded reasoning as first content block + if (msg.role === "assistant") { + const rawContent = msg.content + + const contentArray: Anthropic.Messages.ContentBlockParam[] = Array.isArray(rawContent) + ? (rawContent as Anthropic.Messages.ContentBlockParam[]) + : rawContent !== undefined + ? ([ + { type: "text", text: rawContent } satisfies Anthropic.Messages.TextBlockParam, + ] as Anthropic.Messages.ContentBlockParam[]) + : [] + + const [first, ...rest] = contentArray + + const hasEmbeddedReasoning = + first && (first as any).type === "reasoning" && typeof (first as any).encrypted_content === "string" + + if (hasEmbeddedReasoning) { + const reasoningBlock = first as any + + // Emit a separate reasoning item for the provider + cleanConversationHistory.push({ + type: "reasoning", + summary: reasoningBlock.summary ?? [], + encrypted_content: reasoningBlock.encrypted_content, + ...(reasoningBlock.id ? { id: reasoningBlock.id } : {}), + }) + + // Build assistant message without the embedded reasoning block + let assistantContent: Anthropic.Messages.MessageParam["content"] + + if (rest.length === 0) { + assistantContent = "" + } else if (rest.length === 1 && rest[0].type === "text") { + assistantContent = (rest[0] as Anthropic.Messages.TextBlockParam).text + } else { + assistantContent = rest + } + + cleanConversationHistory.push({ + role: "assistant", + content: assistantContent, + } satisfies Anthropic.Messages.MessageParam) + + continue + } + } + + // Default path for regular messages (no embedded reasoning) + if (msg.role) { + cleanConversationHistory.push({ + role: msg.role, + content: msg.content as Anthropic.Messages.ContentBlockParam[] | string, + }) + } + } + + return cleanConversationHistory + } public async checkpointRestore(options: CheckpointRestoreOptions) { return checkpointRestore(this, options) } diff --git a/src/core/task/__tests__/native-tools-filtering.spec.ts b/src/core/task/__tests__/native-tools-filtering.spec.ts new file mode 100644 index 0000000000..ff7c7a104a --- /dev/null +++ b/src/core/task/__tests__/native-tools-filtering.spec.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach, vi } from "vitest" +import type { ModeConfig } from "@roo-code/types" + +describe("Native Tools Filtering by Mode", () => { + describe("attemptApiRequest native tool filtering", () => { + it("should filter native tools based on mode restrictions", async () => { + // This test verifies that when using native protocol, tools are filtered + // by mode restrictions before being sent to the API, similar to how + // XML tools are filtered in the system prompt. + + const architectMode: ModeConfig = { + slug: "architect", + name: "Architect", + roleDefinition: "Test architect", + groups: ["read", "browser", "mcp"] as const, + } + + const codeMode: ModeConfig = { + slug: "code", + name: "Code", + roleDefinition: "Test code", + groups: ["read", "edit", "browser", "command", "mcp"] as const, + } + + // Import the functions we need to test + const { isToolAllowedForMode } = await import("../../../shared/modes") + const { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS } = await import("../../../shared/tools") + + // Test architect mode - should NOT have edit tools + const architectAllowedTools = new Set() + architectMode.groups.forEach((groupEntry) => { + const groupName = typeof groupEntry === "string" ? groupEntry : groupEntry[0] + const toolGroup = TOOL_GROUPS[groupName] + if (toolGroup) { + toolGroup.tools.forEach((tool) => { + if (isToolAllowedForMode(tool as any, "architect", [architectMode])) { + architectAllowedTools.add(tool) + } + }) + } + }) + ALWAYS_AVAILABLE_TOOLS.forEach((tool) => architectAllowedTools.add(tool)) + + // Architect should NOT have edit tools + expect(architectAllowedTools.has("write_to_file")).toBe(false) + expect(architectAllowedTools.has("apply_diff")).toBe(false) + expect(architectAllowedTools.has("insert_content")).toBe(false) + + // Architect SHOULD have read tools + expect(architectAllowedTools.has("read_file")).toBe(true) + expect(architectAllowedTools.has("list_files")).toBe(true) + + // Architect SHOULD have always-available tools + expect(architectAllowedTools.has("ask_followup_question")).toBe(true) + expect(architectAllowedTools.has("attempt_completion")).toBe(true) + + // Test code mode - SHOULD have edit tools + const codeAllowedTools = new Set() + codeMode.groups.forEach((groupEntry) => { + const groupName = typeof groupEntry === "string" ? groupEntry : groupEntry[0] + const toolGroup = TOOL_GROUPS[groupName] + if (toolGroup) { + toolGroup.tools.forEach((tool) => { + if (isToolAllowedForMode(tool as any, "code", [codeMode])) { + codeAllowedTools.add(tool) + } + }) + } + }) + ALWAYS_AVAILABLE_TOOLS.forEach((tool) => codeAllowedTools.add(tool)) + + // Code SHOULD have edit tools + expect(codeAllowedTools.has("write_to_file")).toBe(true) + expect(codeAllowedTools.has("apply_diff")).toBe(true) + expect(codeAllowedTools.has("insert_content")).toBe(true) + + // Code SHOULD have read tools + expect(codeAllowedTools.has("read_file")).toBe(true) + expect(codeAllowedTools.has("list_files")).toBe(true) + + // Code SHOULD have command tools + expect(codeAllowedTools.has("execute_command")).toBe(true) + }) + + it("should filter MCP tools based on use_mcp_tool permission", async () => { + const modeWithMcp: ModeConfig = { + slug: "test-mode-with-mcp", + name: "Test Mode", + roleDefinition: "Test", + groups: ["read", "mcp"] as const, + } + + const modeWithoutMcp: ModeConfig = { + slug: "test-mode-no-mcp", + name: "Test Mode No MCP", + roleDefinition: "Test", + groups: ["read"] as const, + } + + const { isToolAllowedForMode } = await import("../../../shared/modes") + + // Mode with MCP group should allow use_mcp_tool + expect(isToolAllowedForMode("use_mcp_tool", "test-mode-with-mcp", [modeWithMcp])).toBe(true) + + // Mode without MCP group should NOT allow use_mcp_tool + expect(isToolAllowedForMode("use_mcp_tool", "test-mode-no-mcp", [modeWithoutMcp])).toBe(false) + }) + + it("should always include always-available tools regardless of mode", async () => { + const restrictiveMode: ModeConfig = { + slug: "restrictive", + name: "Restrictive", + roleDefinition: "Test", + groups: [] as const, // No groups at all + } + + const { isToolAllowedForMode } = await import("../../../shared/modes") + const { ALWAYS_AVAILABLE_TOOLS } = await import("../../../shared/tools") + + // Always-available tools should work even with no groups + ALWAYS_AVAILABLE_TOOLS.forEach((tool) => { + expect(isToolAllowedForMode(tool as any, "restrictive", [restrictiveMode])).toBe(true) + }) + }) + }) +}) diff --git a/src/core/task/__tests__/reasoning-preservation.test.ts b/src/core/task/__tests__/reasoning-preservation.test.ts index 28d25c9a8e..d486339fc8 100644 --- a/src/core/task/__tests__/reasoning-preservation.test.ts +++ b/src/core/task/__tests__/reasoning-preservation.test.ts @@ -325,4 +325,50 @@ describe("Task reasoning preservation", () => { expect(task.apiConversationHistory[0].content[0].text).toBe("Here is my response.") expect(task.apiConversationHistory[0].content[0].text).not.toContain("") }) + + it("should embed encrypted reasoning as first assistant content block", async () => { + const task = new Task({ + provider: mockProvider as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "Test task", + startTask: false, + }) + + // Avoid disk writes in this test + ;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined) + + // Mock API handler to provide encrypted reasoning data and response id + task.api = { + getEncryptedContent: vi.fn().mockReturnValue({ + encrypted_content: "encrypted_payload", + id: "rs_test", + }), + getResponseId: vi.fn().mockReturnValue("resp_test"), + } as any + + await (task as any).addToApiConversationHistory({ + role: "assistant", + content: [{ type: "text", text: "Here is my response." }], + }) + + expect(task.apiConversationHistory).toHaveLength(1) + const stored = task.apiConversationHistory[0] as any + + expect(stored.role).toBe("assistant") + expect(Array.isArray(stored.content)).toBe(true) + expect(stored.id).toBe("resp_test") + + const [reasoningBlock, textBlock] = stored.content + + expect(reasoningBlock).toMatchObject({ + type: "reasoning", + encrypted_content: "encrypted_payload", + id: "rs_test", + }) + + expect(textBlock).toMatchObject({ + type: "text", + text: "Here is my response.", + }) + }) }) diff --git a/src/core/task/__tests__/task-tool-history.spec.ts b/src/core/task/__tests__/task-tool-history.spec.ts new file mode 100644 index 0000000000..0ab087c7a2 --- /dev/null +++ b/src/core/task/__tests__/task-tool-history.spec.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, beforeEach, vi } from "vitest" +import { Anthropic } from "@anthropic-ai/sdk" +import { TOOL_PROTOCOL } from "@roo-code/types" +import { resolveToolProtocol } from "../../../utils/resolveToolProtocol" + +describe("Task Tool History Handling", () => { + describe("resumeTaskFromHistory tool block preservation", () => { + it("should preserve tool_use and tool_result blocks for native protocol", () => { + // Mock API conversation history with tool blocks + const apiHistory: any[] = [ + { + role: "user", + content: "Read the file config.json", + ts: Date.now(), + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "I'll read that file for you.", + }, + { + type: "tool_use", + id: "toolu_123", + name: "read_file", + input: { path: "config.json" }, + }, + ], + ts: Date.now(), + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_123", + content: '{"setting": "value"}', + }, + ], + ts: Date.now(), + }, + ] + + // Simulate the protocol check + const mockApiConfiguration = { apiProvider: "roo" as const } + const mockModelInfo = { supportsNativeTools: true } + const mockExperiments = {} + + const protocol = TOOL_PROTOCOL.NATIVE + + // Test the logic that should NOT convert tool blocks for native protocol + const useNative = protocol === TOOL_PROTOCOL.NATIVE + + if (!useNative) { + // This block should NOT execute for native protocol + throw new Error("Should not convert tool blocks for native protocol") + } + + // Verify tool blocks are preserved + const assistantMessage = apiHistory[1] + const userMessage = apiHistory[2] + + expect(assistantMessage.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "tool_use", + id: "toolu_123", + name: "read_file", + }), + ]), + ) + + expect(userMessage.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "tool_result", + tool_use_id: "toolu_123", + }), + ]), + ) + }) + + it("should convert tool blocks to text for XML protocol", () => { + // Mock API conversation history with tool blocks + const apiHistory: any[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_123", + name: "read_file", + input: { path: "config.json" }, + }, + ], + ts: Date.now(), + }, + ] + + // Simulate XML protocol - tool blocks should be converted to text + const protocol = "xml" + const useNative = false // XML protocol is not native + + // For XML protocol, we should convert tool blocks + if (!useNative) { + const conversationWithoutToolBlocks = apiHistory.map((message) => { + if (Array.isArray(message.content)) { + const newContent = message.content.map((block: any) => { + if (block.type === "tool_use") { + return { + type: "text", + text: `\n\nconfig.json\n\n`, + } + } + return block + }) + return { ...message, content: newContent } + } + return message + }) + + // Verify tool blocks were converted to text + expect(conversationWithoutToolBlocks[0].content[0].type).toBe("text") + expect(conversationWithoutToolBlocks[0].content[0].text).toContain("") + } + }) + }) + + describe("convertToOpenAiMessages format", () => { + it("should properly convert tool_use to tool_calls format", () => { + const anthropicMessage: Anthropic.Messages.MessageParam = { + role: "assistant", + content: [ + { + type: "text", + text: "I'll read that file.", + }, + { + type: "tool_use", + id: "toolu_123", + name: "read_file", + input: { path: "config.json" }, + }, + ], + } + + // Simulate what convertToOpenAiMessages does + const toolUseBlocks = (anthropicMessage.content as any[]).filter((block) => block.type === "tool_use") + + const tool_calls = toolUseBlocks.map((toolMessage) => ({ + id: toolMessage.id, + type: "function" as const, + function: { + name: toolMessage.name, + arguments: JSON.stringify(toolMessage.input), + }, + })) + + expect(tool_calls).toHaveLength(1) + expect(tool_calls[0]).toEqual({ + id: "toolu_123", + type: "function", + function: { + name: "read_file", + arguments: '{"path":"config.json"}', + }, + }) + }) + + it("should properly convert tool_result to tool role messages", () => { + const anthropicMessage: Anthropic.Messages.MessageParam = { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_123", + content: '{"setting": "value"}', + }, + ], + } + + // Simulate what convertToOpenAiMessages does + const toolMessages = (anthropicMessage.content as any[]).filter((block) => block.type === "tool_result") + + const openAiToolMessages = toolMessages.map((toolMessage) => ({ + role: "tool" as const, + tool_call_id: toolMessage.tool_use_id, + content: typeof toolMessage.content === "string" ? toolMessage.content : toolMessage.content[0].text, + })) + + expect(openAiToolMessages).toHaveLength(1) + expect(openAiToolMessages[0]).toEqual({ + role: "tool", + tool_call_id: "toolu_123", + content: '{"setting": "value"}', + }) + }) + }) +}) diff --git a/src/core/task/__tests__/task-xml-protocol-regression.spec.ts b/src/core/task/__tests__/task-xml-protocol-regression.spec.ts new file mode 100644 index 0000000000..fe39dab1c7 --- /dev/null +++ b/src/core/task/__tests__/task-xml-protocol-regression.spec.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest" +import { formatToolInvocation } from "../../tools/helpers/toolResultFormatting" + +/** + * Regression tests to ensure XML protocol behavior remains unchanged + * after adding native protocol support. + */ +describe("XML Protocol Regression Tests", () => { + it("should format tool invocations as XML tags for xml protocol", () => { + const result = formatToolInvocation( + "read_file", + { path: "config.json", start_line: "1", end_line: "10" }, + "xml", + ) + + expect(result).toContain("") + expect(result).toContain("") + expect(result).toContain("config.json") + expect(result).toContain("") + expect(result).toContain("") + expect(result).toContain("1") + expect(result).toContain("") + expect(result).toContain("") + }) + + it("should handle complex nested structures in XML format", () => { + const result = formatToolInvocation( + "execute_command", + { + command: "npm install", + cwd: "/home/user/project", + }, + "xml", + ) + + expect(result).toContain("") + expect(result).toContain("") + expect(result).toContain("npm install") + expect(result).toContain("") + expect(result).toContain("") + expect(result).toContain("/home/user/project") + expect(result).toContain("") + expect(result).toContain("") + }) + + it("should handle empty parameters correctly in XML format", () => { + const result = formatToolInvocation("list_files", {}, "xml") + + expect(result).toBe("\n\n") + }) + + it("should preserve XML format for tool results in conversation history", () => { + // Simulate what happens in resumeTaskFromHistory for XML protocol + const useNative = false // XML protocol + + const mockToolUse = { + type: "tool_use", + id: "toolu_123", + name: "read_file", + input: { path: "test.ts" }, + } + + if (!useNative) { + // This is the conversion logic that should happen for XML + const converted = { + type: "text", + text: formatToolInvocation(mockToolUse.name, mockToolUse.input as Record, "xml"), + } + + expect(converted.type).toBe("text") + expect(converted.text).toContain("") + expect(converted.text).toContain("") + expect(converted.text).toContain("test.ts") + } else { + throw new Error("Should not reach here for XML protocol") + } + }) +}) diff --git a/src/core/tools/applyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts similarity index 53% rename from src/core/tools/applyDiffTool.ts rename to src/core/tools/ApplyDiffTool.ts index 1077b7bf39..c5ad24bca3 100644 --- a/src/core/tools/applyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -7,84 +7,69 @@ import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { ClineSayTool } from "../../shared/ExtensionMessage" import { getReadablePath } from "../../utils/path" import { Task } from "../task/Task" -import { ToolUse, RemoveClosingTag, AskApproval, HandleError, PushToolResult } from "../../shared/tools" import { formatResponse } from "../prompts/responses" import { fileExistsAtPath } from "../../utils/fs" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { unescapeHtmlEntities } from "../../utils/text-normalization" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" -export async function applyDiffToolLegacy( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const relPath: string | undefined = block.params.path - let diffContent: string | undefined = block.params.diff +interface ApplyDiffParams { + path: string + diff: string +} - if (diffContent && !cline.api.getModel().id.includes("claude")) { - diffContent = unescapeHtmlEntities(diffContent) +export class ApplyDiffTool extends BaseTool<"apply_diff"> { + readonly name = "apply_diff" as const + + parseLegacy(params: Partial>): ApplyDiffParams { + return { + path: params.path || "", + diff: params.diff || "", + } } - const sharedMessageProps: ClineSayTool = { - tool: "appliedDiff", - path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)), - diff: diffContent, - } + async execute(params: ApplyDiffParams, task: Task, callbacks: ToolCallbacks): Promise { + const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks + let { path: relPath, diff: diffContent } = params - try { - if (block.partial) { - // Update GUI message - let toolProgressStatus + if (diffContent && !task.api.getModel().id.includes("claude")) { + diffContent = unescapeHtmlEntities(diffContent) + } - if (cline.diffStrategy && cline.diffStrategy.getProgressStatus) { - toolProgressStatus = cline.diffStrategy.getProgressStatus(block) - } - - if (toolProgressStatus && Object.keys(toolProgressStatus).length === 0) { - return - } - - await cline - .ask("tool", JSON.stringify(sharedMessageProps), block.partial, toolProgressStatus) - .catch(() => {}) - - return - } else { + try { if (!relPath) { - cline.consecutiveMistakeCount++ - cline.recordToolError("apply_diff") - pushToolResult(await cline.sayAndCreateMissingParamError("apply_diff", "path")) + task.consecutiveMistakeCount++ + task.recordToolError("apply_diff") + pushToolResult(await task.sayAndCreateMissingParamError("apply_diff", "path")) return } if (!diffContent) { - cline.consecutiveMistakeCount++ - cline.recordToolError("apply_diff") - pushToolResult(await cline.sayAndCreateMissingParamError("apply_diff", "diff")) + task.consecutiveMistakeCount++ + task.recordToolError("apply_diff") + pushToolResult(await task.sayAndCreateMissingParamError("apply_diff", "diff")) return } - const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) if (!accessAllowed) { - await cline.say("rooignore_error", relPath) - pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath))) + await task.say("rooignore_error", relPath) + pushToolResult(formatResponse.rooIgnoreError(relPath, toolProtocol)) return } - const absolutePath = path.resolve(cline.cwd, relPath) + const absolutePath = path.resolve(task.cwd, relPath) const fileExists = await fileExistsAtPath(absolutePath) if (!fileExists) { - cline.consecutiveMistakeCount++ - cline.recordToolError("apply_diff") + task.consecutiveMistakeCount++ + task.recordToolError("apply_diff") const formattedError = `File does not exist at path: ${absolutePath}\n\n\nThe specified file could not be found. Please verify the file path and try again.\n` - await cline.say("error", formattedError) + await task.say("error", formattedError) pushToolResult(formattedError) return } @@ -92,21 +77,21 @@ export async function applyDiffToolLegacy( const originalContent: string = await fs.readFile(absolutePath, "utf-8") // Apply the diff to the original content - const diffResult = (await cline.diffStrategy?.applyDiff( + const diffResult = (await task.diffStrategy?.applyDiff( originalContent, diffContent, - parseInt(block.params.start_line ?? ""), + parseInt(params.diff.match(/:start_line:(\d+)/)?.[1] ?? ""), )) ?? { success: false, error: "No diff strategy available", } if (!diffResult.success) { - cline.consecutiveMistakeCount++ - const currentCount = (cline.consecutiveMistakeCountForApplyDiff.get(relPath) || 0) + 1 - cline.consecutiveMistakeCountForApplyDiff.set(relPath, currentCount) + task.consecutiveMistakeCount++ + const currentCount = (task.consecutiveMistakeCountForApplyDiff.get(relPath) || 0) + 1 + task.consecutiveMistakeCountForApplyDiff.set(relPath, currentCount) let formattedError = "" - TelemetryService.instance.captureDiffApplicationError(cline.taskId, currentCount) + TelemetryService.instance.captureDiffApplicationError(task.taskId, currentCount) if (diffResult.failParts && diffResult.failParts.length > 0) { for (const failPart of diffResult.failParts) { @@ -129,17 +114,17 @@ export async function applyDiffToolLegacy( } if (currentCount >= 2) { - await cline.say("diff_error", formattedError) + await task.say("diff_error", formattedError) } - cline.recordToolError("apply_diff", formattedError) + task.recordToolError("apply_diff", formattedError) pushToolResult(formattedError) return } - cline.consecutiveMistakeCount = 0 - cline.consecutiveMistakeCountForApplyDiff.delete(relPath) + task.consecutiveMistakeCount = 0 + task.consecutiveMistakeCountForApplyDiff.delete(relPath) // Generate backend-unified diff for display in chat/webview const unifiedPatchRaw = formatResponse.createPrettyPatch(relPath, originalContent, diffResult.content) @@ -147,7 +132,7 @@ export async function applyDiffToolLegacy( const diffStats = computeDiffStats(unifiedPatch) || undefined // Check if preventFocusDisruption experiment is enabled - const provider = cline.providerRef.deref() + const provider = task.providerRef.deref() const state = await provider?.getState() const diagnosticsEnabled = state?.diagnosticsEnabled ?? true const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS @@ -157,7 +142,13 @@ export async function applyDiffToolLegacy( ) // Check if file is write-protected - const isWriteProtected = cline.rooProtectedController?.isWriteProtected(relPath) || false + const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false + + const sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: getReadablePath(task.cwd, relPath), + diff: diffContent, + } if (isPreventFocusDisruptionEnabled) { // Direct file write without diff view @@ -171,8 +162,14 @@ export async function applyDiffToolLegacy( let toolProgressStatus - if (cline.diffStrategy && cline.diffStrategy.getProgressStatus) { - toolProgressStatus = cline.diffStrategy.getProgressStatus(block, diffResult) + if (task.diffStrategy && task.diffStrategy.getProgressStatus) { + const block: ToolUse<"apply_diff"> = { + type: "tool_use", + name: "apply_diff", + params: { path: relPath, diff: diffContent }, + partial: false, + } + toolProgressStatus = task.diffStrategy.getProgressStatus(block, diffResult) } const didApprove = await askApproval("tool", completeMessage, toolProgressStatus, isWriteProtected) @@ -182,9 +179,9 @@ export async function applyDiffToolLegacy( } // Save directly without showing diff view or opening the file - cline.diffViewProvider.editType = "modify" - cline.diffViewProvider.originalContent = originalContent - await cline.diffViewProvider.saveDirectly( + task.diffViewProvider.editType = "modify" + task.diffViewProvider.originalContent = originalContent + await task.diffViewProvider.saveDirectly( relPath, diffResult.content, false, @@ -194,10 +191,10 @@ export async function applyDiffToolLegacy( } else { // Original behavior with diff view // Show diff view before asking for approval - cline.diffViewProvider.editType = "modify" - await cline.diffViewProvider.open(relPath) - await cline.diffViewProvider.update(diffResult.content, true) - cline.diffViewProvider.scrollToFirstDiff() + task.diffViewProvider.editType = "modify" + await task.diffViewProvider.open(relPath) + await task.diffViewProvider.update(diffResult.content, true) + task.diffViewProvider.scrollToFirstDiff() const completeMessage = JSON.stringify({ ...sharedMessageProps, @@ -209,29 +206,35 @@ export async function applyDiffToolLegacy( let toolProgressStatus - if (cline.diffStrategy && cline.diffStrategy.getProgressStatus) { - toolProgressStatus = cline.diffStrategy.getProgressStatus(block, diffResult) + if (task.diffStrategy && task.diffStrategy.getProgressStatus) { + const block: ToolUse<"apply_diff"> = { + type: "tool_use", + name: "apply_diff", + params: { path: relPath, diff: diffContent }, + partial: false, + } + toolProgressStatus = task.diffStrategy.getProgressStatus(block, diffResult) } const didApprove = await askApproval("tool", completeMessage, toolProgressStatus, isWriteProtected) if (!didApprove) { - await cline.diffViewProvider.revertChanges() // Cline likely handles closing the diff view - cline.processQueuedMessages() + await task.diffViewProvider.revertChanges() + task.processQueuedMessages() return } // Call saveChanges to update the DiffViewProvider properties - await cline.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } // Track file edit operation if (relPath) { - await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) + await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) } // Used to determine if we should wait for busy terminal to update before sending api request - cline.didEditFile = true + task.didEditFile = true let partFailHint = "" if (diffResult.failParts && diffResult.failParts.length > 0) { @@ -239,7 +242,7 @@ export async function applyDiffToolLegacy( } // Get the formatted response message - const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) + const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, !fileExists) // Check for single SEARCH/REPLACE block warning const searchBlocks = (diffContent.match(/<<<<<<< SEARCH/g) || []).length @@ -254,17 +257,42 @@ export async function applyDiffToolLegacy( pushToolResult(message + singleBlockNotice) } - await cline.diffViewProvider.reset() + await task.diffViewProvider.reset() // Process any queued messages after file edit completes - cline.processQueuedMessages() + task.processQueuedMessages() + return + } catch (error) { + await handleError("applying diff", error as Error) + await task.diffViewProvider.reset() + task.processQueuedMessages() return } - } catch (error) { - await handleError("applying diff", error) - await cline.diffViewProvider.reset() - cline.processQueuedMessages() - return + } + + override async handlePartial(task: Task, block: ToolUse<"apply_diff">): Promise { + const relPath: string | undefined = block.params.path + const diffContent: string | undefined = block.params.diff + + const sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: getReadablePath(task.cwd, relPath || ""), + diff: diffContent, + } + + let toolProgressStatus + + if (task.diffStrategy && task.diffStrategy.getProgressStatus) { + toolProgressStatus = task.diffStrategy.getProgressStatus(block) + } + + if (toolProgressStatus && Object.keys(toolProgressStatus).length === 0) { + return + } + + await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial, toolProgressStatus).catch(() => {}) } } + +export const applyDiffTool = new ApplyDiffTool() diff --git a/src/core/tools/AskFollowupQuestionTool.ts b/src/core/tools/AskFollowupQuestionTool.ts new file mode 100644 index 0000000000..27189476dc --- /dev/null +++ b/src/core/tools/AskFollowupQuestionTool.ts @@ -0,0 +1,101 @@ +import { Task } from "../task/Task" +import { formatResponse } from "../prompts/responses" +import { parseXml } from "../../utils/xml" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +interface Suggestion { + text: string + mode?: string +} + +interface AskFollowupQuestionParams { + question: string + follow_up: Suggestion[] +} + +export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> { + readonly name = "ask_followup_question" as const + + parseLegacy(params: Partial>): AskFollowupQuestionParams { + const question = params.question || "" + const follow_up_xml = params.follow_up + + const suggestions: Suggestion[] = [] + + if (follow_up_xml) { + // Define the actual structure returned by the XML parser + type ParsedSuggestion = string | { "#text": string; "@_mode"?: string } + + try { + const parsedSuggest = parseXml(follow_up_xml, ["suggest"]) as { + suggest: ParsedSuggestion[] | ParsedSuggestion + } + + const rawSuggestions = Array.isArray(parsedSuggest?.suggest) + ? parsedSuggest.suggest + : [parsedSuggest?.suggest].filter((sug): sug is ParsedSuggestion => sug !== undefined) + + // Transform parsed XML to our Suggest format + for (const sug of rawSuggestions) { + if (typeof sug === "string") { + // Simple string suggestion (no mode attribute) + suggestions.push({ text: sug }) + } else { + // XML object with text content and optional mode attribute + const suggestion: Suggestion = { text: sug["#text"] } + if (sug["@_mode"]) { + suggestion.mode = sug["@_mode"] + } + suggestions.push(suggestion) + } + } + } catch (error) { + throw new Error( + `Failed to parse follow_up XML: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + return { + question, + follow_up: suggestions, + } + } + + async execute(params: AskFollowupQuestionParams, task: Task, callbacks: ToolCallbacks): Promise { + const { question, follow_up } = params + const { handleError, pushToolResult, toolProtocol } = callbacks + + try { + if (!question) { + task.consecutiveMistakeCount++ + task.recordToolError("ask_followup_question") + pushToolResult(await task.sayAndCreateMissingParamError("ask_followup_question", "question")) + return + } + + // Transform follow_up suggestions to the format expected by task.ask + const follow_up_json = { + question, + suggest: follow_up.map((s) => ({ answer: s.text, mode: s.mode })), + } + + task.consecutiveMistakeCount = 0 + const { text, images } = await task.ask("followup", JSON.stringify(follow_up_json), false) + await task.say("user_feedback", text ?? "", images) + pushToolResult(formatResponse.toolResult(`\n${text}\n`, images)) + } catch (error) { + await handleError("asking question", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"ask_followup_question">): Promise { + const question: string | undefined = block.params.question + await task + .ask("followup", this.removeClosingTag("question", question, block.partial), block.partial) + .catch(() => {}) + } +} + +export const askFollowupQuestionTool = new AskFollowupQuestionTool() diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts new file mode 100644 index 0000000000..a0b358ea5f --- /dev/null +++ b/src/core/tools/AttemptCompletionTool.ts @@ -0,0 +1,136 @@ +import Anthropic from "@anthropic-ai/sdk" +import * as vscode from "vscode" + +import { RooCodeEventName } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" + +import { Task } from "../task/Task" +import { formatResponse } from "../prompts/responses" +import { Package } from "../../shared/package" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +interface AttemptCompletionParams { + result: string + command?: string +} + +export interface AttemptCompletionCallbacks extends ToolCallbacks { + askFinishSubTaskApproval: () => Promise + toolDescription: () => string +} + +export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { + readonly name = "attempt_completion" as const + + parseLegacy(params: Partial>): AttemptCompletionParams { + return { + result: params.result || "", + command: params.command, + } + } + + async execute(params: AttemptCompletionParams, task: Task, callbacks: AttemptCompletionCallbacks): Promise { + const { result } = params + const { handleError, pushToolResult, askFinishSubTaskApproval, toolDescription, toolProtocol } = callbacks + + const preventCompletionWithOpenTodos = vscode.workspace + .getConfiguration(Package.name) + .get("preventCompletionWithOpenTodos", false) + + const hasIncompleteTodos = task.todoList && task.todoList.some((todo) => todo.status !== "completed") + + if (preventCompletionWithOpenTodos && hasIncompleteTodos) { + task.consecutiveMistakeCount++ + task.recordToolError("attempt_completion") + + pushToolResult( + formatResponse.toolError( + "Cannot complete task while there are incomplete todos. Please finish all todos before attempting completion.", + ), + ) + + return + } + + try { + if (!result) { + task.consecutiveMistakeCount++ + task.recordToolError("attempt_completion") + pushToolResult(await task.sayAndCreateMissingParamError("attempt_completion", "result")) + return + } + + task.consecutiveMistakeCount = 0 + + await task.say("completion_result", result, undefined, false) + TelemetryService.instance.captureTaskCompleted(task.taskId) + task.emit(RooCodeEventName.TaskCompleted, task.taskId, task.getTokenUsage(), task.toolUsage) + + if (task.parentTask) { + const didApprove = await askFinishSubTaskApproval() + + if (!didApprove) { + pushToolResult(formatResponse.toolDenied()) + return + } + + pushToolResult("") + await task.providerRef.deref()?.finishSubTask(result) + return + } + + const { response, text, images } = await task.ask("completion_result", "", false) + + if (response === "yesButtonClicked") { + return + } + + // User provided feedback - push tool result to continue the conversation + await task.say("user_feedback", text ?? "", images) + + const feedbackText = `The user has provided feedback on the results. Consider their input to continue the task, and then attempt completion again.\n\n${text}\n` + pushToolResult(formatResponse.toolResult(feedbackText, images)) + } catch (error) { + await handleError("inspecting site", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"attempt_completion">): Promise { + const result: string | undefined = block.params.result + const command: string | undefined = block.params.command + + const lastMessage = task.clineMessages.at(-1) + + if (command) { + if (lastMessage && lastMessage.ask === "command") { + await task + .ask("command", this.removeClosingTag("command", command, block.partial), block.partial) + .catch(() => {}) + } else { + await task.say( + "completion_result", + this.removeClosingTag("result", result, block.partial), + undefined, + false, + ) + + TelemetryService.instance.captureTaskCompleted(task.taskId) + task.emit(RooCodeEventName.TaskCompleted, task.taskId, task.getTokenUsage(), task.toolUsage) + + await task + .ask("command", this.removeClosingTag("command", command, block.partial), block.partial) + .catch(() => {}) + } + } else { + await task.say( + "completion_result", + this.removeClosingTag("result", result, block.partial), + undefined, + block.partial, + ) + } + } +} + +export const attemptCompletionTool = new AttemptCompletionTool() diff --git a/src/core/tools/BaseTool.ts b/src/core/tools/BaseTool.ts new file mode 100644 index 0000000000..e960b2541f --- /dev/null +++ b/src/core/tools/BaseTool.ts @@ -0,0 +1,171 @@ +import { Task } from "../task/Task" +import type { + ToolUse, + HandleError, + PushToolResult, + RemoveClosingTag, + AskApproval, + NativeToolArgs, +} from "../../shared/tools" +import type { ToolName, ToolProtocol } from "@roo-code/types" + +/** + * Callbacks passed to tool execution + */ +export interface ToolCallbacks { + askApproval: AskApproval + handleError: HandleError + pushToolResult: PushToolResult + removeClosingTag: RemoveClosingTag + toolProtocol: ToolProtocol +} + +/** + * Helper type to extract the parameter type for a tool based on its name. + * If the tool has native args defined in NativeToolArgs, use those; otherwise fall back to any. + */ +type ToolParams = TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : any + +/** + * Abstract base class for all tools. + * + * Provides a consistent architecture where: + * - XML/legacy protocol: params → parseLegacy() → typed params → execute() + * - Native protocol: nativeArgs already contain typed data → execute() + * + * Each tool extends this class and implements: + * - parseLegacy(): Convert XML/legacy string params to typed params + * - execute(): Protocol-agnostic core logic using typed params + * - handlePartial(): (optional) Handle streaming partial messages + * + * @template TName - The specific tool name, which determines native arg types + */ +export abstract class BaseTool { + /** + * The tool's name (must match ToolName type) + */ + abstract readonly name: TName + + /** + * Parse XML/legacy string-based parameters into typed parameters. + * + * For XML protocol, this converts params.args (XML string) or params.path (legacy) + * into a typed structure that execute() can use. + * + * @param params - Raw ToolUse.params from XML protocol + * @returns Typed parameters for execute() + * @throws Error if parsing fails + */ + abstract parseLegacy(params: Partial>): ToolParams + + /** + * Execute the tool with typed parameters. + * + * This is the protocol-agnostic core logic. It receives typed parameters + * (from parseLegacy for XML, or directly from native protocol) and performs + * the tool's operation. + * + * @param params - Typed parameters + * @param task - Task instance with state and API access + * @param callbacks - Tool execution callbacks (approval, error handling, results) + */ + abstract execute(params: ToolParams, task: Task, callbacks: ToolCallbacks): Promise + + /** + * Handle partial (streaming) tool messages. + * + * Default implementation does nothing. Tools that support streaming + * partial messages should override this. + * + * @param task - Task instance + * @param block - Partial ToolUse block + */ + async handlePartial(task: Task, block: ToolUse): Promise { + // Default: no-op for partial messages + // Tools can override to show streaming UI updates + } + + /** + * Remove partial closing XML tags from text during streaming. + * + * This utility helps clean up partial XML tag artifacts that can appear + * at the end of streamed content, preventing them from being displayed to users. + * + * @param tag - The tag name to check for partial closing + * @param text - The text content to clean + * @param isPartial - Whether this is a partial message (if false, returns text as-is) + * @returns Cleaned text with partial closing tags removed + */ + protected removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string { + if (!isPartial) { + return text || "" + } + + if (!text) { + return "" + } + + // This regex dynamically constructs a pattern to match the closing tag: + // - Optionally matches whitespace before the tag + // - Matches '<' or ' `(?:${char})?`) + .join("")}$`, + "g", + ) + + return text.replace(tagRegex, "") + } + + /** + * Main entry point for tool execution. + * + * Handles the complete flow: + * 1. Partial message handling (if partial) + * 2. Parameter parsing (parseLegacy for XML, or use nativeArgs directly) + * 3. Core execution (execute) + * + * @param task - Task instance + * @param block - ToolUse block from assistant message + * @param callbacks - Tool execution callbacks + */ + async handle(task: Task, block: ToolUse, callbacks: ToolCallbacks): Promise { + // Handle partial messages + if (block.partial) { + try { + await this.handlePartial(task, block) + } catch (error) { + console.error(`Error in handlePartial:`, error) + await callbacks.handleError( + `handling partial ${this.name}`, + error instanceof Error ? error : new Error(String(error)), + ) + } + return + } + + // Determine protocol and parse parameters accordingly + let params: ToolParams + try { + if (block.nativeArgs !== undefined) { + // Native protocol: typed args provided by NativeToolCallParser + // TypeScript knows nativeArgs is properly typed based on TName + params = block.nativeArgs as ToolParams + } else { + // XML/legacy protocol: parse string params into typed params + params = this.parseLegacy(block.params) + } + } catch (error) { + console.error(`Error parsing parameters:`, error) + const errorMessage = `Failed to parse ${this.name} parameters: ${error instanceof Error ? error.message : String(error)}` + await callbacks.handleError(`parsing ${this.name} args`, new Error(errorMessage)) + callbacks.pushToolResult(`${errorMessage}`) + return + } + + // Execute with typed parameters + await this.execute(params, task, callbacks) + } +} diff --git a/src/core/tools/BrowserActionTool.ts b/src/core/tools/BrowserActionTool.ts new file mode 100644 index 0000000000..3e8f6f176e --- /dev/null +++ b/src/core/tools/BrowserActionTool.ts @@ -0,0 +1,244 @@ +import type { BrowserActionParams, Coordinate, Size } from "@roo-code/types" +import { Task } from "../task/Task" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" +import { + BrowserAction, + BrowserActionResult, + browserActions, + ClineSayBrowserAction, +} from "../../shared/ExtensionMessage" +import { formatResponse } from "../prompts/responses" + +export class BrowserActionTool extends BaseTool<"browser_action"> { + readonly name = "browser_action" as const + + parseLegacy(params: Partial>): BrowserActionParams { + const action = params.action as BrowserAction | undefined + + // Parse coordinate if present - XML protocol sends "x,y" format + let coordinate: Coordinate | undefined + if (params.coordinate) { + // Try parsing as "x,y" string first (XML protocol) + const parts = params.coordinate.split(",") + if (parts.length === 2) { + const x = parseInt(parts[0], 10) + const y = parseInt(parts[1], 10) + if (!isNaN(x) && !isNaN(y)) { + coordinate = { x, y } + } + } else { + // Try parsing as JSON object (fallback) + try { + const parsed = JSON.parse(params.coordinate) + if (parsed && typeof parsed.x === "number" && typeof parsed.y === "number") { + coordinate = { x: parsed.x, y: parsed.y } + } + } catch (error) { + // Invalid coordinate format, leave undefined + } + } + } + + // Parse size if present - XML protocol sends "width,height" format + let size: Size | undefined + if (params.size) { + // Try parsing as "width,height" string first (XML protocol) + const parts = params.size.split(",") + if (parts.length === 2) { + const width = parseInt(parts[0], 10) + const height = parseInt(parts[1], 10) + if (!isNaN(width) && !isNaN(height)) { + size = { width, height } + } + } else { + // Try parsing as JSON object (fallback) + try { + const parsed = JSON.parse(params.size) + if (parsed && typeof parsed.width === "number" && typeof parsed.height === "number") { + size = { width: parsed.width, height: parsed.height } + } + } catch (error) { + // Invalid size format, leave undefined + } + } + } + + return { + action: action!, + url: params.url, + coordinate, + size, + text: params.text, + } + } + + async execute(params: BrowserActionParams, task: Task, callbacks: ToolCallbacks): Promise { + const { action, url, coordinate, text, size } = params + const { handleError, pushToolResult } = callbacks + + // Validate action + if (!action || !browserActions.includes(action)) { + task.consecutiveMistakeCount++ + task.recordToolError("browser_action") + pushToolResult(await task.sayAndCreateMissingParamError("browser_action", "action")) + await task.browserSession.closeBrowser() + return + } + + try { + let browserActionResult: BrowserActionResult = {} + + if (action === "launch") { + if (!url) { + task.consecutiveMistakeCount++ + task.recordToolError("browser_action") + pushToolResult(await task.sayAndCreateMissingParamError("browser_action", "url")) + await task.browserSession.closeBrowser() + return + } + + task.consecutiveMistakeCount = 0 + const didApprove = await callbacks.askApproval("browser_action_launch", url) + + if (!didApprove) { + return + } + + await task.say("browser_action_result", "") + await task.browserSession.launchBrowser() + browserActionResult = await task.browserSession.navigateToUrl(url) + } else { + // Validate parameters for specific actions + if (action === "click" || action === "hover") { + if (!coordinate) { + task.consecutiveMistakeCount++ + task.recordToolError("browser_action") + pushToolResult(await task.sayAndCreateMissingParamError("browser_action", "coordinate")) + await task.browserSession.closeBrowser() + return + } + } + + if (action === "type") { + if (!text) { + task.consecutiveMistakeCount++ + task.recordToolError("browser_action") + pushToolResult(await task.sayAndCreateMissingParamError("browser_action", "text")) + await task.browserSession.closeBrowser() + return + } + } + + if (action === "resize") { + if (!size) { + task.consecutiveMistakeCount++ + task.recordToolError("browser_action") + pushToolResult(await task.sayAndCreateMissingParamError("browser_action", "size")) + await task.browserSession.closeBrowser() + return + } + } + + task.consecutiveMistakeCount = 0 + + await task.say( + "browser_action", + JSON.stringify({ + action: action as BrowserAction, + coordinate: coordinate ? `${coordinate.x},${coordinate.y}` : undefined, + text, + } satisfies ClineSayBrowserAction), + undefined, + false, + ) + + switch (action) { + case "click": + browserActionResult = await task.browserSession.click(`${coordinate!.x},${coordinate!.y}`) + break + case "hover": + browserActionResult = await task.browserSession.hover(`${coordinate!.x},${coordinate!.y}`) + break + case "type": + browserActionResult = await task.browserSession.type(text!) + break + case "scroll_down": + browserActionResult = await task.browserSession.scrollDown() + break + case "scroll_up": + browserActionResult = await task.browserSession.scrollUp() + break + case "resize": + browserActionResult = await task.browserSession.resize(`${size!.width},${size!.height}`) + break + case "close": + browserActionResult = await task.browserSession.closeBrowser() + break + } + } + + switch (action) { + case "launch": + case "click": + case "hover": + case "type": + case "scroll_down": + case "scroll_up": + case "resize": + await task.say("browser_action_result", JSON.stringify(browserActionResult)) + + pushToolResult( + formatResponse.toolResult( + `The browser action has been executed. The console logs and screenshot have been captured for your analysis.\n\nConsole logs:\n${ + browserActionResult?.logs || "(No new logs)" + }\n\n(REMEMBER: if you need to proceed to using non-\`browser_action\` tools or launch a new browser, you MUST first close cline browser. For example, if after analyzing the logs and screenshot you need to edit a file, you must first close the browser before you can use the write_to_file tool.)`, + browserActionResult?.screenshot ? [browserActionResult.screenshot] : [], + ), + ) + break + + case "close": + pushToolResult( + formatResponse.toolResult( + `The browser has been closed. You may now proceed to using other tools.`, + ), + ) + break + } + } catch (error) { + await task.browserSession.closeBrowser() + await handleError("executing browser action", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"browser_action">): Promise { + 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 + + if (!action || !browserActions.includes(action)) { + return + } + + if (action === "launch") { + await task + .ask("browser_action_launch", this.removeClosingTag("url", url, block.partial), block.partial) + .catch(() => {}) + } else { + await task.say( + "browser_action", + JSON.stringify({ + action: action as BrowserAction, + coordinate: this.removeClosingTag("coordinate", coordinate, block.partial), + text: this.removeClosingTag("text", text, block.partial), + } satisfies ClineSayBrowserAction), + undefined, + block.partial, + ) + } + } +} + +export const browserActionTool = new BrowserActionTool() diff --git a/src/core/tools/CodebaseSearchTool.ts b/src/core/tools/CodebaseSearchTool.ts new file mode 100644 index 0000000000..0637ac5241 --- /dev/null +++ b/src/core/tools/CodebaseSearchTool.ts @@ -0,0 +1,158 @@ +import * as vscode from "vscode" +import path from "path" + +import { Task } from "../task/Task" +import { CodeIndexManager } from "../../services/code-index/manager" +import { getWorkspacePath } from "../../utils/path" +import { formatResponse } from "../prompts/responses" +import { VectorStoreSearchResult } from "../../services/code-index/interfaces" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +interface CodebaseSearchParams { + query: string + path?: string +} + +export class CodebaseSearchTool extends BaseTool<"codebase_search"> { + readonly name = "codebase_search" as const + + parseLegacy(params: Partial>): CodebaseSearchParams { + let query = params.query + let directoryPrefix = params.path + + if (directoryPrefix) { + directoryPrefix = path.normalize(directoryPrefix) + } + + return { + query: query || "", + path: directoryPrefix, + } + } + + async execute(params: CodebaseSearchParams, task: Task, callbacks: ToolCallbacks): Promise { + const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks + const { query, path: directoryPrefix } = params + + const workspacePath = task.cwd && task.cwd.trim() !== "" ? task.cwd : getWorkspacePath() + + if (!workspacePath) { + await handleError("codebase_search", new Error("Could not determine workspace path.")) + return + } + + if (!query) { + task.consecutiveMistakeCount++ + pushToolResult(await task.sayAndCreateMissingParamError("codebase_search", "query")) + return + } + + const sharedMessageProps = { + tool: "codebaseSearch", + query: query, + path: directoryPrefix, + isOutsideWorkspace: false, + } + + const didApprove = await askApproval("tool", JSON.stringify(sharedMessageProps)) + if (!didApprove) { + pushToolResult(formatResponse.toolDenied()) + return + } + + task.consecutiveMistakeCount = 0 + + try { + const context = task.providerRef.deref()?.context + if (!context) { + throw new Error("Extension context is not available.") + } + + const manager = CodeIndexManager.getInstance(context) + + if (!manager) { + throw new Error("CodeIndexManager is not available.") + } + + if (!manager.isFeatureEnabled) { + throw new Error("Code Indexing is disabled in the settings.") + } + if (!manager.isFeatureConfigured) { + throw new Error("Code Indexing is not configured (Missing OpenAI Key or Qdrant URL).") + } + + const searchResults: VectorStoreSearchResult[] = await manager.searchIndex(query, directoryPrefix) + + if (!searchResults || searchResults.length === 0) { + pushToolResult(`No relevant code snippets found for the query: "${query}"`) + return + } + + const jsonResult = { + query, + results: [], + } as { + query: string + results: Array<{ + filePath: string + score: number + startLine: number + endLine: number + codeChunk: string + }> + } + + searchResults.forEach((result) => { + if (!result.payload) return + if (!("filePath" in result.payload)) return + + const relativePath = vscode.workspace.asRelativePath(result.payload.filePath, false) + + jsonResult.results.push({ + filePath: relativePath, + score: result.score, + startLine: result.payload.startLine, + endLine: result.payload.endLine, + codeChunk: result.payload.codeChunk.trim(), + }) + }) + + const payload = { tool: "codebaseSearch", content: jsonResult } + await task.say("codebase_search_result", JSON.stringify(payload)) + + const output = `Query: ${query} +Results: + +${jsonResult.results + .map( + (result) => `File path: ${result.filePath} +Score: ${result.score} +Lines: ${result.startLine}-${result.endLine} +Code Chunk: ${result.codeChunk} +`, + ) + .join("\n")}` + + pushToolResult(output) + } catch (error: any) { + await handleError("codebase_search", error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"codebase_search">): Promise { + const query: string | undefined = block.params.query + const directoryPrefix: string | undefined = block.params.path + + const sharedMessageProps = { + tool: "codebaseSearch", + query: query, + path: directoryPrefix, + isOutsideWorkspace: false, + } + + await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {}) + } +} + +export const codebaseSearchTool = new CodebaseSearchTool() diff --git a/src/core/tools/executeCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts similarity index 87% rename from src/core/tools/executeCommandTool.ts rename to src/core/tools/ExecuteCommandTool.ts index 8d0b39bde4..aa6bb097d0 100644 --- a/src/core/tools/executeCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -9,7 +9,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { Task } from "../task/Task" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag, ToolResponse } from "../../shared/tools" +import { ToolUse, ToolResponse } from "../../shared/tools" import { formatResponse } from "../prompts/responses" import { unescapeHtmlEntities } from "../../utils/text-normalization" import { ExitCodeDetails, RooTerminalCallbacks, RooTerminalProcess } from "../../integrations/terminal/types" @@ -17,25 +17,30 @@ import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" import { Terminal } from "../../integrations/terminal/Terminal" import { Package } from "../../shared/package" import { t } from "../../i18n" +import { BaseTool, ToolCallbacks } from "./BaseTool" class ShellIntegrationError extends Error {} -export async function executeCommandTool( - task: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - let command: string | undefined = block.params.command - const customCwd: string | undefined = block.params.cwd +interface ExecuteCommandParams { + command: string + cwd?: string +} - try { - if (block.partial) { - await task.ask("command", removeClosingTag("command", command), block.partial).catch(() => {}) - return - } else { +export class ExecuteCommandTool extends BaseTool<"execute_command"> { + readonly name = "execute_command" as const + + parseLegacy(params: Partial>): ExecuteCommandParams { + return { + command: params.command || "", + cwd: params.cwd, + } + } + + async execute(params: ExecuteCommandParams, task: Task, callbacks: ToolCallbacks): Promise { + const { command, cwd: customCwd } = params + const { handleError, pushToolResult, askApproval, removeClosingTag, toolProtocol } = callbacks + + try { if (!command) { task.consecutiveMistakeCount++ task.recordToolError("execute_command") @@ -47,14 +52,14 @@ export async function executeCommandTool( if (ignoredFileAttemptedToAccess) { await task.say("rooignore_error", ignoredFileAttemptedToAccess) - pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(ignoredFileAttemptedToAccess))) + pushToolResult(formatResponse.rooIgnoreError(ignoredFileAttemptedToAccess, toolProtocol)) return } task.consecutiveMistakeCount = 0 - command = unescapeHtmlEntities(command) // Unescape HTML entities. - const didApprove = await askApproval("command", command) + const unescapedCommand = unescapeHtmlEntities(command) + const didApprove = await askApproval("command", unescapedCommand) if (!didApprove) { return @@ -81,14 +86,16 @@ export async function executeCommandTool( .get("commandTimeoutAllowlist", []) // Check if command matches any prefix in the allowlist - const isCommandAllowlisted = commandTimeoutAllowlist.some((prefix) => command!.startsWith(prefix.trim())) + const isCommandAllowlisted = commandTimeoutAllowlist.some((prefix) => + unescapedCommand.startsWith(prefix.trim()), + ) // Convert seconds to milliseconds for internal use, but skip timeout if command is allowlisted const commandExecutionTimeout = isCommandAllowlisted ? 0 : commandExecutionTimeoutSeconds * 1000 const options: ExecuteCommandOptions = { executionId, - command, + command: unescapedCommand, customCwd, terminalShellIntegrationDisabled, terminalOutputLineLimit, @@ -97,7 +104,7 @@ export async function executeCommandTool( } try { - const [rejected, result] = await executeCommand(task, options) + const [rejected, result] = await executeCommandInTerminal(task, options) if (rejected) { task.didRejectTool = true @@ -110,7 +117,7 @@ export async function executeCommandTool( await task.say("shell_integration_warning") if (error instanceof ShellIntegrationError) { - const [rejected, result] = await executeCommand(task, { + const [rejected, result] = await executeCommandInTerminal(task, { ...options, terminalShellIntegrationDisabled: true, }) @@ -125,11 +132,18 @@ export async function executeCommandTool( } } + return + } catch (error) { + await handleError("executing command", error as Error) return } - } catch (error) { - await handleError("executing command", error) - return + } + + override async handlePartial(task: Task, block: ToolUse<"execute_command">): Promise { + const command = block.params.command + await task + .ask("command", this.removeClosingTag("command", command, block.partial), block.partial) + .catch(() => {}) } } @@ -143,7 +157,7 @@ export type ExecuteCommandOptions = { commandExecutionTimeout?: number } -export async function executeCommand( +export async function executeCommandInTerminal( task: Task, { executionId, @@ -367,3 +381,5 @@ export async function executeCommand( ] } } + +export const executeCommandTool = new ExecuteCommandTool() diff --git a/src/core/tools/FetchInstructionsTool.ts b/src/core/tools/FetchInstructionsTool.ts new file mode 100644 index 0000000000..0632fc12b2 --- /dev/null +++ b/src/core/tools/FetchInstructionsTool.ts @@ -0,0 +1,78 @@ +import { Task } from "../task/Task" +import { fetchInstructions } from "../prompts/instructions/instructions" +import { ClineSayTool } from "../../shared/ExtensionMessage" +import { formatResponse } from "../prompts/responses" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +interface FetchInstructionsParams { + task: string +} + +export class FetchInstructionsTool extends BaseTool<"fetch_instructions"> { + readonly name = "fetch_instructions" as const + + parseLegacy(params: Partial>): FetchInstructionsParams { + return { + task: params.task || "", + } + } + + async execute(params: FetchInstructionsParams, task: Task, callbacks: ToolCallbacks): Promise { + const { handleError, pushToolResult, askApproval, toolProtocol } = callbacks + const { task: taskParam } = params + + try { + if (!taskParam) { + task.consecutiveMistakeCount++ + task.recordToolError("fetch_instructions") + pushToolResult(await task.sayAndCreateMissingParamError("fetch_instructions", "task")) + return + } + + task.consecutiveMistakeCount = 0 + + const completeMessage = JSON.stringify({ + tool: "fetchInstructions", + content: taskParam, + } satisfies ClineSayTool) + + const didApprove = await askApproval("tool", completeMessage) + + if (!didApprove) { + return + } + + // Now fetch the content and provide it to the agent. + const provider = task.providerRef.deref() + const mcpHub = provider?.getMcpHub() + + if (!mcpHub) { + throw new Error("MCP hub not available") + } + + const diffStrategy = task.diffStrategy + const context = provider?.context + const content = await fetchInstructions(taskParam, { mcpHub, diffStrategy, context }) + + if (!content) { + pushToolResult(formatResponse.toolError(`Invalid instructions request: ${taskParam}`)) + return + } + + pushToolResult(content) + } catch (error) { + await handleError("fetch instructions", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"fetch_instructions">): Promise { + const taskParam: string | undefined = block.params.task + const sharedMessageProps: ClineSayTool = { tool: "fetchInstructions", content: taskParam } + + const partialMessage = JSON.stringify({ ...sharedMessageProps, content: undefined } satisfies ClineSayTool) + await task.ask("tool", partialMessage, block.partial).catch(() => {}) + } +} + +export const fetchInstructionsTool = new FetchInstructionsTool() diff --git a/src/core/tools/GenerateImageTool.ts b/src/core/tools/GenerateImageTool.ts new file mode 100644 index 0000000000..60914a86a7 --- /dev/null +++ b/src/core/tools/GenerateImageTool.ts @@ -0,0 +1,241 @@ +import path from "path" +import fs from "fs/promises" +import * as vscode from "vscode" +import type { GenerateImageParams } from "@roo-code/types" +import { Task } from "../task/Task" +import { formatResponse } from "../prompts/responses" +import { fileExistsAtPath } from "../../utils/fs" +import { getReadablePath } from "../../utils/path" +import { isPathOutsideWorkspace } from "../../utils/pathUtils" +import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" +import { OpenRouterHandler } from "../../api/providers/openrouter" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +const IMAGE_GENERATION_MODELS = ["google/gemini-2.5-flash-image", "openai/gpt-5-image", "openai/gpt-5-image-mini"] + +export class GenerateImageTool extends BaseTool<"generate_image"> { + readonly name = "generate_image" as const + + parseLegacy(params: Partial>): GenerateImageParams { + return { + prompt: params.prompt || "", + path: params.path || "", + image: params.image, + } + } + + async execute(params: GenerateImageParams, task: Task, callbacks: ToolCallbacks): Promise { + const { prompt, path: relPath, image: inputImagePath } = params + const { handleError, pushToolResult, askApproval, removeClosingTag, toolProtocol } = callbacks + + const provider = task.providerRef.deref() + const state = await provider?.getState() + const isImageGenerationEnabled = experiments.isEnabled( + state?.experiments ?? {}, + EXPERIMENT_IDS.IMAGE_GENERATION, + ) + + if (!isImageGenerationEnabled) { + pushToolResult( + formatResponse.toolError( + "Image generation is an experimental feature that must be enabled in settings. Please enable 'Image Generation' in the Experimental Settings section.", + ), + ) + return + } + + if (!prompt) { + task.consecutiveMistakeCount++ + task.recordToolError("generate_image") + pushToolResult(await task.sayAndCreateMissingParamError("generate_image", "prompt")) + return + } + + if (!relPath) { + task.consecutiveMistakeCount++ + task.recordToolError("generate_image") + pushToolResult(await task.sayAndCreateMissingParamError("generate_image", "path")) + return + } + + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) + if (!accessAllowed) { + await task.say("rooignore_error", relPath) + pushToolResult(formatResponse.rooIgnoreError(relPath, toolProtocol)) + return + } + + let inputImageData: string | undefined + if (inputImagePath) { + const inputImageFullPath = path.resolve(task.cwd, inputImagePath) + + const inputImageExists = await fileExistsAtPath(inputImageFullPath) + if (!inputImageExists) { + await task.say("error", `Input image not found: ${getReadablePath(task.cwd, inputImagePath)}`) + pushToolResult( + formatResponse.toolError(`Input image not found: ${getReadablePath(task.cwd, inputImagePath)}`), + ) + return + } + + const inputImageAccessAllowed = task.rooIgnoreController?.validateAccess(inputImagePath) + if (!inputImageAccessAllowed) { + await task.say("rooignore_error", inputImagePath) + pushToolResult(formatResponse.rooIgnoreError(inputImagePath, toolProtocol)) + return + } + + try { + const imageBuffer = await fs.readFile(inputImageFullPath) + const imageExtension = path.extname(inputImageFullPath).toLowerCase().replace(".", "") + + const supportedFormats = ["png", "jpg", "jpeg", "gif", "webp"] + if (!supportedFormats.includes(imageExtension)) { + await task.say( + "error", + `Unsupported image format: ${imageExtension}. Supported formats: ${supportedFormats.join(", ")}`, + ) + pushToolResult( + formatResponse.toolError( + `Unsupported image format: ${imageExtension}. Supported formats: ${supportedFormats.join(", ")}`, + ), + ) + return + } + + const mimeType = imageExtension === "jpg" ? "jpeg" : imageExtension + inputImageData = `data:image/${mimeType};base64,${imageBuffer.toString("base64")}` + } catch (error) { + await task.say( + "error", + `Failed to read input image: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + pushToolResult( + formatResponse.toolError( + `Failed to read input image: ${error instanceof Error ? error.message : "Unknown error"}`, + ), + ) + return + } + } + + const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false + + const openRouterApiKey = state?.openRouterImageApiKey + + if (!openRouterApiKey) { + await task.say( + "error", + "OpenRouter API key is required for image generation. Please configure it in the Image Generation experimental settings.", + ) + pushToolResult( + formatResponse.toolError( + "OpenRouter API key is required for image generation. Please configure it in the Image Generation experimental settings.", + ), + ) + return + } + + const selectedModel = state?.openRouterImageGenerationSelectedModel || IMAGE_GENERATION_MODELS[0] + + const fullPath = path.resolve(task.cwd, removeClosingTag("path", relPath)) + const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) + + const sharedMessageProps = { + tool: "generateImage" as const, + path: getReadablePath(task.cwd, removeClosingTag("path", relPath)), + content: prompt, + isOutsideWorkspace, + isProtected: isWriteProtected, + } + + try { + task.consecutiveMistakeCount = 0 + + const approvalMessage = JSON.stringify({ + ...sharedMessageProps, + content: prompt, + ...(inputImagePath && { inputImage: getReadablePath(task.cwd, inputImagePath) }), + }) + + const didApprove = await askApproval("tool", approvalMessage, undefined, isWriteProtected) + + if (!didApprove) { + return + } + + const openRouterHandler = new OpenRouterHandler({} as any) + + const result = await openRouterHandler.generateImage( + prompt, + selectedModel, + openRouterApiKey, + inputImageData, + ) + + if (!result.success) { + await task.say("error", result.error || "Failed to generate image") + pushToolResult(formatResponse.toolError(result.error || "Failed to generate image")) + return + } + + if (!result.imageData) { + const errorMessage = "No image data received" + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage)) + return + } + + const base64Match = result.imageData.match(/^data:image\/(png|jpeg|jpg);base64,(.+)$/) + if (!base64Match) { + const errorMessage = "Invalid image format received" + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage)) + return + } + + const imageFormat = base64Match[1] + const base64Data = base64Match[2] + + let finalPath = relPath + if (!finalPath.match(/\.(png|jpg|jpeg)$/i)) { + finalPath = `${finalPath}.${imageFormat === "jpeg" ? "jpg" : imageFormat}` + } + + const imageBuffer = Buffer.from(base64Data, "base64") + + const absolutePath = path.resolve(task.cwd, finalPath) + const directory = path.dirname(absolutePath) + await fs.mkdir(directory, { recursive: true }) + + await fs.writeFile(absolutePath, imageBuffer) + + if (finalPath) { + await task.fileContextTracker.trackFileContext(finalPath, "roo_edited") + } + + task.didEditFile = true + + task.recordToolUsage("generate_image") + + const fullImagePath = path.join(task.cwd, finalPath) + + let imageUri = provider?.convertToWebviewUri?.(fullImagePath) ?? vscode.Uri.file(fullImagePath).toString() + + const cacheBuster = Date.now() + imageUri = imageUri.includes("?") ? `${imageUri}&t=${cacheBuster}` : `${imageUri}?t=${cacheBuster}` + + await task.say("image", JSON.stringify({ imageUri, imagePath: fullImagePath })) + pushToolResult(formatResponse.toolResult(getReadablePath(task.cwd, finalPath))) + } catch (error) { + await handleError("generating image", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"generate_image">): Promise { + return + } +} + +export const generateImageTool = new GenerateImageTool() diff --git a/src/core/tools/InsertContentTool.ts b/src/core/tools/InsertContentTool.ts new file mode 100644 index 0000000000..68acc229b9 --- /dev/null +++ b/src/core/tools/InsertContentTool.ts @@ -0,0 +1,225 @@ +import fs from "fs/promises" +import path from "path" + +import { getReadablePath } from "../../utils/path" +import { Task } from "../task/Task" +import { formatResponse } from "../prompts/responses" +import { ClineSayTool } from "../../shared/ExtensionMessage" +import { RecordSource } from "../context-tracking/FileContextTrackerTypes" +import { fileExistsAtPath } from "../../utils/fs" +import { insertGroups } from "../diff/insert-groups" +import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" +import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" +import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +interface InsertContentParams { + path: string + line: number + content: string +} + +export class InsertContentTool extends BaseTool<"insert_content"> { + readonly name = "insert_content" as const + + parseLegacy(params: Partial>): InsertContentParams { + const relPath = params.path || "" + const lineStr = params.line || "" + const content = params.content || "" + + const lineNumber = parseInt(lineStr, 10) + + return { + path: relPath, + line: lineNumber, + content: content, + } + } + + async execute(params: InsertContentParams, task: Task, callbacks: ToolCallbacks): Promise { + const { path: relPath, line: lineNumber, content } = params + const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks + + try { + // Validate required parameters + if (!relPath) { + task.consecutiveMistakeCount++ + task.recordToolError("insert_content") + pushToolResult(await task.sayAndCreateMissingParamError("insert_content", "path")) + return + } + + if (isNaN(lineNumber) || lineNumber < 0) { + task.consecutiveMistakeCount++ + task.recordToolError("insert_content") + pushToolResult(formatResponse.toolError("Invalid line number. Must be a non-negative integer.")) + return + } + + if (content === undefined) { + task.consecutiveMistakeCount++ + task.recordToolError("insert_content") + pushToolResult(await task.sayAndCreateMissingParamError("insert_content", "content")) + return + } + + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) + + if (!accessAllowed) { + await task.say("rooignore_error", relPath) + pushToolResult(formatResponse.rooIgnoreError(relPath, toolProtocol)) + return + } + + // Check if file is write-protected + const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false + + const absolutePath = path.resolve(task.cwd, relPath) + + const fileExists = await fileExistsAtPath(absolutePath) + let fileContent: string = "" + if (!fileExists) { + if (lineNumber > 1) { + task.consecutiveMistakeCount++ + task.recordToolError("insert_content") + const formattedError = `Cannot insert content at line ${lineNumber} into a non-existent file. For new files, 'line' must be 0 (to append) or 1 (to insert at the beginning).` + await task.say("error", formattedError) + pushToolResult(formattedError) + return + } + } else { + fileContent = await fs.readFile(absolutePath, "utf8") + } + + task.consecutiveMistakeCount = 0 + + task.diffViewProvider.editType = fileExists ? "modify" : "create" + task.diffViewProvider.originalContent = fileContent + const lines = fileExists ? fileContent.split("\n") : [] + + let updatedContent = insertGroups(lines, [ + { + index: lineNumber - 1, + elements: content.split("\n"), + }, + ]).join("\n") + + // Check if preventFocusDisruption experiment is enabled + const provider = task.providerRef.deref() + const state = await provider?.getState() + const diagnosticsEnabled = state?.diagnosticsEnabled ?? true + const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS + const isPreventFocusDisruptionEnabled = experiments.isEnabled( + state?.experiments ?? {}, + EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, + ) + + // Build unified diff for display (normalize EOLs only for diff generation) + let unified: string + if (fileExists) { + const oldForDiff = fileContent.replace(/\r\n/g, "\n") + const newForDiff = updatedContent.replace(/\r\n/g, "\n") + unified = formatResponse.createPrettyPatch(relPath, oldForDiff, newForDiff) + if (!unified) { + pushToolResult(`No changes needed for '${relPath}'`) + return + } + } else { + const newForDiff = updatedContent.replace(/\r\n/g, "\n") + unified = convertNewFileToUnifiedDiff(newForDiff, relPath) + } + unified = sanitizeUnifiedDiff(unified) + const diffStats = computeDiffStats(unified) || undefined + + // Prepare the approval message (same for both flows) + const sharedMessageProps: ClineSayTool = { + tool: "insertContent", + path: getReadablePath(task.cwd, relPath), + diff: content, + lineNumber: lineNumber, + } + + const completeMessage = JSON.stringify({ + ...sharedMessageProps, + // Send unified diff as content for render-only webview + content: unified, + lineNumber: lineNumber, + isProtected: isWriteProtected, + diffStats, + } satisfies ClineSayTool) + + // Show diff view if focus disruption prevention is disabled + if (!isPreventFocusDisruptionEnabled) { + await task.diffViewProvider.open(relPath) + await task.diffViewProvider.update(updatedContent, true) + task.diffViewProvider.scrollToFirstDiff() + } + + // Ask for approval (same for both flows) + const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) + + if (!didApprove) { + // Revert changes if diff view was shown + if (!isPreventFocusDisruptionEnabled) { + await task.diffViewProvider.revertChanges() + } + pushToolResult("Changes were rejected by the user.") + await task.diffViewProvider.reset() + return + } + + // Save the changes + if (isPreventFocusDisruptionEnabled) { + // Direct file write without diff view or opening the file + await task.diffViewProvider.saveDirectly( + relPath, + updatedContent, + false, + diagnosticsEnabled, + writeDelayMs, + ) + } else { + // Call saveChanges to update the DiffViewProvider properties + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + } + + // Track file edit operation + if (relPath) { + await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) + } + + task.didEditFile = true + + // Get the formatted response message + const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, !fileExists) + + pushToolResult(message) + + await task.diffViewProvider.reset() + + // Process any queued messages after file edit completes + task.processQueuedMessages() + } catch (error) { + await handleError("insert content", error as Error) + await task.diffViewProvider.reset() + } + } + + override async handlePartial(task: Task, block: ToolUse<"insert_content">): Promise { + const relPath: string | undefined = block.params.path + const line: string | undefined = block.params.line + const content: string | undefined = block.params.content + + const sharedMessageProps: ClineSayTool = { + tool: "insertContent", + path: getReadablePath(task.cwd, relPath || ""), + diff: content, + lineNumber: line ? parseInt(line, 10) : undefined, + } + + await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {}) + } +} + +export const insertContentTool = new InsertContentTool() diff --git a/src/core/tools/ListCodeDefinitionNamesTool.ts b/src/core/tools/ListCodeDefinitionNamesTool.ts new file mode 100644 index 0000000000..981b508ee3 --- /dev/null +++ b/src/core/tools/ListCodeDefinitionNamesTool.ts @@ -0,0 +1,107 @@ +import path from "path" +import fs from "fs/promises" + +import { Task } from "../task/Task" +import { ClineSayTool } from "../../shared/ExtensionMessage" +import { getReadablePath } from "../../utils/path" +import { isPathOutsideWorkspace } from "../../utils/pathUtils" +import { parseSourceCodeForDefinitionsTopLevel, parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter" +import { RecordSource } from "../context-tracking/FileContextTrackerTypes" +import { truncateDefinitionsToLineLimit } from "./helpers/truncateDefinitions" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +interface ListCodeDefinitionNamesParams { + path: string +} + +export class ListCodeDefinitionNamesTool extends BaseTool<"list_code_definition_names"> { + readonly name = "list_code_definition_names" as const + + parseLegacy(params: Partial>): ListCodeDefinitionNamesParams { + return { + path: params.path || "", + } + } + + async execute(params: ListCodeDefinitionNamesParams, task: Task, callbacks: ToolCallbacks): Promise { + const { askApproval, handleError, pushToolResult } = callbacks + const { path: relPath } = params + + if (!relPath) { + task.consecutiveMistakeCount++ + task.recordToolError("list_code_definition_names") + pushToolResult(await task.sayAndCreateMissingParamError("list_code_definition_names", "path")) + return + } + + task.consecutiveMistakeCount = 0 + + const absolutePath = path.resolve(task.cwd, relPath) + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + + const sharedMessageProps: ClineSayTool = { + tool: "listCodeDefinitionNames", + path: getReadablePath(task.cwd, relPath), + isOutsideWorkspace, + } + + try { + let result: string + + try { + const stats = await fs.stat(absolutePath) + + if (stats.isFile()) { + const fileResult = await parseSourceCodeDefinitionsForFile(absolutePath, task.rooIgnoreController) + + if (fileResult) { + const { maxReadFileLine = -1 } = (await task.providerRef.deref()?.getState()) ?? {} + result = truncateDefinitionsToLineLimit(fileResult, maxReadFileLine) + } else { + result = "No source code definitions found in file." + } + } else if (stats.isDirectory()) { + result = await parseSourceCodeForDefinitionsTopLevel(absolutePath, task.rooIgnoreController) + } else { + result = "The specified path is neither a file nor a directory." + } + } catch { + result = `${absolutePath}: does not exist or cannot be accessed.` + } + + const completeMessage = JSON.stringify({ ...sharedMessageProps, content: result } satisfies ClineSayTool) + const didApprove = await askApproval("tool", completeMessage) + + if (!didApprove) { + return + } + + if (relPath) { + await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + } + + pushToolResult(result) + } catch (error) { + await handleError("parsing source code definitions", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"list_code_definition_names">): Promise { + const relPath: string | undefined = block.params.path + + const absolutePath = relPath ? path.resolve(task.cwd, relPath) : task.cwd + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + + const sharedMessageProps: ClineSayTool = { + tool: "listCodeDefinitionNames", + path: getReadablePath(task.cwd, relPath || ""), + isOutsideWorkspace, + } + + const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "" } satisfies ClineSayTool) + await task.ask("tool", partialMessage, block.partial).catch(() => {}) + } +} + +export const listCodeDefinitionNamesTool = new ListCodeDefinitionNamesTool() diff --git a/src/core/tools/ListFilesTool.ts b/src/core/tools/ListFilesTool.ts new file mode 100644 index 0000000000..795bebf85a --- /dev/null +++ b/src/core/tools/ListFilesTool.ts @@ -0,0 +1,97 @@ +import * as path from "path" + +import { Task } from "../task/Task" +import { ClineSayTool } from "../../shared/ExtensionMessage" +import { formatResponse } from "../prompts/responses" +import { listFiles } from "../../services/glob/list-files" +import { getReadablePath } from "../../utils/path" +import { isPathOutsideWorkspace } from "../../utils/pathUtils" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +interface ListFilesParams { + path: string + recursive?: boolean +} + +export class ListFilesTool extends BaseTool<"list_files"> { + readonly name = "list_files" as const + + parseLegacy(params: Partial>): ListFilesParams { + const recursiveRaw: string | undefined = params.recursive + const recursive = recursiveRaw?.toLowerCase() === "true" + + return { + path: params.path || "", + recursive, + } + } + + async execute(params: ListFilesParams, task: Task, callbacks: ToolCallbacks): Promise { + const { path: relDirPath, recursive } = params + const { askApproval, handleError, pushToolResult, removeClosingTag } = callbacks + + try { + if (!relDirPath) { + task.consecutiveMistakeCount++ + task.recordToolError("list_files") + pushToolResult(await task.sayAndCreateMissingParamError("list_files", "path")) + return + } + + task.consecutiveMistakeCount = 0 + + const absolutePath = path.resolve(task.cwd, relDirPath) + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + + const [files, didHitLimit] = await listFiles(absolutePath, recursive || false, 200) + const { showRooIgnoredFiles = false } = (await task.providerRef.deref()?.getState()) ?? {} + + const result = formatResponse.formatFilesList( + absolutePath, + files, + didHitLimit, + task.rooIgnoreController, + showRooIgnoredFiles, + task.rooProtectedController, + ) + + const sharedMessageProps: ClineSayTool = { + tool: !recursive ? "listFilesTopLevel" : "listFilesRecursive", + path: getReadablePath(task.cwd, relDirPath), + isOutsideWorkspace, + } + + const completeMessage = JSON.stringify({ ...sharedMessageProps, content: result } satisfies ClineSayTool) + const didApprove = await askApproval("tool", completeMessage) + + if (!didApprove) { + return + } + + pushToolResult(result) + } catch (error) { + await handleError("listing files", error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"list_files">): Promise { + const relDirPath: string | undefined = block.params.path + const recursiveRaw: string | undefined = block.params.recursive + const recursive = recursiveRaw?.toLowerCase() === "true" + + const absolutePath = relDirPath ? path.resolve(task.cwd, relDirPath) : task.cwd + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + + const sharedMessageProps: ClineSayTool = { + tool: !recursive ? "listFilesTopLevel" : "listFilesRecursive", + path: getReadablePath(task.cwd, this.removeClosingTag("path", relDirPath, block.partial)), + isOutsideWorkspace, + } + + const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "" } satisfies ClineSayTool) + await task.ask("tool", partialMessage, block.partial).catch(() => {}) + } +} + +export const listFilesTool = new ListFilesTool() diff --git a/src/core/tools/multiApplyDiffTool.ts b/src/core/tools/MultiApplyDiffTool.ts similarity index 93% rename from src/core/tools/multiApplyDiffTool.ts rename to src/core/tools/MultiApplyDiffTool.ts index 08bce08ede..7e076d27a9 100644 --- a/src/core/tools/multiApplyDiffTool.ts +++ b/src/core/tools/MultiApplyDiffTool.ts @@ -14,8 +14,10 @@ import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { unescapeHtmlEntities } from "../../utils/text-normalization" import { parseXmlForDiff } from "../../utils/xml" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" -import { applyDiffToolLegacy } from "./applyDiffTool" +import { applyDiffTool as applyDiffToolClass } from "./ApplyDiffTool" import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" +import { isNativeProtocol } from "@roo-code/types" +import { resolveToolProtocol } from "../../utils/resolveToolProtocol" interface DiffOperation { path: string @@ -59,18 +61,36 @@ export async function applyDiffTool( pushToolResult: PushToolResult, removeClosingTag: RemoveClosingTag, ) { + // Check if native protocol is enabled - if so, always use single-file class-based tool + const toolProtocol = resolveToolProtocol(cline.apiConfiguration, cline.api.getModel().info) + if (isNativeProtocol(toolProtocol)) { + return applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) + } + // Check if MULTI_FILE_APPLY_DIFF experiment is enabled const provider = cline.providerRef.deref() - if (provider) { - const state = await provider.getState() + const state = await provider?.getState() + if (provider && state) { const isMultiFileApplyDiffEnabled = experiments.isEnabled( state.experiments ?? {}, EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF, ) - // If experiment is disabled, use legacy tool + // If experiment is disabled, use single-file class-based tool if (!isMultiFileApplyDiffEnabled) { - return applyDiffToolLegacy(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + return applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) } } @@ -248,7 +268,7 @@ Original error: ${errorMessage}` await cline.say("rooignore_error", relPath) updateOperationResult(relPath, { status: "blocked", - error: formatResponse.rooIgnoreError(relPath), + error: formatResponse.rooIgnoreError(relPath, undefined), }) continue } @@ -716,9 +736,16 @@ ${errorDetails ? `\nTechnical details:\n${errorDetails}\n` : ""} } } + // Check protocol for notice formatting + const toolProtocol = resolveToolProtocol(cline.apiConfiguration, cline.api.getModel().info) const singleBlockNotice = totalSearchBlocks === 1 - ? "\nMaking multiple related changes in a single apply_diff is more efficient. If other changes are needed in this file, please include them as additional SEARCH/REPLACE blocks." + ? isNativeProtocol(toolProtocol) + ? "\n" + + JSON.stringify({ + notice: "Making multiple related changes in a single apply_diff is more efficient. If other changes are needed in this file, please include them as additional SEARCH/REPLACE blocks.", + }) + : "\nMaking multiple related changes in a single apply_diff is more efficient. If other changes are needed in this file, please include them as additional SEARCH/REPLACE blocks." : "" // Push the final result combining all operation results diff --git a/src/core/tools/newTaskTool.ts b/src/core/tools/NewTaskTool.ts similarity index 70% rename from src/core/tools/newTaskTool.ts rename to src/core/tools/NewTaskTool.ts index aeb0c8393b..ce68b20f3e 100644 --- a/src/core/tools/newTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -2,38 +2,37 @@ import * as vscode from "vscode" import { TodoItem } from "@roo-code/types" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" import { Task } from "../task/Task" import { defaultModeSlug, getModeBySlug } from "../../shared/modes" import { formatResponse } from "../prompts/responses" import { t } from "../../i18n" -import { parseMarkdownChecklist } from "./updateTodoListTool" +import { parseMarkdownChecklist } from "./UpdateTodoListTool" import { Package } from "../../shared/package" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" -export async function newTaskTool( - task: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const mode: string | undefined = block.params.mode - const message: string | undefined = block.params.message - const todos: string | undefined = block.params.todos +interface NewTaskParams { + mode: string + message: string + todos?: string +} - try { - if (block.partial) { - const partialMessage = JSON.stringify({ - tool: "newTask", - mode: removeClosingTag("mode", mode), - content: removeClosingTag("message", message), - todos: removeClosingTag("todos", todos), - }) +export class NewTaskTool extends BaseTool<"new_task"> { + readonly name = "new_task" as const - await task.ask("tool", partialMessage, block.partial).catch(() => {}) - return - } else { + parseLegacy(params: Partial>): NewTaskParams { + return { + mode: params.mode || "", + message: params.message || "", + todos: params.todos, + } + } + + async execute(params: NewTaskParams, task: Task, callbacks: ToolCallbacks): Promise { + const { mode, message, todos } = params + const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks + + try { // Validate required parameters. if (!mode) { task.consecutiveMistakeCount++ @@ -134,10 +133,27 @@ export async function newTaskTool( `Successfully created new task in ${targetMode.name} mode with message: ${unescapedMessage} and ${todoItems.length} todo items`, ) + return + } catch (error) { + await handleError("creating new task", error) return } - } catch (error) { - await handleError("creating new task", error) - return + } + + override async handlePartial(task: Task, block: ToolUse<"new_task">): Promise { + const mode: string | undefined = block.params.mode + const message: string | undefined = block.params.message + const todos: string | undefined = block.params.todos + + const partialMessage = JSON.stringify({ + tool: "newTask", + mode: this.removeClosingTag("mode", mode, block.partial), + content: this.removeClosingTag("message", message, block.partial), + todos: this.removeClosingTag("todos", todos, block.partial), + }) + + await task.ask("tool", partialMessage, block.partial).catch(() => {}) } } + +export const newTaskTool = new NewTaskTool() diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts new file mode 100644 index 0000000000..d6989c103e --- /dev/null +++ b/src/core/tools/ReadFileTool.ts @@ -0,0 +1,736 @@ +import path from "path" +import { isBinaryFile } from "isbinaryfile" +import type { FileEntry, LineRange } from "@roo-code/types" +import { isNativeProtocol } from "@roo-code/types" + +import { Task } from "../task/Task" +import { ClineSayTool } from "../../shared/ExtensionMessage" +import { formatResponse } from "../prompts/responses" +import { t } from "../../i18n" +import { RecordSource } from "../context-tracking/FileContextTrackerTypes" +import { isPathOutsideWorkspace } from "../../utils/pathUtils" +import { getReadablePath } from "../../utils/path" +import { countFileLines } from "../../integrations/misc/line-counter" +import { readLines } from "../../integrations/misc/read-lines" +import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text" +import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter" +import { parseXml } from "../../utils/xml" +import { resolveToolProtocol } from "../../utils/resolveToolProtocol" +import { + DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + isSupportedImageFormat, + validateImageForProcessing, + processImageFile, + ImageMemoryTracker, +} from "./helpers/imageHelpers" +import { validateFileTokenBudget, truncateFileContent } from "./helpers/fileTokenBudget" +import { truncateDefinitionsToLineLimit } from "./helpers/truncateDefinitions" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +interface FileResult { + path: string + status: "approved" | "denied" | "blocked" | "error" | "pending" + content?: string + error?: string + notice?: string + lineRanges?: LineRange[] + xmlContent?: string + nativeContent?: string + imageDataUrl?: string + feedbackText?: string + feedbackImages?: any[] +} + +export class ReadFileTool extends BaseTool<"read_file"> { + readonly name = "read_file" as const + + parseLegacy(params: Partial>): { files: FileEntry[] } { + const argsXmlTag = params.args + const legacyPath = params.path + const legacyStartLineStr = params.start_line + const legacyEndLineStr = params.end_line + + const fileEntries: FileEntry[] = [] + + // XML args format + if (argsXmlTag) { + const parsed = parseXml(argsXmlTag) as any + const files = Array.isArray(parsed.file) ? parsed.file : [parsed.file].filter(Boolean) + + for (const file of files) { + if (!file.path) continue + + const fileEntry: FileEntry = { + path: file.path, + lineRanges: [], + } + + if (file.line_range) { + const ranges = Array.isArray(file.line_range) ? file.line_range : [file.line_range] + for (const range of ranges) { + const match = String(range).match(/(\d+)-(\d+)/) + if (match) { + const [, start, end] = match.map(Number) + if (!isNaN(start) && !isNaN(end)) { + fileEntry.lineRanges?.push({ start, end }) + } + } + } + } + fileEntries.push(fileEntry) + } + + return { files: fileEntries } + } + + // Legacy single file path + if (legacyPath) { + const fileEntry: FileEntry = { + path: legacyPath, + lineRanges: [], + } + + if (legacyStartLineStr && legacyEndLineStr) { + const start = parseInt(legacyStartLineStr, 10) + const end = parseInt(legacyEndLineStr, 10) + if (!isNaN(start) && !isNaN(end) && start > 0 && end > 0) { + fileEntry.lineRanges?.push({ start, end }) + } + } + fileEntries.push(fileEntry) + } + + return { files: fileEntries } + } + + async execute(params: { files: FileEntry[] }, task: Task, callbacks: ToolCallbacks): Promise { + const { handleError, pushToolResult, toolProtocol } = callbacks + const fileEntries = params.files + const modelInfo = task.api.getModel().info + const protocol = resolveToolProtocol(task.apiConfiguration, modelInfo) + const useNative = isNativeProtocol(protocol) + + if (!fileEntries || fileEntries.length === 0) { + task.consecutiveMistakeCount++ + task.recordToolError("read_file") + const errorMsg = await task.sayAndCreateMissingParamError("read_file", "args (containing valid file paths)") + const errorResult = useNative ? `Error: ${errorMsg}` : `${errorMsg}` + pushToolResult(errorResult) + return + } + + const supportsImages = modelInfo.supportsImages ?? false + + const fileResults: FileResult[] = fileEntries.map((entry) => ({ + path: entry.path, + status: "pending", + lineRanges: entry.lineRanges, + })) + + const updateFileResult = (filePath: string, updates: Partial) => { + const index = fileResults.findIndex((result) => result.path === filePath) + if (index !== -1) { + fileResults[index] = { ...fileResults[index], ...updates } + } + } + + try { + const filesToApprove: FileResult[] = [] + + for (const fileResult of fileResults) { + const relPath = fileResult.path + const fullPath = path.resolve(task.cwd, relPath) + + if (fileResult.lineRanges) { + let hasRangeError = false + for (const range of fileResult.lineRanges) { + if (range.start > range.end) { + const errorMsg = "Invalid line range: end line cannot be less than start line" + updateFileResult(relPath, { + status: "blocked", + error: errorMsg, + xmlContent: `${relPath}Error reading file: ${errorMsg}`, + nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`, + }) + await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) + hasRangeError = true + break + } + if (isNaN(range.start) || isNaN(range.end)) { + const errorMsg = "Invalid line range values" + updateFileResult(relPath, { + status: "blocked", + error: errorMsg, + xmlContent: `${relPath}Error reading file: ${errorMsg}`, + nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`, + }) + await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) + hasRangeError = true + break + } + } + if (hasRangeError) continue + } + + if (fileResult.status === "pending") { + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) + if (!accessAllowed) { + await task.say("rooignore_error", relPath) + const errorMsg = formatResponse.rooIgnoreError(relPath) + updateFileResult(relPath, { + status: "blocked", + error: errorMsg, + xmlContent: `${relPath}${errorMsg}`, + nativeContent: `File: ${relPath}\nError: ${errorMsg}`, + }) + continue + } + + filesToApprove.push(fileResult) + } + } + + if (filesToApprove.length > 1) { + const { maxReadFileLine = -1 } = (await task.providerRef.deref()?.getState()) ?? {} + + const batchFiles = filesToApprove.map((fileResult) => { + const relPath = fileResult.path + const fullPath = path.resolve(task.cwd, relPath) + const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) + + let lineSnippet = "" + if (fileResult.lineRanges && fileResult.lineRanges.length > 0) { + const ranges = fileResult.lineRanges.map((range) => + t("tools:readFile.linesRange", { start: range.start, end: range.end }), + ) + lineSnippet = ranges.join(", ") + } else if (maxReadFileLine === 0) { + lineSnippet = t("tools:readFile.definitionsOnly") + } else if (maxReadFileLine > 0) { + lineSnippet = t("tools:readFile.maxLines", { max: maxReadFileLine }) + } + + const readablePath = getReadablePath(task.cwd, relPath) + const key = `${readablePath}${lineSnippet ? ` (${lineSnippet})` : ""}` + + return { path: readablePath, lineSnippet, isOutsideWorkspace, key, content: fullPath } + }) + + const completeMessage = JSON.stringify({ tool: "readFile", batchFiles } satisfies ClineSayTool) + const { response, text, images } = await task.ask("tool", completeMessage, false) + + if (response === "yesButtonClicked") { + if (text) await task.say("user_feedback", text, images) + filesToApprove.forEach((fileResult) => { + updateFileResult(fileResult.path, { + status: "approved", + feedbackText: text, + feedbackImages: images, + }) + }) + } else if (response === "noButtonClicked") { + if (text) await task.say("user_feedback", text, images) + task.didRejectTool = true + filesToApprove.forEach((fileResult) => { + updateFileResult(fileResult.path, { + status: "denied", + xmlContent: `${fileResult.path}Denied by user`, + nativeContent: `File: ${fileResult.path}\nStatus: Denied by user`, + feedbackText: text, + feedbackImages: images, + }) + }) + } else { + try { + const individualPermissions = JSON.parse(text || "{}") + let hasAnyDenial = false + + batchFiles.forEach((batchFile, index) => { + const fileResult = filesToApprove[index] + const approved = individualPermissions[batchFile.key] === true + + if (approved) { + updateFileResult(fileResult.path, { status: "approved" }) + } else { + hasAnyDenial = true + updateFileResult(fileResult.path, { + status: "denied", + xmlContent: `${fileResult.path}Denied by user`, + nativeContent: `File: ${fileResult.path}\nStatus: Denied by user`, + }) + } + }) + + if (hasAnyDenial) task.didRejectTool = true + } catch (error) { + console.error("Failed to parse individual permissions:", error) + task.didRejectTool = true + filesToApprove.forEach((fileResult) => { + updateFileResult(fileResult.path, { + status: "denied", + xmlContent: `${fileResult.path}Denied by user`, + nativeContent: `File: ${fileResult.path}\nStatus: Denied by user`, + }) + }) + } + } + } else if (filesToApprove.length === 1) { + const fileResult = filesToApprove[0] + const relPath = fileResult.path + const fullPath = path.resolve(task.cwd, relPath) + const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) + const { maxReadFileLine = -1 } = (await task.providerRef.deref()?.getState()) ?? {} + + let lineSnippet = "" + if (fileResult.lineRanges && fileResult.lineRanges.length > 0) { + const ranges = fileResult.lineRanges.map((range) => + t("tools:readFile.linesRange", { start: range.start, end: range.end }), + ) + lineSnippet = ranges.join(", ") + } else if (maxReadFileLine === 0) { + lineSnippet = t("tools:readFile.definitionsOnly") + } else if (maxReadFileLine > 0) { + lineSnippet = t("tools:readFile.maxLines", { max: maxReadFileLine }) + } + + const completeMessage = JSON.stringify({ + tool: "readFile", + path: getReadablePath(task.cwd, relPath), + isOutsideWorkspace, + content: fullPath, + reason: lineSnippet, + } satisfies ClineSayTool) + + const { response, text, images } = await task.ask("tool", completeMessage, false) + + if (response !== "yesButtonClicked") { + if (text) await task.say("user_feedback", text, images) + task.didRejectTool = true + updateFileResult(relPath, { + status: "denied", + xmlContent: `${relPath}Denied by user`, + nativeContent: `File: ${relPath}\nStatus: Denied by user`, + feedbackText: text, + feedbackImages: images, + }) + } else { + if (text) await task.say("user_feedback", text, images) + updateFileResult(relPath, { status: "approved", feedbackText: text, feedbackImages: images }) + } + } + + const imageMemoryTracker = new ImageMemoryTracker() + const state = await task.providerRef.deref()?.getState() + const { + maxReadFileLine = -1, + maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + } = state ?? {} + + for (const fileResult of fileResults) { + if (fileResult.status !== "approved") continue + + const relPath = fileResult.path + const fullPath = path.resolve(task.cwd, relPath) + + try { + const [totalLines, isBinary] = await Promise.all([countFileLines(fullPath), isBinaryFile(fullPath)]) + + if (isBinary) { + const fileExtension = path.extname(relPath).toLowerCase() + const supportedBinaryFormats = getSupportedBinaryFormats() + + if (isSupportedImageFormat(fileExtension)) { + try { + const validationResult = await validateImageForProcessing( + fullPath, + supportsImages, + maxImageFileSize, + maxTotalImageSize, + imageMemoryTracker.getTotalMemoryUsed(), + ) + + if (!validationResult.isValid) { + await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + updateFileResult(relPath, { + xmlContent: `${relPath}\n${validationResult.notice}\n`, + nativeContent: `File: ${relPath}\nNote: ${validationResult.notice}`, + }) + continue + } + + const imageResult = await processImageFile(fullPath) + imageMemoryTracker.addMemoryUsage(imageResult.sizeInMB) + await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + + updateFileResult(relPath, { + xmlContent: `${relPath}\n${imageResult.notice}\n`, + nativeContent: `File: ${relPath}\nNote: ${imageResult.notice}`, + imageDataUrl: imageResult.dataUrl, + }) + continue + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + updateFileResult(relPath, { + status: "error", + error: `Error reading image file: ${errorMsg}`, + xmlContent: `${relPath}Error reading image file: ${errorMsg}`, + nativeContent: `File: ${relPath}\nError: Error reading image file: ${errorMsg}`, + }) + await task.say("error", `Error reading image file ${relPath}: ${errorMsg}`) + continue + } + } + + if (supportedBinaryFormats && supportedBinaryFormats.includes(fileExtension)) { + // Fall through to extractTextFromFile + } else { + const fileFormat = fileExtension.slice(1) || "bin" + updateFileResult(relPath, { + notice: `Binary file format: ${fileFormat}`, + xmlContent: `${relPath}\nBinary file - content not displayed\n`, + nativeContent: `File: ${relPath}\nBinary file (${fileFormat}) - content not displayed`, + }) + continue + } + } + + if (fileResult.lineRanges && fileResult.lineRanges.length > 0) { + const rangeResults: string[] = [] + const nativeRangeResults: string[] = [] + + for (const range of fileResult.lineRanges) { + const content = addLineNumbers( + await readLines(fullPath, range.end - 1, range.start - 1), + range.start, + ) + const lineRangeAttr = ` lines="${range.start}-${range.end}"` + rangeResults.push(`\n${content}`) + nativeRangeResults.push(`Lines ${range.start}-${range.end}:\n${content}`) + } + + updateFileResult(relPath, { + xmlContent: `${relPath}\n${rangeResults.join("\n")}\n`, + nativeContent: `File: ${relPath}\n${nativeRangeResults.join("\n\n")}`, + }) + continue + } + + if (maxReadFileLine === 0) { + try { + const defResult = await parseSourceCodeDefinitionsForFile( + fullPath, + task.rooIgnoreController, + ) + if (defResult) { + const notice = `Showing only ${maxReadFileLine} of ${totalLines} total lines. Use line_range if you need to read more lines` + updateFileResult(relPath, { + xmlContent: `${relPath}\n${defResult}\n${notice}\n`, + nativeContent: `File: ${relPath}\nCode Definitions:\n${defResult}\n\nNote: ${notice}`, + }) + } + } catch (error) { + if (error instanceof Error && error.message.startsWith("Unsupported language:")) { + console.warn(`[read_file] Warning: ${error.message}`) + } else { + console.error( + `[read_file] Unhandled error: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + continue + } + + if (maxReadFileLine > 0 && totalLines > maxReadFileLine) { + const content = addLineNumbers(await readLines(fullPath, maxReadFileLine - 1, 0)) + const lineRangeAttr = ` lines="1-${maxReadFileLine}"` + let xmlInfo = `\n${content}\n` + let nativeInfo = `Lines 1-${maxReadFileLine}:\n${content}\n` + + try { + const defResult = await parseSourceCodeDefinitionsForFile( + fullPath, + task.rooIgnoreController, + ) + if (defResult) { + const truncatedDefs = truncateDefinitionsToLineLimit(defResult, maxReadFileLine) + xmlInfo += `${truncatedDefs}\n` + nativeInfo += `\nCode Definitions:\n${truncatedDefs}\n` + } + + const notice = `Showing only ${maxReadFileLine} of ${totalLines} total lines. Use line_range if you need to read more lines` + xmlInfo += `${notice}\n` + nativeInfo += `\nNote: ${notice}` + + updateFileResult(relPath, { + xmlContent: `${relPath}\n${xmlInfo}`, + nativeContent: `File: ${relPath}\n${nativeInfo}`, + }) + } catch (error) { + if (error instanceof Error && error.message.startsWith("Unsupported language:")) { + console.warn(`[read_file] Warning: ${error.message}`) + } else { + console.error( + `[read_file] Unhandled error: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + continue + } + + const modelInfo = task.api.getModel().info + const { contextTokens } = task.getTokenUsage() + const contextWindow = modelInfo.contextWindow + + const budgetResult = await validateFileTokenBudget(fullPath, contextWindow, contextTokens || 0) + + let content = await extractTextFromFile(fullPath) + let xmlInfo = "" + + let nativeInfo = "" + + if (budgetResult.shouldTruncate && budgetResult.maxChars !== undefined) { + const truncateResult = truncateFileContent( + content, + budgetResult.maxChars, + content.length, + budgetResult.isPreview, + ) + content = truncateResult.content + + let displayedLines = content.length === 0 ? 0 : content.split(/\r?\n/).length + if (displayedLines > 0 && content.endsWith("\n")) { + displayedLines-- + } + const lineRangeAttr = displayedLines > 0 ? ` lines="1-${displayedLines}"` : "" + xmlInfo = + content.length > 0 ? `\n${content}\n` : `` + xmlInfo += `${truncateResult.notice}\n` + + nativeInfo = + content.length > 0 + ? `Lines 1-${displayedLines}:\n${content}\n\nNote: ${truncateResult.notice}` + : `Note: ${truncateResult.notice}` + } else { + const lineRangeAttr = ` lines="1-${totalLines}"` + xmlInfo = totalLines > 0 ? `\n${content}\n` : `` + + if (totalLines === 0) { + xmlInfo += `File is empty\n` + nativeInfo = "Note: File is empty" + } else { + nativeInfo = `Lines 1-${totalLines}:\n${content}` + } + } + + await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + + updateFileResult(relPath, { + xmlContent: `${relPath}\n${xmlInfo}`, + nativeContent: `File: ${relPath}\n${nativeInfo}`, + }) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + updateFileResult(relPath, { + status: "error", + error: `Error reading file: ${errorMsg}`, + xmlContent: `${relPath}Error reading file: ${errorMsg}`, + nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`, + }) + await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) + } + } + + // Build final result based on protocol + let finalResult: string + if (useNative) { + const nativeResults = fileResults + .filter((result) => result.nativeContent) + .map((result) => result.nativeContent) + finalResult = nativeResults.join("\n\n---\n\n") + } else { + const xmlResults = fileResults.filter((result) => result.xmlContent).map((result) => result.xmlContent) + finalResult = `\n${xmlResults.join("\n")}\n` + } + + const fileImageUrls = fileResults + .filter((result) => result.imageDataUrl) + .map((result) => result.imageDataUrl as string) + + let statusMessage = "" + let feedbackImages: any[] = [] + + const deniedWithFeedback = fileResults.find((result) => result.status === "denied" && result.feedbackText) + + if (deniedWithFeedback && deniedWithFeedback.feedbackText) { + statusMessage = formatResponse.toolDeniedWithFeedback(deniedWithFeedback.feedbackText) + feedbackImages = deniedWithFeedback.feedbackImages || [] + } else if (task.didRejectTool) { + statusMessage = formatResponse.toolDenied() + } else { + const approvedWithFeedback = fileResults.find( + (result) => result.status === "approved" && result.feedbackText, + ) + + if (approvedWithFeedback && approvedWithFeedback.feedbackText) { + statusMessage = formatResponse.toolApprovedWithFeedback(approvedWithFeedback.feedbackText) + feedbackImages = approvedWithFeedback.feedbackImages || [] + } + } + + const allImages = [...feedbackImages, ...fileImageUrls] + + const finalModelSupportsImages = task.api.getModel().info.supportsImages ?? false + const imagesToInclude = finalModelSupportsImages ? allImages : [] + + if (statusMessage || imagesToInclude.length > 0) { + const result = formatResponse.toolResult( + statusMessage || finalResult, + imagesToInclude.length > 0 ? imagesToInclude : undefined, + ) + + if (typeof result === "string") { + if (statusMessage) { + pushToolResult(`${result}\n${finalResult}`) + } else { + pushToolResult(result) + } + } else { + if (statusMessage) { + const textBlock = { type: "text" as const, text: finalResult } + pushToolResult([...result, textBlock]) + } else { + pushToolResult(result) + } + } + } else { + pushToolResult(finalResult) + } + } catch (error) { + const relPath = fileEntries[0]?.path || "unknown" + const errorMsg = error instanceof Error ? error.message : String(error) + + if (fileResults.length > 0) { + updateFileResult(relPath, { + status: "error", + error: `Error reading file: ${errorMsg}`, + xmlContent: `${relPath}Error reading file: ${errorMsg}`, + nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`, + }) + } + + await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) + + // Build final error result based on protocol + let errorResult: string + if (useNative) { + const nativeResults = fileResults + .filter((result) => result.nativeContent) + .map((result) => result.nativeContent) + errorResult = nativeResults.join("\n\n---\n\n") + } else { + const xmlResults = fileResults.filter((result) => result.xmlContent).map((result) => result.xmlContent) + errorResult = `\n${xmlResults.join("\n")}\n` + } + + pushToolResult(errorResult) + } + } + + getReadFileToolDescription(blockName: string, blockParams: any): string + getReadFileToolDescription(blockName: string, nativeArgs: { files: FileEntry[] }): string + getReadFileToolDescription(blockName: string, second: any): string { + // If native typed args ({ files: FileEntry[] }) were provided + if (second && typeof second === "object" && "files" in second && Array.isArray(second.files)) { + const paths = (second.files as FileEntry[]).map((f) => f?.path).filter(Boolean) as string[] + if (paths.length === 0) { + return `[${blockName} with no valid paths]` + } else if (paths.length === 1) { + return `[${blockName} for '${paths[0]}'. Reading multiple files at once is more efficient for the LLM. If other files are relevant to your current task, please read them simultaneously.]` + } else if (paths.length <= 3) { + const pathList = paths.map((p) => `'${p}'`).join(", ") + return `[${blockName} for ${pathList}]` + } else { + return `[${blockName} for ${paths.length} files]` + } + } + + // Fallback to legacy/XML or synthesized params + const blockParams = second as any + + if (blockParams?.args) { + try { + const parsed = parseXml(blockParams.args) as any + const files = Array.isArray(parsed.file) ? parsed.file : [parsed.file].filter(Boolean) + const paths = files.map((f: any) => f?.path).filter(Boolean) as string[] + + if (paths.length === 0) { + return `[${blockName} with no valid paths]` + } else if (paths.length === 1) { + return `[${blockName} for '${paths[0]}'. Reading multiple files at once is more efficient for the LLM. If other files are relevant to your current task, please read them simultaneously.]` + } else if (paths.length <= 3) { + const pathList = paths.map((p) => `'${p}'`).join(", ") + return `[${blockName} for ${pathList}]` + } else { + return `[${blockName} for ${paths.length} files]` + } + } catch (error) { + console.error("Failed to parse read_file args XML for description:", error) + return `[${blockName} with unparsable args]` + } + } else if (blockParams?.path) { + return `[${blockName} for '${blockParams.path}'. Reading multiple files at once is more efficient for the LLM. If other files are relevant to your current task, please read them simultaneously.]` + } else if (blockParams?.files) { + // Back-compat: some paths may still synthesize params.files; try to parse if present + try { + const files = JSON.parse(blockParams.files) + if (Array.isArray(files) && files.length > 0) { + const paths = files.map((f: any) => f?.path).filter(Boolean) as string[] + if (paths.length === 1) { + return `[${blockName} for '${paths[0]}'. Reading multiple files at once is more efficient for the LLM. If other files are relevant to your current task, please read them simultaneously.]` + } else if (paths.length <= 3) { + const pathList = paths.map((p) => `'${p}'`).join(", ") + return `[${blockName} for ${pathList}]` + } else { + return `[${blockName} for ${paths.length} files]` + } + } + } catch (error) { + console.error("Failed to parse native files JSON for description:", error) + return `[${blockName} with unparsable files]` + } + } + + return `[${blockName} with missing path/args/files]` + } + + override async handlePartial(task: Task, block: ToolUse<"read_file">): Promise { + const argsXmlTag = block.params.args + const legacyPath = block.params.path + + let filePath = "" + if (argsXmlTag) { + const match = argsXmlTag.match(/.*?([^<]+)<\/path>/s) + if (match) filePath = match[1] + } + if (!filePath && legacyPath) { + filePath = legacyPath + } + + const fullPath = filePath ? path.resolve(task.cwd, filePath) : "" + const sharedMessageProps: ClineSayTool = { + tool: "readFile", + path: getReadablePath(task.cwd, filePath), + isOutsideWorkspace: filePath ? isPathOutsideWorkspace(fullPath) : false, + } + const partialMessage = JSON.stringify({ + ...sharedMessageProps, + content: undefined, + } satisfies ClineSayTool) + await task.ask("tool", partialMessage, block.partial).catch(() => {}) + } +} + +export const readFileTool = new ReadFileTool() diff --git a/src/core/tools/RunSlashCommandTool.ts b/src/core/tools/RunSlashCommandTool.ts new file mode 100644 index 0000000000..c82b6cc0a8 --- /dev/null +++ b/src/core/tools/RunSlashCommandTool.ts @@ -0,0 +1,122 @@ +import { Task } from "../task/Task" +import { formatResponse } from "../prompts/responses" +import { getCommand, getCommandNames } from "../../services/command/commands" +import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +interface RunSlashCommandParams { + command: string + args?: string +} + +export class RunSlashCommandTool extends BaseTool<"run_slash_command"> { + readonly name = "run_slash_command" as const + + parseLegacy(params: Partial>): RunSlashCommandParams { + return { + command: params.command || "", + args: params.args, + } + } + + async execute(params: RunSlashCommandParams, task: Task, callbacks: ToolCallbacks): Promise { + const { command: commandName, args } = params + const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks + + // Check if run slash command experiment is enabled + const provider = task.providerRef.deref() + const state = await provider?.getState() + const isRunSlashCommandEnabled = experiments.isEnabled( + state?.experiments ?? {}, + EXPERIMENT_IDS.RUN_SLASH_COMMAND, + ) + + if (!isRunSlashCommandEnabled) { + pushToolResult( + formatResponse.toolError( + "Run slash command is an experimental feature that must be enabled in settings. Please enable 'Run Slash Command' in the Experimental Settings section.", + ), + ) + return + } + + try { + if (!commandName) { + task.consecutiveMistakeCount++ + task.recordToolError("run_slash_command") + pushToolResult(await task.sayAndCreateMissingParamError("run_slash_command", "command")) + return + } + + task.consecutiveMistakeCount = 0 + + // Get the command from the commands service + const command = await getCommand(task.cwd, commandName) + + if (!command) { + // Get available commands for error message + const availableCommands = await getCommandNames(task.cwd) + task.recordToolError("run_slash_command") + pushToolResult( + formatResponse.toolError( + `Command '${commandName}' not found. Available commands: ${availableCommands.join(", ") || "(none)"}`, + ), + ) + return + } + + const toolMessage = JSON.stringify({ + tool: "runSlashCommand", + command: commandName, + args: args, + source: command.source, + description: command.description, + }) + + const didApprove = await askApproval("tool", toolMessage) + + if (!didApprove) { + return + } + + // Build the result message + let result = `Command: /${commandName}` + + if (command.description) { + result += `\nDescription: ${command.description}` + } + + if (command.argumentHint) { + result += `\nArgument hint: ${command.argumentHint}` + } + + if (args) { + result += `\nProvided arguments: ${args}` + } + + result += `\nSource: ${command.source}` + result += `\n\n--- Command Content ---\n\n${command.content}` + + // Return the command content as the tool result + pushToolResult(result) + } catch (error) { + await handleError("running slash command", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"run_slash_command">): Promise { + const commandName: string | undefined = block.params.command + const args: string | undefined = block.params.args + + const partialMessage = JSON.stringify({ + tool: "runSlashCommand", + command: this.removeClosingTag("command", commandName, block.partial), + args: this.removeClosingTag("args", args, block.partial), + }) + + await task.ask("tool", partialMessage, block.partial).catch(() => {}) + } +} + +export const runSlashCommandTool = new RunSlashCommandTool() diff --git a/src/core/tools/SearchFilesTool.ts b/src/core/tools/SearchFilesTool.ts new file mode 100644 index 0000000000..f22462c22e --- /dev/null +++ b/src/core/tools/SearchFilesTool.ts @@ -0,0 +1,99 @@ +import path from "path" + +import { Task } from "../task/Task" +import { ClineSayTool } from "../../shared/ExtensionMessage" +import { getReadablePath } from "../../utils/path" +import { isPathOutsideWorkspace } from "../../utils/pathUtils" +import { regexSearchFiles } from "../../services/ripgrep" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +interface SearchFilesParams { + path: string + regex: string + file_pattern?: string | null +} + +export class SearchFilesTool extends BaseTool<"search_files"> { + readonly name = "search_files" as const + + parseLegacy(params: Partial>): SearchFilesParams { + return { + path: params.path || "", + regex: params.regex || "", + file_pattern: params.file_pattern || undefined, + } + } + + async execute(params: SearchFilesParams, task: Task, callbacks: ToolCallbacks): Promise { + const { askApproval, handleError, pushToolResult } = callbacks + + const relDirPath = params.path + const regex = params.regex + const filePattern = params.file_pattern || undefined + + if (!relDirPath) { + task.consecutiveMistakeCount++ + task.recordToolError("search_files") + pushToolResult(await task.sayAndCreateMissingParamError("search_files", "path")) + return + } + + if (!regex) { + task.consecutiveMistakeCount++ + task.recordToolError("search_files") + pushToolResult(await task.sayAndCreateMissingParamError("search_files", "regex")) + return + } + + task.consecutiveMistakeCount = 0 + + const absolutePath = path.resolve(task.cwd, relDirPath) + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + + const sharedMessageProps: ClineSayTool = { + tool: "searchFiles", + path: getReadablePath(task.cwd, relDirPath), + regex: regex, + filePattern: filePattern, + isOutsideWorkspace, + } + + try { + const results = await regexSearchFiles(task.cwd, absolutePath, regex, filePattern, task.rooIgnoreController) + + const completeMessage = JSON.stringify({ ...sharedMessageProps, content: results } satisfies ClineSayTool) + const didApprove = await askApproval("tool", completeMessage) + + if (!didApprove) { + return + } + + pushToolResult(results) + } catch (error) { + await handleError("searching files", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"search_files">): Promise { + const relDirPath = block.params.path + const regex = block.params.regex + const filePattern = block.params.file_pattern + + const absolutePath = relDirPath ? path.resolve(task.cwd, relDirPath) : task.cwd + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + + const sharedMessageProps: ClineSayTool = { + tool: "searchFiles", + path: getReadablePath(task.cwd, this.removeClosingTag("path", relDirPath, block.partial)), + regex: this.removeClosingTag("regex", regex, block.partial), + filePattern: this.removeClosingTag("file_pattern", filePattern, block.partial), + isOutsideWorkspace, + } + + const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "" } satisfies ClineSayTool) + await task.ask("tool", partialMessage, block.partial).catch(() => {}) + } +} + +export const searchFilesTool = new SearchFilesTool() diff --git a/src/core/tools/SwitchModeTool.ts b/src/core/tools/SwitchModeTool.ts new file mode 100644 index 0000000000..df418cfdfc --- /dev/null +++ b/src/core/tools/SwitchModeTool.ts @@ -0,0 +1,92 @@ +import delay from "delay" + +import { Task } from "../task/Task" +import { formatResponse } from "../prompts/responses" +import { defaultModeSlug, getModeBySlug } from "../../shared/modes" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +interface SwitchModeParams { + mode_slug: string + reason: string +} + +export class SwitchModeTool extends BaseTool<"switch_mode"> { + readonly name = "switch_mode" as const + + parseLegacy(params: Partial>): SwitchModeParams { + return { + mode_slug: params.mode_slug || "", + reason: params.reason || "", + } + } + + async execute(params: SwitchModeParams, task: Task, callbacks: ToolCallbacks): Promise { + const { mode_slug, reason } = params + const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks + + try { + if (!mode_slug) { + task.consecutiveMistakeCount++ + task.recordToolError("switch_mode") + pushToolResult(await task.sayAndCreateMissingParamError("switch_mode", "mode_slug")) + return + } + + task.consecutiveMistakeCount = 0 + + // Verify the mode exists + const targetMode = getModeBySlug(mode_slug, (await task.providerRef.deref()?.getState())?.customModes) + + if (!targetMode) { + task.recordToolError("switch_mode") + pushToolResult(formatResponse.toolError(`Invalid mode: ${mode_slug}`)) + return + } + + // Check if already in requested mode + const currentMode = (await task.providerRef.deref()?.getState())?.mode ?? defaultModeSlug + + if (currentMode === mode_slug) { + task.recordToolError("switch_mode") + pushToolResult(`Already in ${targetMode.name} mode.`) + return + } + + const completeMessage = JSON.stringify({ tool: "switchMode", mode: mode_slug, reason }) + const didApprove = await askApproval("tool", completeMessage) + + if (!didApprove) { + return + } + + // Switch the mode using shared handler + await task.providerRef.deref()?.handleModeSwitch(mode_slug) + + pushToolResult( + `Successfully switched from ${getModeBySlug(currentMode)?.name ?? currentMode} mode to ${ + targetMode.name + } mode${reason ? ` because: ${reason}` : ""}.`, + ) + + await delay(500) // Delay to allow mode change to take effect before next tool is executed + } catch (error) { + await handleError("switching mode", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"switch_mode">): Promise { + const mode_slug: string | undefined = block.params.mode_slug + const reason: string | undefined = block.params.reason + + const partialMessage = JSON.stringify({ + tool: "switchMode", + mode: this.removeClosingTag("mode_slug", mode_slug, block.partial), + reason: this.removeClosingTag("reason", reason, block.partial), + }) + + await task.ask("tool", partialMessage, block.partial).catch(() => {}) + } +} + +export const switchModeTool = new SwitchModeTool() diff --git a/src/core/tools/ToolRepetitionDetector.ts b/src/core/tools/ToolRepetitionDetector.ts index 1d88c66ad7..9e70bb41a0 100644 --- a/src/core/tools/ToolRepetitionDetector.ts +++ b/src/core/tools/ToolRepetitionDetector.ts @@ -1,3 +1,4 @@ +import stringify from "safe-stable-stringify" import { ToolUse } from "../../shared/tools" import { t } from "../../i18n" @@ -95,26 +96,16 @@ export class ToolRepetitionDetector { * @returns JSON string representation of the tool use with sorted parameter keys */ private serializeToolUse(toolUse: ToolUse): string { - // Create a new parameters object with alphabetically sorted keys - const sortedParams: Record = {} - - // Get parameter keys and sort them alphabetically - const sortedKeys = Object.keys(toolUse.params).sort() - - // Populate the sorted parameters object in a type-safe way - for (const key of sortedKeys) { - if (Object.prototype.hasOwnProperty.call(toolUse.params, key)) { - sortedParams[key] = toolUse.params[key as keyof typeof toolUse.params] - } - } - - // Create the object with the tool name and sorted parameters - const toolObject = { + const toolObject: Record = { name: toolUse.name, - parameters: sortedParams, + params: toolUse.params, } - // Convert to a canonical JSON string - return JSON.stringify(toolObject) + // Only include nativeArgs if it has content + if (toolUse.nativeArgs && Object.keys(toolUse.nativeArgs).length > 0) { + toolObject.nativeArgs = toolUse.nativeArgs + } + + return stringify(toolObject) } } diff --git a/src/core/tools/updateTodoListTool.ts b/src/core/tools/UpdateTodoListTool.ts similarity index 57% rename from src/core/tools/updateTodoListTool.ts rename to src/core/tools/UpdateTodoListTool.ts index fcd41914a8..bf2c2b5301 100644 --- a/src/core/tools/updateTodoListTool.ts +++ b/src/core/tools/UpdateTodoListTool.ts @@ -1,17 +1,115 @@ import { Task } from "../task/Task" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" import { formatResponse } from "../prompts/responses" - +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" import cloneDeep from "clone-deep" import crypto from "crypto" import { TodoItem, TodoStatus, todoStatusSchema } from "@roo-code/types" import { getLatestTodo } from "../../shared/todo" +interface UpdateTodoListParams { + todos: string +} + let approvedTodoList: TodoItem[] | undefined = undefined -/** - * Add a todo item to the task's todoList. - */ +export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { + readonly name = "update_todo_list" as const + + parseLegacy(params: Partial>): UpdateTodoListParams { + return { + todos: params.todos || "", + } + } + + async execute(params: UpdateTodoListParams, task: Task, callbacks: ToolCallbacks): Promise { + const { pushToolResult, handleError, askApproval, toolProtocol } = callbacks + + try { + const todosRaw = params.todos + + let todos: TodoItem[] + try { + todos = parseMarkdownChecklist(todosRaw || "") + } catch { + task.consecutiveMistakeCount++ + task.recordToolError("update_todo_list") + pushToolResult(formatResponse.toolError("The todos parameter is not valid markdown checklist or JSON")) + return + } + + const { valid, error } = validateTodos(todos) + if (!valid) { + task.consecutiveMistakeCount++ + task.recordToolError("update_todo_list") + pushToolResult(formatResponse.toolError(error || "todos parameter validation failed")) + return + } + + let normalizedTodos: TodoItem[] = todos.map((t) => ({ + id: t.id, + content: t.content, + status: normalizeStatus(t.status), + })) + + const approvalMsg = JSON.stringify({ + tool: "updateTodoList", + todos: normalizedTodos, + }) + + approvedTodoList = cloneDeep(normalizedTodos) + const didApprove = await askApproval("tool", approvalMsg) + if (!didApprove) { + pushToolResult("User declined to update the todoList.") + return + } + + const isTodoListChanged = + approvedTodoList !== undefined && JSON.stringify(normalizedTodos) !== JSON.stringify(approvedTodoList) + if (isTodoListChanged) { + normalizedTodos = approvedTodoList ?? [] + task.say( + "user_edit_todos", + JSON.stringify({ + tool: "updateTodoList", + todos: normalizedTodos, + }), + ) + } + + await setTodoListForTask(task, normalizedTodos) + + if (isTodoListChanged) { + const md = todoListToMarkdown(normalizedTodos) + pushToolResult(formatResponse.toolResult("User edits todo:\n\n" + md)) + } else { + pushToolResult(formatResponse.toolResult("Todo list updated successfully.")) + } + } catch (error) { + await handleError("update todo list", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"update_todo_list">): Promise { + const todosRaw = block.params.todos + + // Parse the markdown checklist to maintain consistent format with execute() + let todos: TodoItem[] + try { + todos = parseMarkdownChecklist(todosRaw || "") + } catch { + // If parsing fails during partial, send empty array + todos = [] + } + + const approvalMsg = JSON.stringify({ + tool: "updateTodoList", + todos: todos, + }) + await task.ask("tool", approvalMsg, block.partial).catch(() => {}) + } +} + export function addTodoToTask(cline: Task, content: string, status: TodoStatus = "pending", id?: string): TodoItem { const todo: TodoItem = { id: id ?? crypto.randomUUID(), @@ -23,9 +121,6 @@ export function addTodoToTask(cline: Task, content: string, status: TodoStatus = return todo } -/** - * Update the status of a todo item by id. - */ export function updateTodoStatusForTask(cline: Task, id: string, nextStatus: TodoStatus): boolean { if (!cline.todoList) return false const idx = cline.todoList.findIndex((t) => t.id === id) @@ -42,9 +137,6 @@ export function updateTodoStatusForTask(cline: Task, id: string, nextStatus: Tod return false } -/** - * Remove a todo item by id. - */ export function removeTodoFromTask(cline: Task, id: string): boolean { if (!cline.todoList) return false const idx = cline.todoList.findIndex((t) => t.id === id) @@ -53,24 +145,15 @@ export function removeTodoFromTask(cline: Task, id: string): boolean { return true } -/** - * Get a copy of the todoList. - */ export function getTodoListForTask(cline: Task): TodoItem[] | undefined { return cline.todoList?.slice() } -/** - * Set the todoList for the task. - */ export async function setTodoListForTask(cline?: Task, todos?: TodoItem[]) { if (cline === undefined) return cline.todoList = Array.isArray(todos) ? todos : [] } -/** - * Restore the todoList from argument or from clineMessages. - */ export function restoreTodoListForTask(cline: Task, todoList?: TodoItem[]) { if (todoList) { cline.todoList = Array.isArray(todoList) ? todoList : [] @@ -78,11 +161,7 @@ export function restoreTodoListForTask(cline: Task, todoList?: TodoItem[]) { } cline.todoList = getLatestTodo(cline.clineMessages) } -/** - * Convert TodoItem[] to markdown checklist string. - * @param todos TodoItem array - * @returns markdown checklist string - */ + function todoListToMarkdown(todos: TodoItem[]): string { return todos .map((t) => { @@ -108,7 +187,6 @@ export function parseMarkdownChecklist(md: string): TodoItem[] { .filter(Boolean) const todos: TodoItem[] = [] for (const line of lines) { - // Support both "[ ] Task" and "- [ ] Task" formats const match = line.match(/^(?:-\s*)?\[\s*([ xX\-~])\s*\]\s+(.+)$/) if (!match) continue let status: TodoStatus = "pending" @@ -144,94 +222,4 @@ function validateTodos(todos: any[]): { valid: boolean; error?: string } { return { valid: true } } -/** - * Update the todo list for a task. - * @param cline Task instance - * @param block ToolUse block - * @param askApproval AskApproval function - * @param handleError HandleError function - * @param pushToolResult PushToolResult function - * @param removeClosingTag RemoveClosingTag function - * @param userEdited If true, only show "User Edit Succeeded" and do nothing else - */ -export async function updateTodoListTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, - userEdited?: boolean, -) { - // If userEdited is true, only show "User Edit Succeeded" and do nothing else - if (userEdited === true) { - pushToolResult("User Edit Succeeded") - return - } - try { - const todosRaw = block.params.todos - - let todos: TodoItem[] - try { - todos = parseMarkdownChecklist(todosRaw || "") - } catch { - cline.consecutiveMistakeCount++ - cline.recordToolError("update_todo_list") - pushToolResult(formatResponse.toolError("The todos parameter is not valid markdown checklist or JSON")) - return - } - - const { valid, error } = validateTodos(todos) - if (!valid && !block.partial) { - cline.consecutiveMistakeCount++ - cline.recordToolError("update_todo_list") - pushToolResult(formatResponse.toolError(error || "todos parameter validation failed")) - return - } - - let normalizedTodos: TodoItem[] = todos.map((t) => ({ - id: t.id, - content: t.content, - status: normalizeStatus(t.status), - })) - - const approvalMsg = JSON.stringify({ - tool: "updateTodoList", - todos: normalizedTodos, - }) - if (block.partial) { - await cline.ask("tool", approvalMsg, block.partial).catch(() => {}) - return - } - approvedTodoList = cloneDeep(normalizedTodos) - const didApprove = await askApproval("tool", approvalMsg) - if (!didApprove) { - pushToolResult("User declined to update the todoList.") - return - } - const isTodoListChanged = - approvedTodoList !== undefined && JSON.stringify(normalizedTodos) !== JSON.stringify(approvedTodoList) - if (isTodoListChanged) { - normalizedTodos = approvedTodoList ?? [] - cline.say( - "user_edit_todos", - JSON.stringify({ - tool: "updateTodoList", - todos: normalizedTodos, - }), - ) - } - - await setTodoListForTask(cline, normalizedTodos) - - // If todo list changed, output new todo list in markdown format - if (isTodoListChanged) { - const md = todoListToMarkdown(normalizedTodos) - pushToolResult(formatResponse.toolResult("User edits todo:\n\n" + md)) - } else { - pushToolResult(formatResponse.toolResult("Todo list updated successfully.")) - } - } catch (error) { - await handleError("update todo list", error) - } -} +export const updateTodoListTool = new UpdateTodoListTool() diff --git a/src/core/tools/UseMcpToolTool.ts b/src/core/tools/UseMcpToolTool.ts new file mode 100644 index 0000000000..b276293f6f --- /dev/null +++ b/src/core/tools/UseMcpToolTool.ts @@ -0,0 +1,342 @@ +import { Task } from "../task/Task" +import { formatResponse } from "../prompts/responses" +import { ClineAskUseMcpServer } from "../../shared/ExtensionMessage" +import { McpExecutionStatus } from "@roo-code/types" +import { t } from "../../i18n" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +interface UseMcpToolParams { + server_name: string + tool_name: string + arguments?: Record +} + +type ValidationResult = + | { isValid: false } + | { + isValid: true + serverName: string + toolName: string + parsedArguments?: Record + } + +export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { + readonly name = "use_mcp_tool" as const + + parseLegacy(params: Partial>): UseMcpToolParams { + // For legacy params, arguments come as a JSON string that needs parsing + // We don't parse here - let validateParams handle parsing and errors + return { + server_name: params.server_name || "", + tool_name: params.tool_name || "", + arguments: params.arguments as any, // Keep as string for validation to handle + } + } + + async execute(params: UseMcpToolParams, task: Task, callbacks: ToolCallbacks): Promise { + const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks + + try { + // Validate parameters + const validation = await this.validateParams(task, params, pushToolResult) + if (!validation.isValid) { + return + } + + const { serverName, toolName, parsedArguments } = validation + + // Validate that the tool exists on the server + const toolValidation = await this.validateToolExists(task, serverName, toolName, pushToolResult) + if (!toolValidation.isValid) { + return + } + + // Reset mistake count on successful validation + task.consecutiveMistakeCount = 0 + + // Get user approval + const completeMessage = JSON.stringify({ + type: "use_mcp_tool", + serverName, + toolName, + arguments: params.arguments ? JSON.stringify(params.arguments) : undefined, + } satisfies ClineAskUseMcpServer) + + const executionId = task.lastMessageTs?.toString() ?? Date.now().toString() + const didApprove = await askApproval("use_mcp_server", completeMessage) + + if (!didApprove) { + return + } + + // Execute the tool and process results + await this.executeToolAndProcessResult( + task, + serverName, + toolName, + parsedArguments, + executionId, + pushToolResult, + ) + } catch (error) { + await handleError("executing MCP tool", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"use_mcp_tool">): Promise { + const params = block.params + const partialMessage = JSON.stringify({ + type: "use_mcp_tool", + serverName: this.removeClosingTag("server_name", params.server_name, block.partial), + toolName: this.removeClosingTag("tool_name", params.tool_name, block.partial), + arguments: this.removeClosingTag("arguments", params.arguments, block.partial), + } satisfies ClineAskUseMcpServer) + + await task.ask("use_mcp_server", partialMessage, true).catch(() => {}) + } + + private async validateParams( + task: Task, + params: UseMcpToolParams, + pushToolResult: (content: string) => void, + ): Promise { + if (!params.server_name) { + task.consecutiveMistakeCount++ + task.recordToolError("use_mcp_tool") + pushToolResult(await task.sayAndCreateMissingParamError("use_mcp_tool", "server_name")) + return { isValid: false } + } + + if (!params.tool_name) { + task.consecutiveMistakeCount++ + task.recordToolError("use_mcp_tool") + pushToolResult(await task.sayAndCreateMissingParamError("use_mcp_tool", "tool_name")) + return { isValid: false } + } + + // Parse arguments if provided + let parsedArguments: Record | undefined + + if (params.arguments) { + // If arguments is already an object (from native protocol), use it + if (typeof params.arguments === "object") { + parsedArguments = params.arguments + } else if (typeof params.arguments === "string") { + // If arguments is a string (from legacy/XML protocol), parse it + try { + parsedArguments = JSON.parse(params.arguments) + } catch (error) { + task.consecutiveMistakeCount++ + task.recordToolError("use_mcp_tool") + await task.say("error", t("mcp:errors.invalidJsonArgument", { toolName: params.tool_name })) + + pushToolResult( + formatResponse.toolError( + formatResponse.invalidMcpToolArgumentError(params.server_name, params.tool_name), + ), + ) + return { isValid: false } + } + } + } + + return { + isValid: true, + serverName: params.server_name, + toolName: params.tool_name, + parsedArguments, + } + } + + private async validateToolExists( + task: Task, + serverName: string, + toolName: string, + pushToolResult: (content: string) => void, + ): Promise<{ isValid: boolean; availableTools?: string[] }> { + try { + // Get the MCP hub to access server information + const provider = task.providerRef.deref() + const mcpHub = provider?.getMcpHub() + + if (!mcpHub) { + // If we can't get the MCP hub, we can't validate, so proceed with caution + return { isValid: true } + } + + // Get all servers to find the specific one + const servers = mcpHub.getAllServers() + const server = servers.find((s) => s.name === serverName) + + if (!server) { + // Fail fast when server is unknown + const availableServersArray = servers.map((s) => s.name) + const availableServers = + availableServersArray.length > 0 ? availableServersArray.join(", ") : "No servers available" + + task.consecutiveMistakeCount++ + task.recordToolError("use_mcp_tool") + await task.say("error", t("mcp:errors.serverNotFound", { serverName, availableServers })) + + pushToolResult(formatResponse.unknownMcpServerError(serverName, availableServersArray)) + return { isValid: false, availableTools: [] } + } + + // Check if the server has tools defined + if (!server.tools || server.tools.length === 0) { + // No tools available on this server + task.consecutiveMistakeCount++ + task.recordToolError("use_mcp_tool") + await task.say( + "error", + t("mcp:errors.toolNotFound", { + toolName, + serverName, + availableTools: "No tools available", + }), + ) + + pushToolResult(formatResponse.unknownMcpToolError(serverName, toolName, [])) + return { isValid: false, availableTools: [] } + } + + // Check if the requested tool exists + const tool = server.tools.find((tool) => tool.name === toolName) + + if (!tool) { + // Tool not found - provide list of available tools + const availableToolNames = server.tools.map((tool) => tool.name) + + task.consecutiveMistakeCount++ + task.recordToolError("use_mcp_tool") + await task.say( + "error", + t("mcp:errors.toolNotFound", { + toolName, + serverName, + availableTools: availableToolNames.join(", "), + }), + ) + + pushToolResult(formatResponse.unknownMcpToolError(serverName, toolName, availableToolNames)) + return { isValid: false, availableTools: availableToolNames } + } + + // Check if the tool is disabled (enabledForPrompt is false) + if (tool.enabledForPrompt === false) { + // Tool is disabled - only show enabled tools + const enabledTools = server.tools.filter((t) => t.enabledForPrompt !== false) + const enabledToolNames = enabledTools.map((t) => t.name) + + task.consecutiveMistakeCount++ + task.recordToolError("use_mcp_tool") + await task.say( + "error", + t("mcp:errors.toolDisabled", { + toolName, + serverName, + availableTools: + enabledToolNames.length > 0 ? enabledToolNames.join(", ") : "No enabled tools available", + }), + ) + + pushToolResult(formatResponse.unknownMcpToolError(serverName, toolName, enabledToolNames)) + return { isValid: false, availableTools: enabledToolNames } + } + + // Tool exists and is enabled + return { isValid: true, availableTools: server.tools.map((tool) => tool.name) } + } catch (error) { + // If there's an error during validation, log it but don't block the tool execution + // The actual tool call might still fail with a proper error + console.error("Error validating MCP tool existence:", error) + return { isValid: true } + } + } + + private async sendExecutionStatus(task: Task, status: McpExecutionStatus): Promise { + const clineProvider = await task.providerRef.deref() + clineProvider?.postMessageToWebview({ + type: "mcpExecutionStatus", + text: JSON.stringify(status), + }) + } + + private processToolContent(toolResult: any): string { + if (!toolResult?.content || toolResult.content.length === 0) { + return "" + } + + return toolResult.content + .map((item: any) => { + if (item.type === "text") { + return item.text + } + if (item.type === "resource") { + const { blob: _, ...rest } = item.resource + return JSON.stringify(rest, null, 2) + } + return "" + }) + .filter(Boolean) + .join("\n\n") + } + + private async executeToolAndProcessResult( + task: Task, + serverName: string, + toolName: string, + parsedArguments: Record | undefined, + executionId: string, + pushToolResult: (content: string | Array) => void, + ): Promise { + await task.say("mcp_server_request_started") + + // Send started status + await this.sendExecutionStatus(task, { + executionId, + status: "started", + serverName, + toolName, + }) + + const toolResult = await task.providerRef.deref()?.getMcpHub()?.callTool(serverName, toolName, parsedArguments) + + let toolResultPretty = "(No response)" + + if (toolResult) { + const outputText = this.processToolContent(toolResult) + + if (outputText) { + await this.sendExecutionStatus(task, { + executionId, + status: "output", + response: outputText, + }) + + toolResultPretty = (toolResult.isError ? "Error:\n" : "") + outputText + } + + // Send completion status + await this.sendExecutionStatus(task, { + executionId, + status: toolResult.isError ? "error" : "completed", + response: toolResultPretty, + error: toolResult.isError ? "Error executing MCP tool" : undefined, + }) + } else { + // Send error status if no result + await this.sendExecutionStatus(task, { + executionId, + status: "error", + error: "No response from MCP server", + }) + } + + await task.say("mcp_server_response", toolResultPretty) + pushToolResult(formatResponse.toolResult(toolResultPretty)) + } +} + +export const useMcpToolTool = new UseMcpToolTool() diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts new file mode 100644 index 0000000000..4c355beb07 --- /dev/null +++ b/src/core/tools/WriteToFileTool.ts @@ -0,0 +1,343 @@ +import path from "path" +import delay from "delay" +import * as vscode from "vscode" +import fs from "fs/promises" + +import { Task } from "../task/Task" +import { ClineSayTool } from "../../shared/ExtensionMessage" +import { formatResponse } from "../prompts/responses" +import { RecordSource } from "../context-tracking/FileContextTrackerTypes" +import { fileExistsAtPath } from "../../utils/fs" +import { stripLineNumbers, everyLineHasLineNumbers } from "../../integrations/misc/extract-text" +import { getReadablePath } from "../../utils/path" +import { isPathOutsideWorkspace } from "../../utils/pathUtils" +import { detectCodeOmission } from "../../integrations/editor/detect-omission" +import { unescapeHtmlEntities } from "../../utils/text-normalization" +import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" +import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" +import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" +import { resolveToolProtocol } from "../../utils/resolveToolProtocol" + +interface WriteToFileParams { + path: string + content: string + line_count: number +} + +export class WriteToFileTool extends BaseTool<"write_to_file"> { + readonly name = "write_to_file" as const + + parseLegacy(params: Partial>): WriteToFileParams { + return { + path: params.path || "", + content: params.content || "", + line_count: parseInt(params.line_count ?? "0", 10), + } + } + + async execute(params: WriteToFileParams, task: Task, callbacks: ToolCallbacks): Promise { + const { pushToolResult, handleError, askApproval, removeClosingTag, toolProtocol } = callbacks + const relPath = params.path + let newContent = params.content + const predictedLineCount = params.line_count + + if (!relPath) { + task.consecutiveMistakeCount++ + task.recordToolError("write_to_file") + pushToolResult(await task.sayAndCreateMissingParamError("write_to_file", "path")) + await task.diffViewProvider.reset() + return + } + + if (newContent === undefined) { + task.consecutiveMistakeCount++ + task.recordToolError("write_to_file") + pushToolResult(await task.sayAndCreateMissingParamError("write_to_file", "content")) + await task.diffViewProvider.reset() + return + } + + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) + + if (!accessAllowed) { + await task.say("rooignore_error", relPath) + pushToolResult(formatResponse.rooIgnoreError(relPath, toolProtocol)) + return + } + + const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false + + let fileExists: boolean + + if (task.diffViewProvider.editType !== undefined) { + fileExists = task.diffViewProvider.editType === "modify" + } else { + const absolutePath = path.resolve(task.cwd, relPath) + fileExists = await fileExistsAtPath(absolutePath) + task.diffViewProvider.editType = fileExists ? "modify" : "create" + } + + if (newContent.startsWith("```")) { + newContent = newContent.split("\n").slice(1).join("\n") + } + + if (newContent.endsWith("```")) { + newContent = newContent.split("\n").slice(0, -1).join("\n") + } + + if (!task.api.getModel().id.includes("claude")) { + newContent = unescapeHtmlEntities(newContent) + } + + const fullPath = relPath ? path.resolve(task.cwd, removeClosingTag("path", relPath)) : "" + const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) + + const sharedMessageProps: ClineSayTool = { + tool: fileExists ? "editedExistingFile" : "newFileCreated", + path: getReadablePath(task.cwd, removeClosingTag("path", relPath)), + content: newContent, + isOutsideWorkspace, + isProtected: isWriteProtected, + } + + try { + if (predictedLineCount === undefined || predictedLineCount === 0) { + task.consecutiveMistakeCount++ + task.recordToolError("write_to_file") + + const actualLineCount = newContent.split("\n").length + const isNewFile = !fileExists + const diffStrategyEnabled = !!task.diffStrategy + const modelInfo = task.api.getModel().info + const toolProtocol = resolveToolProtocol(task.apiConfiguration, modelInfo) + + await task.say( + "error", + `Roo tried to use write_to_file${ + relPath ? ` for '${relPath.toPosix()}'` : "" + } but the required parameter 'line_count' was missing or truncated after ${actualLineCount} lines of content were written. Retrying...`, + ) + + pushToolResult( + formatResponse.toolError( + formatResponse.lineCountTruncationError( + actualLineCount, + isNewFile, + diffStrategyEnabled, + toolProtocol, + ), + ), + ) + await task.diffViewProvider.revertChanges() + return + } + + task.consecutiveMistakeCount = 0 + + const provider = task.providerRef.deref() + const state = await provider?.getState() + const diagnosticsEnabled = state?.diagnosticsEnabled ?? true + const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS + const isPreventFocusDisruptionEnabled = experiments.isEnabled( + state?.experiments ?? {}, + EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, + ) + + if (isPreventFocusDisruptionEnabled) { + task.diffViewProvider.editType = fileExists ? "modify" : "create" + if (fileExists) { + const absolutePath = path.resolve(task.cwd, relPath) + task.diffViewProvider.originalContent = await fs.readFile(absolutePath, "utf-8") + } else { + task.diffViewProvider.originalContent = "" + } + + if (detectCodeOmission(task.diffViewProvider.originalContent || "", newContent, predictedLineCount)) { + if (task.diffStrategy) { + pushToolResult( + formatResponse.toolError( + `Content appears to be truncated (file has ${ + newContent.split("\n").length + } lines but was predicted to have ${predictedLineCount} lines), and found comments indicating omitted code (e.g., '// rest of code unchanged', '/* previous code */'). Please provide the complete file content without any omissions if possible, or otherwise use the 'apply_diff' tool to apply the diff to the original file.`, + ), + ) + return + } else { + vscode.window + .showWarningMessage( + "Potential code truncation detected. cline happens when the AI reaches its max output limit.", + "Follow cline guide to fix the issue", + ) + .then((selection) => { + if (selection === "Follow cline guide to fix the issue") { + vscode.env.openExternal( + vscode.Uri.parse( + "https://github.com/cline/cline/wiki/Troubleshooting-%E2%80%90-Cline-Deleting-Code-with-%22Rest-of-Code-Here%22-Comments", + ), + ) + } + }) + } + } + + let unified = fileExists + ? formatResponse.createPrettyPatch(relPath, task.diffViewProvider.originalContent, newContent) + : convertNewFileToUnifiedDiff(newContent, relPath) + unified = sanitizeUnifiedDiff(unified) + const completeMessage = JSON.stringify({ + ...sharedMessageProps, + content: unified, + diffStats: computeDiffStats(unified) || undefined, + } satisfies ClineSayTool) + + const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) + + if (!didApprove) { + return + } + + await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + } else { + if (!task.diffViewProvider.isEditing) { + const partialMessage = JSON.stringify(sharedMessageProps) + await task.ask("tool", partialMessage, true).catch(() => {}) + await task.diffViewProvider.open(relPath) + } + + await task.diffViewProvider.update( + everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, + true, + ) + + await delay(300) + task.diffViewProvider.scrollToFirstDiff() + + if (detectCodeOmission(task.diffViewProvider.originalContent || "", newContent, predictedLineCount)) { + if (task.diffStrategy) { + await task.diffViewProvider.revertChanges() + + pushToolResult( + formatResponse.toolError( + `Content appears to be truncated (file has ${ + newContent.split("\n").length + } lines but was predicted to have ${predictedLineCount} lines), and found comments indicating omitted code (e.g., '// rest of code unchanged', '/* previous code */'). Please provide the complete file content without any omissions if possible, or otherwise use the 'apply_diff' tool to apply the diff to the original file.`, + ), + ) + return + } else { + vscode.window + .showWarningMessage( + "Potential code truncation detected. cline happens when the AI reaches its max output limit.", + "Follow cline guide to fix the issue", + ) + .then((selection) => { + if (selection === "Follow cline guide to fix the issue") { + vscode.env.openExternal( + vscode.Uri.parse( + "https://github.com/cline/cline/wiki/Troubleshooting-%E2%80%90-Cline-Deleting-Code-with-%22Rest-of-Code-Here%22-Comments", + ), + ) + } + }) + } + } + + let unified = fileExists + ? formatResponse.createPrettyPatch(relPath, task.diffViewProvider.originalContent, newContent) + : convertNewFileToUnifiedDiff(newContent, relPath) + unified = sanitizeUnifiedDiff(unified) + const completeMessage = JSON.stringify({ + ...sharedMessageProps, + content: unified, + diffStats: computeDiffStats(unified) || undefined, + } satisfies ClineSayTool) + + const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) + + if (!didApprove) { + await task.diffViewProvider.revertChanges() + return + } + + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + } + + if (relPath) { + await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) + } + + task.didEditFile = true + + const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, !fileExists) + + pushToolResult(message) + + await task.diffViewProvider.reset() + + task.processQueuedMessages() + + return + } catch (error) { + await handleError("writing file", error as Error) + await task.diffViewProvider.reset() + return + } + } + + override async handlePartial(task: Task, block: ToolUse<"write_to_file">): Promise { + const relPath: string | undefined = block.params.path + let newContent: string | undefined = block.params.content + + if (!relPath || newContent === undefined) { + return + } + + const provider = task.providerRef.deref() + const state = await provider?.getState() + const isPreventFocusDisruptionEnabled = experiments.isEnabled( + state?.experiments ?? {}, + EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, + ) + + if (isPreventFocusDisruptionEnabled) { + return + } + + let fileExists: boolean + if (task.diffViewProvider.editType !== undefined) { + fileExists = task.diffViewProvider.editType === "modify" + } else { + const absolutePath = path.resolve(task.cwd, relPath) + fileExists = await fileExistsAtPath(absolutePath) + task.diffViewProvider.editType = fileExists ? "modify" : "create" + } + + const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false + const fullPath = path.resolve(task.cwd, relPath) + const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) + + const sharedMessageProps: ClineSayTool = { + tool: fileExists ? "editedExistingFile" : "newFileCreated", + path: getReadablePath(task.cwd, relPath), + content: newContent, + isOutsideWorkspace, + isProtected: isWriteProtected, + } + + const partialMessage = JSON.stringify(sharedMessageProps) + await task.ask("tool", partialMessage, block.partial).catch(() => {}) + + if (!task.diffViewProvider.isEditing) { + await task.diffViewProvider.open(relPath) + } + + await task.diffViewProvider.update( + everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, + false, + ) + } +} + +export const writeToFileTool = new WriteToFileTool() diff --git a/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts b/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts index 8313d83960..3e156dd7c4 100644 --- a/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts +++ b/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts @@ -562,4 +562,139 @@ describe("ToolRepetitionDetector", () => { expect(result.askUser).toBeDefined() }) }) + + // ===== Native Protocol (nativeArgs) tests ===== + describe("native protocol with nativeArgs", () => { + it("should differentiate read_file calls with different files in nativeArgs", () => { + const detector = new ToolRepetitionDetector(2) + + // Create read_file tool use with nativeArgs (like native protocol does) + const readFile1: ToolUse = { + type: "tool_use", + name: "read_file" as ToolName, + params: {}, // Empty for native protocol + partial: false, + nativeArgs: { + files: [{ path: "file1.ts" }], + }, + } + + const readFile2: ToolUse = { + type: "tool_use", + name: "read_file" as ToolName, + params: {}, // Empty for native protocol + partial: false, + nativeArgs: { + files: [{ path: "file2.ts" }], + }, + } + + // First call with file1 + expect(detector.check(readFile1).allowExecution).toBe(true) + + // Second call with file2 - should be treated as different + expect(detector.check(readFile2).allowExecution).toBe(true) + + // Third call with file1 again - should reset counter + expect(detector.check(readFile1).allowExecution).toBe(true) + }) + + it("should detect repetition when same files are read multiple times with nativeArgs", () => { + const detector = new ToolRepetitionDetector(2) + + // Create identical read_file tool uses + const readFile: ToolUse = { + type: "tool_use", + name: "read_file" as ToolName, + params: {}, // Empty for native protocol + partial: false, + nativeArgs: { + files: [{ path: "same-file.ts" }], + }, + } + + // First call allowed + expect(detector.check(readFile).allowExecution).toBe(true) + + // Second call allowed + expect(detector.check(readFile).allowExecution).toBe(true) + + // Third identical call should be blocked (limit is 2) + const result = detector.check(readFile) + expect(result.allowExecution).toBe(false) + expect(result.askUser).toBeDefined() + }) + + it("should differentiate read_file calls with multiple files in different orders", () => { + const detector = new ToolRepetitionDetector(2) + + const readFile1: ToolUse = { + type: "tool_use", + name: "read_file" as ToolName, + params: {}, + partial: false, + nativeArgs: { + files: [{ path: "a.ts" }, { path: "b.ts" }], + }, + } + + const readFile2: ToolUse = { + type: "tool_use", + name: "read_file" as ToolName, + params: {}, + partial: false, + nativeArgs: { + files: [{ path: "b.ts" }, { path: "a.ts" }], + }, + } + + // Different order should be treated as different calls + expect(detector.check(readFile1).allowExecution).toBe(true) + expect(detector.check(readFile2).allowExecution).toBe(true) + }) + + it("should handle tools with both params and nativeArgs", () => { + const detector = new ToolRepetitionDetector(2) + + const tool1: ToolUse = { + type: "tool_use", + name: "execute_command" as ToolName, + params: { command: "ls" }, + partial: false, + nativeArgs: { + command: "ls", + cwd: "/home/user", + }, + } + + const tool2: ToolUse = { + type: "tool_use", + name: "execute_command" as ToolName, + params: { command: "ls" }, + partial: false, + nativeArgs: { + command: "ls", + cwd: "/home/admin", + }, + } + + // Different cwd in nativeArgs should make these different + expect(detector.check(tool1).allowExecution).toBe(true) + expect(detector.check(tool2).allowExecution).toBe(true) + }) + + it("should handle tools with only params (no nativeArgs)", () => { + const detector = new ToolRepetitionDetector(2) + + const legacyTool = createToolUse("read_file", "read_file", { path: "test.txt" }) + + // Should work the same as before + expect(detector.check(legacyTool).allowExecution).toBe(true) + expect(detector.check(legacyTool).allowExecution).toBe(true) + + const result = detector.check(legacyTool) + expect(result.allowExecution).toBe(false) + expect(result.askUser).toBeDefined() + }) + }) }) diff --git a/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts index f82d4b1820..4e0044c5ee 100644 --- a/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts @@ -1,13 +1,23 @@ -import { applyDiffTool } from "../multiApplyDiffTool" import { EXPERIMENT_IDS } from "../../../shared/experiments" +import { TOOL_PROTOCOL } from "@roo-code/types" -// Mock the applyDiffTool module -vi.mock("../applyDiffTool", () => ({ - applyDiffToolLegacy: vi.fn(), +// Mock vscode +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn(), + }, +})) + +// Mock the ApplyDiffTool module +vi.mock("../ApplyDiffTool", () => ({ + applyDiffTool: { + handle: vi.fn(), + }, })) // Import after mocking to get the mocked version -import { applyDiffToolLegacy } from "../applyDiffTool" +import { applyDiffTool as multiApplyDiffTool } from "../MultiApplyDiffTool" +import { applyDiffTool as applyDiffToolClass } from "../ApplyDiffTool" describe("applyDiffTool experiment routing", () => { let mockCline: any @@ -18,9 +28,15 @@ describe("applyDiffTool experiment routing", () => { let mockRemoveClosingTag: any let mockProvider: any - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks() + // Reset vscode mock to default behavior (XML protocol) + const vscode = await import("vscode") + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue(TOOL_PROTOCOL.XML), + } as any) + mockProvider = { getState: vi.fn(), } @@ -37,8 +53,19 @@ describe("applyDiffTool experiment routing", () => { diffViewProvider: { reset: vi.fn(), }, + apiConfiguration: { + apiProvider: "anthropic", + }, api: { - getModel: vi.fn().mockReturnValue({ id: "test-model" }), + getModel: vi.fn().mockReturnValue({ + id: "test-model", + info: { + maxTokens: 4096, + contextWindow: 128000, + supportsPromptCache: false, + supportsNativeTools: false, + }, + }), }, processQueuedMessages: vi.fn(), } as any @@ -64,10 +91,10 @@ describe("applyDiffTool experiment routing", () => { }, }) - // Mock the legacy tool to resolve successfully - ;(applyDiffToolLegacy as any).mockResolvedValue(undefined) + // Mock the class-based tool to resolve successfully + ;(applyDiffToolClass.handle as any).mockResolvedValue(undefined) - await applyDiffTool( + await multiApplyDiffTool( mockCline, mockBlock, mockAskApproval, @@ -76,23 +103,22 @@ describe("applyDiffTool experiment routing", () => { mockRemoveClosingTag, ) - expect(applyDiffToolLegacy).toHaveBeenCalledWith( - mockCline, - mockBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + expect(applyDiffToolClass.handle).toHaveBeenCalledWith(mockCline, mockBlock, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) }) it("should use legacy tool when experiments are not defined", async () => { mockProvider.getState.mockResolvedValue({}) - // Mock the legacy tool to resolve successfully - ;(applyDiffToolLegacy as any).mockResolvedValue(undefined) + // Mock the class-based tool to resolve successfully + ;(applyDiffToolClass.handle as any).mockResolvedValue(undefined) - await applyDiffTool( + await multiApplyDiffTool( mockCline, mockBlock, mockAskApproval, @@ -101,26 +127,25 @@ describe("applyDiffTool experiment routing", () => { mockRemoveClosingTag, ) - expect(applyDiffToolLegacy).toHaveBeenCalledWith( - mockCline, - mockBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + expect(applyDiffToolClass.handle).toHaveBeenCalledWith(mockCline, mockBlock, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) }) - it("should use new tool when MULTI_FILE_APPLY_DIFF experiment is enabled", async () => { + it("should use multi-file tool when MULTI_FILE_APPLY_DIFF experiment is enabled and using XML protocol", async () => { mockProvider.getState.mockResolvedValue({ experiments: { [EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF]: true, }, }) - // Mock the new tool behavior - it should continue with the new implementation - // Since we're not mocking the entire function, we'll just verify it doesn't call legacy - await applyDiffTool( + // Mock the new tool behavior - it should continue with the multi-file implementation + // Since we're not mocking the entire function, we'll just verify it doesn't call the class-based tool + await multiApplyDiffTool( mockCline, mockBlock, mockAskApproval, @@ -129,13 +154,30 @@ describe("applyDiffTool experiment routing", () => { mockRemoveClosingTag, ) - expect(applyDiffToolLegacy).not.toHaveBeenCalled() + expect(applyDiffToolClass.handle).not.toHaveBeenCalled() }) - it("should use new tool when provider is not available", async () => { - mockCline.providerRef.deref.mockReturnValue(null) + it("should use class-based tool when model defaults to native protocol", async () => { + // Update model to support native tools and default to native protocol + mockCline.api.getModel = vi.fn().mockReturnValue({ + id: "test-model", + info: { + maxTokens: 4096, + contextWindow: 128000, + supportsPromptCache: false, + supportsNativeTools: true, // Model supports native tools + defaultToolProtocol: "native", // Model defaults to native protocol + }, + }) - await applyDiffTool( + mockProvider.getState.mockResolvedValue({ + experiments: { + [EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF]: true, + }, + }) + ;(applyDiffToolClass.handle as any).mockResolvedValue(undefined) + + await multiApplyDiffTool( mockCline, mockBlock, mockAskApproval, @@ -144,7 +186,13 @@ describe("applyDiffTool experiment routing", () => { mockRemoveClosingTag, ) - // When provider is null, it should continue with new implementation (not call legacy) - expect(applyDiffToolLegacy).not.toHaveBeenCalled() + // When native protocol is used, should always use class-based tool + expect(applyDiffToolClass.handle).toHaveBeenCalledWith(mockCline, mockBlock, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "native", + }) }) }) diff --git a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts index 6bddddcdf1..2aa46b5100 100644 --- a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts +++ b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts @@ -1,4 +1,4 @@ -import { askFollowupQuestionTool } from "../askFollowupQuestionTool" +import { askFollowupQuestionTool } from "../AskFollowupQuestionTool" import { ToolUse } from "../../../shared/tools" describe("askFollowupQuestionTool", () => { @@ -31,14 +31,13 @@ describe("askFollowupQuestionTool", () => { partial: false, } - await askFollowupQuestionTool( - mockCline, - block, - vi.fn(), - vi.fn(), - mockPushToolResult, - vi.fn((tag, content) => content), - ) + await askFollowupQuestionTool.handle(mockCline, block as ToolUse<"ask_followup_question">, { + askApproval: vi.fn(), + handleError: vi.fn(), + pushToolResult: mockPushToolResult, + removeClosingTag: vi.fn((tag, content) => content), + toolProtocol: "xml", + }) expect(mockCline.ask).toHaveBeenCalledWith( "followup", @@ -58,14 +57,13 @@ describe("askFollowupQuestionTool", () => { partial: false, } - await askFollowupQuestionTool( - mockCline, - block, - vi.fn(), - vi.fn(), - mockPushToolResult, - vi.fn((tag, content) => content), - ) + await askFollowupQuestionTool.handle(mockCline, block as ToolUse<"ask_followup_question">, { + askApproval: vi.fn(), + handleError: vi.fn(), + pushToolResult: mockPushToolResult, + removeClosingTag: vi.fn((tag, content) => content), + toolProtocol: "xml", + }) expect(mockCline.ask).toHaveBeenCalledWith( "followup", @@ -87,14 +85,13 @@ describe("askFollowupQuestionTool", () => { partial: false, } - await askFollowupQuestionTool( - mockCline, - block, - vi.fn(), - vi.fn(), - mockPushToolResult, - vi.fn((tag, content) => content), - ) + await askFollowupQuestionTool.handle(mockCline, block as ToolUse<"ask_followup_question">, { + askApproval: vi.fn(), + handleError: vi.fn(), + pushToolResult: mockPushToolResult, + removeClosingTag: vi.fn((tag, content) => content), + toolProtocol: "xml", + }) expect(mockCline.ask).toHaveBeenCalledWith( "followup", diff --git a/src/core/tools/__tests__/attemptCompletionTool.spec.ts b/src/core/tools/__tests__/attemptCompletionTool.spec.ts index fcad4d5f49..9ab4d57ebb 100644 --- a/src/core/tools/__tests__/attemptCompletionTool.spec.ts +++ b/src/core/tools/__tests__/attemptCompletionTool.spec.ts @@ -25,7 +25,7 @@ vi.mock("../../../shared/package", () => ({ }, })) -import { attemptCompletionTool } from "../attemptCompletionTool" +import { attemptCompletionTool, AttemptCompletionCallbacks } from "../AttemptCompletionTool" import { Task } from "../../task/Task" import * as vscode from "vscode" @@ -76,16 +76,16 @@ describe("attemptCompletionTool", () => { mockTask.todoList = undefined - await attemptCompletionTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - mockToolDescription, - mockAskFinishSubTaskApproval, - ) + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + toolProtocol: "xml", + } + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) // Should not call pushToolResult with an error for empty todo list expect(mockTask.consecutiveMistakeCount).toBe(0) @@ -102,16 +102,16 @@ describe("attemptCompletionTool", () => { mockTask.todoList = [] - await attemptCompletionTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - mockToolDescription, - mockAskFinishSubTaskApproval, - ) + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + toolProtocol: "xml", + } + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockTask.recordToolError).not.toHaveBeenCalled() @@ -132,16 +132,16 @@ describe("attemptCompletionTool", () => { mockTask.todoList = completedTodos - await attemptCompletionTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - mockToolDescription, - mockAskFinishSubTaskApproval, - ) + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + toolProtocol: "xml", + } + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockTask.recordToolError).not.toHaveBeenCalled() @@ -172,16 +172,16 @@ describe("attemptCompletionTool", () => { }), }) - await attemptCompletionTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - mockToolDescription, - mockAskFinishSubTaskApproval, - ) + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + toolProtocol: "xml", + } + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) expect(mockTask.consecutiveMistakeCount).toBe(1) expect(mockTask.recordToolError).toHaveBeenCalledWith("attempt_completion") @@ -215,16 +215,16 @@ describe("attemptCompletionTool", () => { }), }) - await attemptCompletionTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - mockToolDescription, - mockAskFinishSubTaskApproval, - ) + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + toolProtocol: "xml", + } + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) expect(mockTask.consecutiveMistakeCount).toBe(1) expect(mockTask.recordToolError).toHaveBeenCalledWith("attempt_completion") @@ -259,16 +259,16 @@ describe("attemptCompletionTool", () => { }), }) - await attemptCompletionTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - mockToolDescription, - mockAskFinishSubTaskApproval, - ) + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + toolProtocol: "xml", + } + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) expect(mockTask.consecutiveMistakeCount).toBe(1) expect(mockTask.recordToolError).toHaveBeenCalledWith("attempt_completion") @@ -302,16 +302,16 @@ describe("attemptCompletionTool", () => { }), }) - await attemptCompletionTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - mockToolDescription, - mockAskFinishSubTaskApproval, - ) + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + toolProtocol: "xml", + } + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) // Should not prevent completion when setting is disabled expect(mockTask.consecutiveMistakeCount).toBe(0) @@ -346,16 +346,16 @@ describe("attemptCompletionTool", () => { }), }) - await attemptCompletionTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - mockToolDescription, - mockAskFinishSubTaskApproval, - ) + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + toolProtocol: "xml", + } + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) // Should prevent completion when setting is enabled and there are incomplete todos expect(mockTask.consecutiveMistakeCount).toBe(1) @@ -390,16 +390,16 @@ describe("attemptCompletionTool", () => { }), }) - await attemptCompletionTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - mockToolDescription, - mockAskFinishSubTaskApproval, - ) + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + toolProtocol: "xml", + } + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) // Should allow completion when setting is enabled but all todos are completed expect(mockTask.consecutiveMistakeCount).toBe(0) diff --git a/src/core/tools/__tests__/executeCommand.spec.ts b/src/core/tools/__tests__/executeCommand.spec.ts index 2e973a24cb..f5fc258e3a 100644 --- a/src/core/tools/__tests__/executeCommand.spec.ts +++ b/src/core/tools/__tests__/executeCommand.spec.ts @@ -4,7 +4,7 @@ import * as path from "path" import * as fs from "fs/promises" -import { ExecuteCommandOptions } from "../executeCommandTool" +import { ExecuteCommandOptions } from "../ExecuteCommandTool" import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" import { Terminal } from "../../../integrations/terminal/Terminal" import { ExecaTerminal } from "../../../integrations/terminal/ExecaTerminal" @@ -21,7 +21,7 @@ vitest.mock("../../../integrations/terminal/Terminal") vitest.mock("../../../integrations/terminal/ExecaTerminal") // Import the actual executeCommand function (not mocked) -import { executeCommand } from "../executeCommandTool" +import { executeCommandInTerminal } from "../ExecuteCommandTool" // Tests for the executeCommand function describe("executeCommand", () => { @@ -104,7 +104,7 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommand(mockTask, options) + const [rejected, result] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) @@ -145,7 +145,7 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommand(mockTask, options) + const [rejected, result] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) @@ -178,7 +178,7 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommand(mockTask, options) + const [rejected, result] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) @@ -209,7 +209,7 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommand(mockTask, options) + const [rejected, result] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) @@ -239,7 +239,7 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommand(mockTask, options) + const [rejected, result] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) @@ -262,7 +262,7 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommand(mockTask, options) + const [rejected, result] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) @@ -289,7 +289,7 @@ describe("executeCommand", () => { } // Execute - await executeCommand(mockTask, options) + await executeCommandInTerminal(mockTask, options) // Verify expect(TerminalRegistry.getOrCreateTerminal).toHaveBeenCalledWith(mockTask.cwd, mockTask.taskId, "vscode") @@ -312,7 +312,7 @@ describe("executeCommand", () => { } // Execute - await executeCommand(mockTask, options) + await executeCommandInTerminal(mockTask, options) // Verify expect(TerminalRegistry.getOrCreateTerminal).toHaveBeenCalledWith(mockTask.cwd, mockTask.taskId, "execa") @@ -338,7 +338,7 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommand(mockTask, options) + const [rejected, result] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) @@ -364,7 +364,7 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommand(mockTask, options) + const [rejected, result] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) @@ -398,7 +398,7 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommand(mockTask, options) + const [rejected, result] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) @@ -440,7 +440,7 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommand(mockTask, options) + const [rejected, result] = await executeCommandInTerminal(mockTask, options) // Verify the result uses the updated working directory expect(rejected).toBe(false) diff --git a/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts b/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts index b9e0af3a8a..f93a29caaf 100644 --- a/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts +++ b/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts @@ -3,7 +3,7 @@ import * as vscode from "vscode" import * as fs from "fs/promises" -import { executeCommand, executeCommandTool, ExecuteCommandOptions } from "../executeCommandTool" +import { executeCommandInTerminal, executeCommandTool, ExecuteCommandOptions } from "../ExecuteCommandTool" import { Task } from "../../task/Task" import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" @@ -90,7 +90,7 @@ describe("Command Execution Timeout Integration", () => { const quickProcess = Promise.resolve() mockTerminal.runCommand.mockReturnValue(quickProcess) - await executeCommand(mockTask as Task, options) + await executeCommandInTerminal(mockTask as Task, options) // Verify that the terminal was called with the command expect(mockTerminal.runCommand).toHaveBeenCalledWith("echo test", expect.any(Object)) @@ -115,7 +115,7 @@ describe("Command Execution Timeout Integration", () => { mockTerminal.runCommand.mockReturnValue(longRunningProcess) // Execute with timeout - const result = await executeCommand(mockTask as Task, options) + const result = await executeCommandInTerminal(mockTask as Task, options) // Should return timeout error expect(result[0]).toBe(false) // Not rejected by user @@ -140,7 +140,7 @@ describe("Command Execution Timeout Integration", () => { mockTerminal.runCommand.mockReturnValue(neverResolvingPromise) - await executeCommand(mockTask as Task, options) + await executeCommandInTerminal(mockTask as Task, options) // Verify abort was called expect(abortSpy).toHaveBeenCalled() @@ -157,7 +157,7 @@ describe("Command Execution Timeout Integration", () => { const quickProcess = Promise.resolve() mockTerminal.runCommand.mockReturnValue(quickProcess) - const result = await executeCommand(mockTask as Task, options) + const result = await executeCommandInTerminal(mockTask as Task, options) // Should complete successfully without timeout expect(result[0]).toBe(false) // Not rejected @@ -174,7 +174,7 @@ describe("Command Execution Timeout Integration", () => { const quickProcess = Promise.resolve() mockTerminal.runCommand.mockReturnValue(quickProcess) - await executeCommand(mockTask as Task, options) + await executeCommandInTerminal(mockTask as Task, options) // Should complete without issues using default (no timeout) expect(mockTerminal.runCommand).toHaveBeenCalled() @@ -194,7 +194,7 @@ describe("Command Execution Timeout Integration", () => { mockTerminal.runCommand.mockReturnValue(longRunningProcess) - const result = await executeCommand(mockTask as Task, options) + const result = await executeCommandInTerminal(mockTask as Task, options) // Should complete successfully without timeout expect(result[0]).toBe(false) // Not rejected @@ -273,14 +273,13 @@ describe("Command Execution Timeout Integration", () => { }) mockTerminal.runCommand.mockReturnValue(longRunningProcess) - await executeCommandTool( - mockTask as Task, - mockBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await executeCommandTool.handle(mockTask as Task, mockBlock, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Should complete successfully without timeout because "npm" is in allowlist expect(mockPushToolResult).toHaveBeenCalled() @@ -306,14 +305,13 @@ describe("Command Execution Timeout Integration", () => { ;(neverResolvingProcess as any).abort = vitest.fn() mockTerminal.runCommand.mockReturnValue(neverResolvingProcess) - await executeCommandTool( - mockTask as Task, - mockBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await executeCommandTool.handle(mockTask as Task, mockBlock, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Should timeout because "sleep" is not in allowlist expect(mockPushToolResult).toHaveBeenCalled() @@ -339,14 +337,13 @@ describe("Command Execution Timeout Integration", () => { ;(neverResolvingProcess as any).abort = vitest.fn() mockTerminal.runCommand.mockReturnValue(neverResolvingProcess) - await executeCommandTool( - mockTask as Task, - mockBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await executeCommandTool.handle(mockTask as Task, mockBlock, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Should timeout because allowlist is empty expect(mockPushToolResult).toHaveBeenCalled() @@ -375,14 +372,13 @@ describe("Command Execution Timeout Integration", () => { mockBlock.params.command = "git log --oneline" mockTerminal.runCommand.mockReturnValueOnce(longRunningProcess) - await executeCommandTool( - mockTask as Task, - mockBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await executeCommandTool.handle(mockTask as Task, mockBlock, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockPushToolResult).toHaveBeenCalled() const result1 = mockPushToolResult.mock.calls[0][0] @@ -395,14 +391,13 @@ describe("Command Execution Timeout Integration", () => { mockBlock.params.command = "git status" // "git" alone is not in allowlist, only "git log" mockTerminal.runCommand.mockReturnValueOnce(neverResolvingProcess) - await executeCommandTool( - mockTask as Task, - mockBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await executeCommandTool.handle(mockTask as Task, mockBlock, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockPushToolResult).toHaveBeenCalled() const result2 = mockPushToolResult.mock.calls[0][0] diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index dbb1945177..0406a83d2a 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -13,69 +13,33 @@ vitest.mock("execa", () => ({ execa: vitest.fn(), })) +vitest.mock("fs/promises", () => ({ + default: { + access: vitest.fn().mockResolvedValue(undefined), + }, +})) + vitest.mock("vscode", () => ({ workspace: { getConfiguration: vitest.fn(), }, })) +vitest.mock("../../../integrations/terminal/TerminalRegistry", () => ({ + TerminalRegistry: { + getOrCreateTerminal: vitest.fn().mockResolvedValue({ + runCommand: vitest.fn().mockResolvedValue(undefined), + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + }), + }, +})) + vitest.mock("../../task/Task") vitest.mock("../../prompts/responses") -// Create a mock for the executeCommand function -const mockExecuteCommand = vitest.fn().mockImplementation(() => { - return Promise.resolve([false, "Command executed"]) -}) - -// Mock the module -vitest.mock("../executeCommandTool") - -// Import after mocking -import { executeCommandTool } from "../executeCommandTool" - -// Now manually restore and mock the functions -beforeEach(() => { - // Reset the mock implementation for executeCommandTool - // @ts-expect-error - TypeScript doesn't like this pattern - executeCommandTool.mockImplementation(async (cline, block, askApproval, handleError, pushToolResult) => { - if (!block.params.command) { - cline.consecutiveMistakeCount++ - cline.recordToolError("execute_command") - const errorMessage = await cline.sayAndCreateMissingParamError("execute_command", "command") - pushToolResult(errorMessage) - return - } - - const ignoredFileAttemptedToAccess = cline.rooIgnoreController?.validateCommand(block.params.command) - if (ignoredFileAttemptedToAccess) { - await cline.say("rooignore_error", ignoredFileAttemptedToAccess) - // Call the mocked formatResponse functions with the correct arguments - const mockRooIgnoreError = "RooIgnore error" - ;(formatResponse.rooIgnoreError as any).mockReturnValue(mockRooIgnoreError) - ;(formatResponse.toolError as any).mockReturnValue("Tool error") - formatResponse.rooIgnoreError(ignoredFileAttemptedToAccess) - formatResponse.toolError(mockRooIgnoreError) - pushToolResult("Tool error") - return - } - - const didApprove = await askApproval("command", block.params.command) - if (!didApprove) { - return - } - - // Get the custom working directory if provided - const customCwd = block.params.cwd - - const [userRejected, result] = await mockExecuteCommand(cline, block.params.command, customCwd) - - if (userRejected) { - cline.didRejectTool = true - } - - pushToolResult(result) - }) -}) +// Import the module +import * as executeCommandModule from "../ExecuteCommandTool" +const { executeCommandTool } = executeCommandModule describe("executeCommandTool", () => { // Setup common test variables @@ -84,12 +48,15 @@ describe("executeCommandTool", () => { let mockHandleError: any let mockPushToolResult: any let mockRemoveClosingTag: any - let mockToolUse: ToolUse + let mockToolUse: ToolUse<"execute_command"> beforeEach(() => { // Reset mocks vitest.clearAllMocks() + // Spy on executeCommandInTerminal and mock its return value + vitest.spyOn(executeCommandModule, "executeCommandInTerminal").mockResolvedValue([false, "Command executed"]) + // Create mock implementations with eslint directives to handle the type issues mockCline = { ask: vitest.fn().mockResolvedValue(undefined), @@ -101,8 +68,19 @@ describe("executeCommandTool", () => { validateCommand: vitest.fn().mockReturnValue(null), }, recordToolUsage: vitest.fn().mockReturnValue({} as ToolUsage), - // Add the missing recordToolError function recordToolError: vitest.fn(), + providerRef: { + deref: vitest.fn().mockResolvedValue({ + getState: vitest.fn().mockResolvedValue({ + terminalOutputLineLimit: 500, + terminalOutputCharacterLimit: 100000, + terminalShellIntegrationDisabled: true, + }), + postMessageToWebview: vitest.fn(), + }), + }, + lastMessageTs: Date.now(), + cwd: "/test/workspace", } mockAskApproval = vitest.fn().mockResolvedValue(true) @@ -110,6 +88,12 @@ describe("executeCommandTool", () => { mockPushToolResult = vitest.fn() mockRemoveClosingTag = vitest.fn().mockReturnValue("command") + // Setup vscode config mock + const mockConfig = { + get: vitest.fn().mockImplementation((key: string, defaultValue: any) => defaultValue), + } + ;(vscode.workspace.getConfiguration as any).mockReturnValue(mockConfig) + // Create a mock tool use object mockToolUse = { type: "tool_use", @@ -157,20 +141,21 @@ describe("executeCommandTool", () => { // Setup mockToolUse.params.command = "echo test" - // Execute - await executeCommandTool( - mockCline as unknown as Task, - mockToolUse, - mockAskApproval as unknown as AskApproval, - mockHandleError as unknown as HandleError, - mockPushToolResult as unknown as PushToolResult, - mockRemoveClosingTag as unknown as RemoveClosingTag, - ) + // Execute using the class-based handle method + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + removeClosingTag: mockRemoveClosingTag as unknown as RemoveClosingTag, + toolProtocol: "xml", + }) // Verify expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test") - expect(mockExecuteCommand).toHaveBeenCalled() - expect(mockPushToolResult).toHaveBeenCalledWith("Command executed") + expect(mockPushToolResult).toHaveBeenCalled() + // The exact message depends on the terminal mock's behavior + const result = mockPushToolResult.mock.calls[0][0] + expect(result).toContain("Command") }) it("should pass along custom working directory if provided", async () => { @@ -179,20 +164,20 @@ describe("executeCommandTool", () => { mockToolUse.params.cwd = "/custom/path" // Execute - await executeCommandTool( - mockCline as unknown as Task, - mockToolUse, - mockAskApproval as unknown as AskApproval, - mockHandleError as unknown as HandleError, - mockPushToolResult as unknown as PushToolResult, - mockRemoveClosingTag as unknown as RemoveClosingTag, - ) + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + removeClosingTag: mockRemoveClosingTag as unknown as RemoveClosingTag, + toolProtocol: "xml", + }) - // Verify - expect(mockExecuteCommand).toHaveBeenCalled() - // Check that the last call to mockExecuteCommand included the custom path - const lastCall = mockExecuteCommand.mock.calls[mockExecuteCommand.mock.calls.length - 1] - expect(lastCall[2]).toBe("/custom/path") + // Verify - confirm the command was approved and result was pushed + // The custom path handling is tested in integration tests + expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test") + expect(mockPushToolResult).toHaveBeenCalled() + const result = mockPushToolResult.mock.calls[0][0] + expect(result).toContain("/custom/path") }) }) @@ -202,21 +187,20 @@ describe("executeCommandTool", () => { mockToolUse.params.command = undefined // Execute - await executeCommandTool( - mockCline as unknown as Task, - mockToolUse, - mockAskApproval as unknown as AskApproval, - mockHandleError as unknown as HandleError, - mockPushToolResult as unknown as PushToolResult, - mockRemoveClosingTag as unknown as RemoveClosingTag, - ) + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + removeClosingTag: mockRemoveClosingTag as unknown as RemoveClosingTag, + toolProtocol: "xml", + }) // Verify expect(mockCline.consecutiveMistakeCount).toBe(1) expect(mockCline.sayAndCreateMissingParamError).toHaveBeenCalledWith("execute_command", "command") expect(mockPushToolResult).toHaveBeenCalledWith("Missing parameter error") expect(mockAskApproval).not.toHaveBeenCalled() - expect(mockExecuteCommand).not.toHaveBeenCalled() + expect(executeCommandModule.executeCommandInTerminal).not.toHaveBeenCalled() }) it("should handle command rejection", async () => { @@ -225,18 +209,17 @@ describe("executeCommandTool", () => { mockAskApproval.mockResolvedValue(false) // Execute - await executeCommandTool( - mockCline as unknown as Task, - mockToolUse, - mockAskApproval as unknown as AskApproval, - mockHandleError as unknown as HandleError, - mockPushToolResult as unknown as PushToolResult, - mockRemoveClosingTag as unknown as RemoveClosingTag, - ) + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + removeClosingTag: mockRemoveClosingTag as unknown as RemoveClosingTag, + toolProtocol: "xml", + }) // Verify expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test") - expect(mockExecuteCommand).not.toHaveBeenCalled() + // executeCommandInTerminal should not be called since approval was denied expect(mockPushToolResult).not.toHaveBeenCalled() }) @@ -251,26 +234,23 @@ describe("executeCommandTool", () => { const mockRooIgnoreError = "RooIgnore error" ;(formatResponse.rooIgnoreError as any).mockReturnValue(mockRooIgnoreError) - ;(formatResponse.toolError as any).mockReturnValue("Tool error") // Execute - await executeCommandTool( - mockCline as unknown as Task, - mockToolUse, - mockAskApproval as unknown as AskApproval, - mockHandleError as unknown as HandleError, - mockPushToolResult as unknown as PushToolResult, - mockRemoveClosingTag as unknown as RemoveClosingTag, - ) + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + removeClosingTag: mockRemoveClosingTag as unknown as RemoveClosingTag, + toolProtocol: "xml", + }) // Verify expect(validateCommandMock).toHaveBeenCalledWith("cat .env") expect(mockCline.say).toHaveBeenCalledWith("rooignore_error", ".env") - expect(formatResponse.rooIgnoreError).toHaveBeenCalledWith(".env") - expect(formatResponse.toolError).toHaveBeenCalledWith(mockRooIgnoreError) - expect(mockPushToolResult).toHaveBeenCalled() + expect(formatResponse.rooIgnoreError).toHaveBeenCalledWith(".env", "xml") + expect(mockPushToolResult).toHaveBeenCalledWith(mockRooIgnoreError) expect(mockAskApproval).not.toHaveBeenCalled() - expect(mockExecuteCommand).not.toHaveBeenCalled() + // executeCommandInTerminal should not be called since rooignore blocked it }) }) @@ -292,7 +272,7 @@ describe("executeCommandTool", () => { }) it("should handle timeout parameter in function signature", () => { - // Test that the executeCommand function accepts timeout parameter + // Test that the executeCommandInTerminal function accepts timeout parameter // This is a compile-time check that the types are correct const mockOptions = { executionId: "test-id", diff --git a/src/core/tools/__tests__/generateImageTool.test.ts b/src/core/tools/__tests__/generateImageTool.test.ts index 940490ecfb..483533e34d 100644 --- a/src/core/tools/__tests__/generateImageTool.test.ts +++ b/src/core/tools/__tests__/generateImageTool.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest" -import { generateImageTool } from "../generateImageTool" +import { generateImageTool } from "../GenerateImageTool" import { ToolUse } from "../../../shared/tools" import { Task } from "../../task/Task" import * as fs from "fs/promises" @@ -82,14 +82,13 @@ describe("generateImageTool", () => { partial: true, } - await generateImageTool( - mockCline as Task, - partialBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await generateImageTool.handle(mockCline as Task, partialBlock as ToolUse<"generate_image">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Should not process anything when partial expect(mockAskApproval).not.toHaveBeenCalled() @@ -109,14 +108,13 @@ describe("generateImageTool", () => { partial: true, } - await generateImageTool( - mockCline as Task, - partialBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await generateImageTool.handle(mockCline as Task, partialBlock as ToolUse<"generate_image">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Should not process anything when partial expect(mockAskApproval).not.toHaveBeenCalled() @@ -149,14 +147,13 @@ describe("generateImageTool", () => { }) as any, ) - await generateImageTool( - mockCline as Task, - completeBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await generateImageTool.handle(mockCline as Task, completeBlock as ToolUse<"generate_image">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Should process the complete block expect(mockAskApproval).toHaveBeenCalled() @@ -192,14 +189,13 @@ describe("generateImageTool", () => { }) as any, ) - await generateImageTool( - mockCline as Task, - completeBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await generateImageTool.handle(mockCline as Task, completeBlock as ToolUse<"generate_image">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Check that cline.say was called with image data containing cache-busting parameter expect(mockCline.say).toHaveBeenCalledWith("image", expect.stringMatching(/"imageUri":"[^"]+\?t=\d+"/)) @@ -230,14 +226,13 @@ describe("generateImageTool", () => { partial: false, } - await generateImageTool( - mockCline as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await generateImageTool.handle(mockCline as Task, block as ToolUse<"generate_image">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockCline.consecutiveMistakeCount).toBe(1) expect(mockCline.recordToolError).toHaveBeenCalledWith("generate_image") @@ -255,14 +250,13 @@ describe("generateImageTool", () => { partial: false, } - await generateImageTool( - mockCline as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await generateImageTool.handle(mockCline as Task, block as ToolUse<"generate_image">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockCline.consecutiveMistakeCount).toBe(1) expect(mockCline.recordToolError).toHaveBeenCalledWith("generate_image") @@ -290,14 +284,13 @@ describe("generateImageTool", () => { partial: false, } - await generateImageTool( - mockCline as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await generateImageTool.handle(mockCline as Task, block as ToolUse<"generate_image">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockPushToolResult).toHaveBeenCalledWith( formatResponse.toolError( @@ -322,14 +315,13 @@ describe("generateImageTool", () => { partial: false, } - await generateImageTool( - mockCline as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await generateImageTool.handle(mockCline as Task, block as ToolUse<"generate_image">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("Input image not found")) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Input image not found")) @@ -347,14 +339,13 @@ describe("generateImageTool", () => { partial: false, } - await generateImageTool( - mockCline as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await generateImageTool.handle(mockCline as Task, block as ToolUse<"generate_image">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("Unsupported image format")) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Unsupported image format")) diff --git a/src/core/tools/__tests__/insertContentTool.spec.ts b/src/core/tools/__tests__/insertContentTool.spec.ts index 5f055fb29a..aa1a8f2f3a 100644 --- a/src/core/tools/__tests__/insertContentTool.spec.ts +++ b/src/core/tools/__tests__/insertContentTool.spec.ts @@ -4,7 +4,7 @@ import type { MockedFunction } from "vitest" import { fileExistsAtPath } from "../../../utils/fs" import { ToolUse, ToolResponse } from "../../../shared/tools" -import { insertContentTool } from "../insertContentTool" +import { insertContentTool } from "../InsertContentTool" // Helper to normalize paths to POSIX format for cross-platform testing const toPosix = (filePath: string) => filePath.replace(/\\/g, "/") @@ -154,16 +154,15 @@ describe("insertContentTool", () => { partial: isPartial, } - await insertContentTool( - mockCline, - toolUse, - mockAskApproval, - mockHandleError, - (result: ToolResponse) => { + await insertContentTool.handle(mockCline, toolUse as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: (result: ToolResponse) => { toolResult = result }, - mockRemoveClosingTag, - ) + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) return toolResult } diff --git a/src/core/tools/__tests__/listCodeDefinitionNamesTool.spec.ts b/src/core/tools/__tests__/listCodeDefinitionNamesTool.spec.ts index 7a26c2f8ee..1e9c61e11c 100644 --- a/src/core/tools/__tests__/listCodeDefinitionNamesTool.spec.ts +++ b/src/core/tools/__tests__/listCodeDefinitionNamesTool.spec.ts @@ -1,7 +1,7 @@ // npx vitest src/core/tools/__tests__/listCodeDefinitionNamesTool.spec.ts import { describe, it, expect, vi, beforeEach } from "vitest" -import { listCodeDefinitionNamesTool } from "../listCodeDefinitionNamesTool" +import { listCodeDefinitionNamesTool } from "../ListCodeDefinitionNamesTool" import { Task } from "../../task/Task" import { ToolUse } from "../../../shared/tools" import * as treeSitter from "../../../services/tree-sitter" @@ -80,14 +80,13 @@ describe("listCodeDefinitionNamesTool", () => { partial: false, } - await listCodeDefinitionNamesTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await listCodeDefinitionNamesTool.handle(mockTask as Task, block as ToolUse<"list_code_definition_names">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockPushToolResult).toHaveBeenCalledWith(mockDefinitions) }) @@ -118,14 +117,13 @@ describe("listCodeDefinitionNamesTool", () => { partial: false, } - await listCodeDefinitionNamesTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await listCodeDefinitionNamesTool.handle(mockTask as Task, block as ToolUse<"list_code_definition_names">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockPushToolResult).toHaveBeenCalledWith(mockDefinitions) }) @@ -156,14 +154,13 @@ describe("listCodeDefinitionNamesTool", () => { partial: false, } - await listCodeDefinitionNamesTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await listCodeDefinitionNamesTool.handle(mockTask as Task, block as ToolUse<"list_code_definition_names">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Should only include definitions starting at or before line 25 const expectedResult = `# test.ts @@ -197,14 +194,13 @@ describe("listCodeDefinitionNamesTool", () => { partial: false, } - await listCodeDefinitionNamesTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await listCodeDefinitionNamesTool.handle(mockTask as Task, block as ToolUse<"list_code_definition_names">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Should include foo (starts at 10) but not bar (starts at 60) const expectedResult = `# test.ts @@ -239,14 +235,13 @@ describe("listCodeDefinitionNamesTool", () => { partial: false, } - await listCodeDefinitionNamesTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await listCodeDefinitionNamesTool.handle(mockTask as Task, block as ToolUse<"list_code_definition_names">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Should include foo and bar but not baz const expectedResult = `# test.ts @@ -280,14 +275,13 @@ describe("listCodeDefinitionNamesTool", () => { partial: false, } - await listCodeDefinitionNamesTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await listCodeDefinitionNamesTool.handle(mockTask as Task, block as ToolUse<"list_code_definition_names">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Should keep header but exclude all definitions beyond line 50 const expectedResult = `# test.ts` @@ -306,14 +300,13 @@ describe("listCodeDefinitionNamesTool", () => { mockTask.sayAndCreateMissingParamError = vi.fn(async () => "Missing parameter: path") - await listCodeDefinitionNamesTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await listCodeDefinitionNamesTool.handle(mockTask as Task, block as ToolUse<"list_code_definition_names">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockTask.consecutiveMistakeCount).toBe(1) expect(mockTask.recordToolError).toHaveBeenCalledWith("list_code_definition_names") @@ -337,14 +330,13 @@ describe("listCodeDefinitionNamesTool", () => { partial: false, } - await listCodeDefinitionNamesTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await listCodeDefinitionNamesTool.handle(mockTask as Task, block as ToolUse<"list_code_definition_names">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockPushToolResult).toHaveBeenCalledWith(mockDefinitions) }) diff --git a/src/core/tools/__tests__/multiApplyDiffTool.spec.ts b/src/core/tools/__tests__/multiApplyDiffTool.spec.ts index 5e591f9fe7..5a9a1af1da 100644 --- a/src/core/tools/__tests__/multiApplyDiffTool.spec.ts +++ b/src/core/tools/__tests__/multiApplyDiffTool.spec.ts @@ -1,4 +1,4 @@ -import { applyDiffTool } from "../multiApplyDiffTool" +import { applyDiffTool } from "../MultiApplyDiffTool" import { EXPERIMENT_IDS } from "../../../shared/experiments" import * as fs from "fs/promises" import * as fileUtils from "../../../utils/fs" @@ -78,8 +78,19 @@ describe("multiApplyDiffTool", () => { saveChanges: vi.fn().mockResolvedValue(undefined), pushToolWriteResult: vi.fn().mockResolvedValue("File modified successfully"), }, + apiConfiguration: { + apiProvider: "anthropic", + }, api: { - getModel: vi.fn().mockReturnValue({ id: "test-model" }), + getModel: vi.fn().mockReturnValue({ + id: "test-model", + info: { + maxTokens: 4096, + contextWindow: 128000, + supportsPromptCache: false, + supportsNativeTools: false, + }, + }), }, rooIgnoreController: { validateAccess: vi.fn().mockReturnValue(true), diff --git a/src/core/tools/__tests__/newTaskTool.spec.ts b/src/core/tools/__tests__/newTaskTool.spec.ts index a95efcd94f..43ba3ea9d3 100644 --- a/src/core/tools/__tests__/newTaskTool.spec.ts +++ b/src/core/tools/__tests__/newTaskTool.spec.ts @@ -97,8 +97,8 @@ const mockCline = { }, } -// Import the function to test AFTER mocks are set up -import { newTaskTool } from "../newTaskTool" +// Import the class to test AFTER mocks are set up +import { newTaskTool } from "../NewTaskTool" import type { ToolUse } from "../../../shared/tools" import { getModeBySlug } from "../../../shared/modes" import * as vscode from "vscode" @@ -135,14 +135,13 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, // Use 'as any' for simplicity in mocking complex type - block, - mockAskApproval, // Now correctly typed - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Verify askApproval was called expect(mockAskApproval).toHaveBeenCalled() @@ -173,14 +172,13 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, // Now correctly typed - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockStartSubtask).toHaveBeenCalledWith( "This is already unescaped: \\@file1.txt", // Expected: \@ remains \@ @@ -201,14 +199,13 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, // Now correctly typed - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockStartSubtask).toHaveBeenCalledWith( "A normal mention @file1.txt", // Expected: @ remains @ @@ -229,14 +226,13 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, // Now correctly typed - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockStartSubtask).toHaveBeenCalledWith( "Mix: @file0.txt, \\@file1.txt, \\@file2.txt, \\\\\\@file3.txt", // Unit Test Expectation: @->@, \@->\@, \\@->\@, \\\\@->\\\\@ @@ -257,14 +253,13 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Should NOT error when todos is missing expect(mockSayAndCreateMissingParamError).not.toHaveBeenCalledWith("new_task", "todos") @@ -290,14 +285,13 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Should parse and include todos when provided expect(mockStartSubtask).toHaveBeenCalledWith( @@ -324,14 +318,13 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockSayAndCreateMissingParamError).toHaveBeenCalledWith("new_task", "mode") expect(mockCline.consecutiveMistakeCount).toBe(1) @@ -350,14 +343,13 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockSayAndCreateMissingParamError).toHaveBeenCalledWith("new_task", "message") expect(mockCline.consecutiveMistakeCount).toBe(1) @@ -376,14 +368,13 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockStartSubtask).toHaveBeenCalledWith( "Test message", @@ -415,14 +406,13 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Should NOT error when todos is missing and setting is disabled expect(mockSayAndCreateMissingParamError).not.toHaveBeenCalledWith("new_task", "todos") @@ -454,14 +444,13 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Should error when todos is missing and setting is enabled expect(mockSayAndCreateMissingParamError).toHaveBeenCalledWith("new_task", "todos") @@ -493,14 +482,13 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Should NOT error when todos is provided and setting is enabled expect(mockSayAndCreateMissingParamError).not.toHaveBeenCalledWith("new_task", "todos") @@ -538,14 +526,13 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Should NOT error when todos is empty string and setting is enabled expect(mockSayAndCreateMissingParamError).not.toHaveBeenCalledWith("new_task", "todos") @@ -575,14 +562,13 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Verify that VSCode configuration was accessed with Package.name expect(mockGetConfiguration).toHaveBeenCalledWith("roo-cline") @@ -611,14 +597,13 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Assert: configuration was read using the dynamic nightly namespace expect(mockGetConfiguration).toHaveBeenCalledWith("roo-code-nightly") diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index d693d6ba44..11f533e54f 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -8,7 +8,7 @@ import { extractTextFromFile } from "../../../integrations/misc/extract-text" import { parseSourceCodeDefinitionsForFile } from "../../../services/tree-sitter" import { isBinaryFile } from "isbinaryfile" import { ReadFileToolUse, ToolParamName, ToolResponse } from "../../../shared/tools" -import { readFileTool } from "../readFileTool" +import { readFileTool } from "../ReadFileTool" import { formatResponse } from "../../prompts/responses" import { DEFAULT_MAX_IMAGE_FILE_SIZE_MB, DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB } from "../helpers/imageHelpers" @@ -204,10 +204,19 @@ function createMockCline(): any { getTokenUsage: vi.fn().mockReturnValue({ contextTokens: 10000, }), + apiConfiguration: { + apiProvider: "anthropic", + }, // CRITICAL: Always ensure image support is enabled api: { getModel: vi.fn().mockReturnValue({ - info: { supportsImages: true, contextWindow: 200000 }, + info: { + supportsImages: true, + contextWindow: 200000, + maxTokens: 4096, + supportsPromptCache: false, + supportsNativeTools: false, + }, }), }, } @@ -320,16 +329,15 @@ describe("read_file tool with maxReadFileLine setting", () => { partial: false, } - await readFileTool( - mockCline, - toolUse, - mockCline.ask, - vi.fn(), - (result: ToolResponse) => { + await readFileTool.handle(mockCline, toolUse, { + askApproval: mockCline.ask, + handleError: vi.fn(), + pushToolResult: (result: ToolResponse) => { toolResult = result }, - (_: ToolParamName, content?: string) => content ?? "", - ) + removeClosingTag: (_: ToolParamName, content?: string) => content ?? "", + toolProtocol: "xml", + }) return toolResult } @@ -631,16 +639,15 @@ describe("read_file tool XML output structure", () => { } // Execute the tool - await readFileTool( - mockCline, - toolUse, - mockCline.ask, - vi.fn(), - (result: ToolResponse) => { + await readFileTool.handle(mockCline, toolUse, { + askApproval: mockCline.ask, + handleError: vi.fn(), + pushToolResult: (result: ToolResponse) => { toolResult = result }, - (param: ToolParamName, content?: string) => content ?? "", - ) + removeClosingTag: (param: ToolParamName, content?: string) => content ?? "", + toolProtocol: "xml", + }) return toolResult } @@ -737,16 +744,15 @@ describe("read_file tool XML output structure", () => { } let localResult: ToolResponse | undefined - await readFileTool( - mockCline, - toolUse, - mockCline.ask, - vi.fn(), - (result: ToolResponse) => { + await readFileTool.handle(mockCline, toolUse, { + askApproval: mockCline.ask, + handleError: vi.fn(), + pushToolResult: (result: ToolResponse) => { localResult = result }, - (_: ToolParamName, content?: string) => content ?? "", - ) + removeClosingTag: (_: ToolParamName, content?: string) => content ?? "", + toolProtocol: "xml", + }) // In multi-image scenarios, the result is pushed to pushToolResult, not returned directly. // We need to check the mock's calls to get the result. if (mockCline.pushToolResult.mock.calls.length > 0) { @@ -1359,16 +1365,15 @@ describe("read_file tool XML output structure", () => { } // Execute the tool - await readFileTool( - mockCline, - toolUse, - mockCline.ask, - vi.fn(), - (result: ToolResponse) => { + await readFileTool.handle(mockCline, toolUse, { + askApproval: mockCline.ask, + handleError: vi.fn(), + pushToolResult: (result: ToolResponse) => { toolResult = result }, - (param: ToolParamName, content?: string) => content ?? "", - ) + removeClosingTag: (param: ToolParamName, content?: string) => content ?? "", + toolProtocol: "xml", + }) // Verify expect(toolResult).toBe(`Missing required parameter`) @@ -1448,16 +1453,15 @@ describe("read_file tool with image support", () => { console.log("Mock API:", localMockCline.api) console.log("Supports images:", localMockCline.api?.getModel?.()?.info?.supportsImages) - await readFileTool( - localMockCline, - toolUse, - localMockCline.ask, - vi.fn(), - (result: ToolResponse) => { + await readFileTool.handle(localMockCline, toolUse, { + askApproval: localMockCline.ask, + handleError: vi.fn(), + pushToolResult: (result: ToolResponse) => { toolResult = result }, - (_: ToolParamName, content?: string) => content ?? "", - ) + removeClosingTag: (_: ToolParamName, content?: string) => content ?? "", + toolProtocol: "xml", + }) console.log("Result type:", Array.isArray(toolResult) ? "array" : typeof toolResult) console.log("Result:", toolResult) @@ -1612,10 +1616,7 @@ describe("read_file tool with image support", () => { // Setup - simulate read error mockedFsReadFile.mockRejectedValue(new Error("Failed to read image")) - // Create a spy for handleError - const handleErrorSpy = vi.fn() - - // Execute with the spy + // Execute const argsContent = `${testImagePath}` const toolUse: ReadFileToolUse = { type: "tool_use", @@ -1624,20 +1625,20 @@ describe("read_file tool with image support", () => { partial: false, } - await readFileTool( - localMockCline, - toolUse, - localMockCline.ask, - handleErrorSpy, // Use our spy here - (result: ToolResponse) => { + await readFileTool.handle(localMockCline, toolUse, { + askApproval: localMockCline.ask, + handleError: vi.fn(), + pushToolResult: (result: ToolResponse) => { toolResult = result }, - (_: ToolParamName, content?: string) => content ?? "", - ) + removeClosingTag: (_: ToolParamName, content?: string) => content ?? "", + toolProtocol: "xml", + }) // Verify error handling expect(toolResult).toContain("Error reading image file: Failed to read image") - expect(handleErrorSpy).toHaveBeenCalled() + // Verify that say was called to show error to user + expect(localMockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("Failed to read image")) }) }) diff --git a/src/core/tools/__tests__/runSlashCommandTool.spec.ts b/src/core/tools/__tests__/runSlashCommandTool.spec.ts index 07143e96cc..e3c8180e38 100644 --- a/src/core/tools/__tests__/runSlashCommandTool.spec.ts +++ b/src/core/tools/__tests__/runSlashCommandTool.spec.ts @@ -1,8 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest" -import { runSlashCommandTool } from "../runSlashCommandTool" +import { runSlashCommandTool } from "../RunSlashCommandTool" import { Task } from "../../task/Task" import { formatResponse } from "../../prompts/responses" import { getCommand, getCommandNames } from "../../../services/command/commands" +import type { ToolUse } from "../../../shared/tools" // Mock dependencies vi.mock("../../../services/command/commands", () => ({ @@ -12,10 +13,7 @@ vi.mock("../../../services/command/commands", () => ({ describe("runSlashCommandTool", () => { let mockTask: any - let mockAskApproval: any - let mockHandleError: any - let mockPushToolResult: any - let mockRemoveClosingTag: any + let mockCallbacks: any beforeEach(() => { vi.clearAllMocks() @@ -24,7 +22,7 @@ describe("runSlashCommandTool", () => { consecutiveMistakeCount: 0, recordToolError: vi.fn(), sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing parameter error"), - ask: vi.fn(), + ask: vi.fn().mockResolvedValue({}), cwd: "/test/project", providerRef: { deref: vi.fn().mockReturnValue({ @@ -37,37 +35,32 @@ describe("runSlashCommandTool", () => { }, } - mockAskApproval = vi.fn().mockResolvedValue(true) - mockHandleError = vi.fn() - mockPushToolResult = vi.fn() - mockRemoveClosingTag = vi.fn((tag, text) => text || "") + mockCallbacks = { + askApproval: vi.fn().mockResolvedValue(true), + handleError: vi.fn(), + pushToolResult: vi.fn(), + removeClosingTag: vi.fn((tag, text) => text || ""), + } }) it("should handle missing command parameter", async () => { - const block = { + const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, params: {}, partial: false, } - await runSlashCommandTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) expect(mockTask.consecutiveMistakeCount).toBe(1) expect(mockTask.recordToolError).toHaveBeenCalledWith("run_slash_command") expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("run_slash_command", "command") - expect(mockPushToolResult).toHaveBeenCalledWith("Missing parameter error") + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith("Missing parameter error") }) it("should handle command not found", async () => { - const block = { + const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, params: { @@ -79,23 +72,16 @@ describe("runSlashCommandTool", () => { vi.mocked(getCommand).mockResolvedValue(undefined) vi.mocked(getCommandNames).mockResolvedValue(["init", "test", "deploy"]) - await runSlashCommandTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) expect(mockTask.recordToolError).toHaveBeenCalledWith("run_slash_command") - expect(mockPushToolResult).toHaveBeenCalledWith( + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( formatResponse.toolError("Command 'nonexistent' not found. Available commands: init, test, deploy"), ) }) it("should handle user rejection", async () => { - const block = { + const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, params: { @@ -113,23 +99,16 @@ describe("runSlashCommandTool", () => { } vi.mocked(getCommand).mockResolvedValue(mockCommand) - mockAskApproval.mockResolvedValue(false) + mockCallbacks.askApproval.mockResolvedValue(false) - await runSlashCommandTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) - expect(mockAskApproval).toHaveBeenCalled() - expect(mockPushToolResult).not.toHaveBeenCalled() + expect(mockCallbacks.askApproval).toHaveBeenCalled() + expect(mockCallbacks.pushToolResult).not.toHaveBeenCalled() }) it("should successfully execute built-in command", async () => { - const block = { + const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, params: { @@ -148,16 +127,9 @@ describe("runSlashCommandTool", () => { vi.mocked(getCommand).mockResolvedValue(mockCommand) - await runSlashCommandTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) - expect(mockAskApproval).toHaveBeenCalledWith( + expect(mockCallbacks.askApproval).toHaveBeenCalledWith( "tool", JSON.stringify({ tool: "runSlashCommand", @@ -168,7 +140,7 @@ describe("runSlashCommandTool", () => { }), ) - expect(mockPushToolResult).toHaveBeenCalledWith( + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( `Command: /init Description: Analyze codebase and create AGENTS.md Source: built-in @@ -180,7 +152,7 @@ Initialize project content here`, }) it("should successfully execute command with arguments", async () => { - const block = { + const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, params: { @@ -201,16 +173,9 @@ Initialize project content here`, vi.mocked(getCommand).mockResolvedValue(mockCommand) - await runSlashCommandTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) - expect(mockPushToolResult).toHaveBeenCalledWith( + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( `Command: /test Description: Run project tests Argument hint: test type or focus area @@ -224,7 +189,7 @@ Run tests with specific focus`, }) it("should handle global command", async () => { - const block = { + const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, params: { @@ -242,16 +207,9 @@ Run tests with specific focus`, vi.mocked(getCommand).mockResolvedValue(mockCommand) - await runSlashCommandTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) - expect(mockPushToolResult).toHaveBeenCalledWith( + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( `Command: /deploy Source: global @@ -262,7 +220,7 @@ Deploy application to production`, }) it("should handle partial block", async () => { - const block = { + const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, params: { @@ -271,14 +229,7 @@ Deploy application to production`, partial: true, } - await runSlashCommandTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) expect(mockTask.ask).toHaveBeenCalledWith( "tool", @@ -290,11 +241,11 @@ Deploy application to production`, true, ) - expect(mockPushToolResult).not.toHaveBeenCalled() + expect(mockCallbacks.pushToolResult).not.toHaveBeenCalled() }) it("should handle errors during execution", async () => { - const block = { + const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, params: { @@ -306,20 +257,13 @@ Deploy application to production`, const error = new Error("Test error") vi.mocked(getCommand).mockRejectedValue(error) - await runSlashCommandTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) - expect(mockHandleError).toHaveBeenCalledWith("running slash command", error) + expect(mockCallbacks.handleError).toHaveBeenCalledWith("running slash command", error) }) it("should handle empty available commands list", async () => { - const block = { + const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, params: { @@ -331,22 +275,15 @@ Deploy application to production`, vi.mocked(getCommand).mockResolvedValue(undefined) vi.mocked(getCommandNames).mockResolvedValue([]) - await runSlashCommandTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) - expect(mockPushToolResult).toHaveBeenCalledWith( + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( formatResponse.toolError("Command 'nonexistent' not found. Available commands: (none)"), ) }) it("should reset consecutive mistake count on valid command", async () => { - const block = { + const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, params: { @@ -366,14 +303,7 @@ Deploy application to production`, vi.mocked(getCommand).mockResolvedValue(mockCommand) - await runSlashCommandTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) expect(mockTask.consecutiveMistakeCount).toBe(0) }) diff --git a/src/core/tools/__tests__/updateTodoListTool.spec.ts b/src/core/tools/__tests__/updateTodoListTool.spec.ts index 0b7e810572..ebe0500d66 100644 --- a/src/core/tools/__tests__/updateTodoListTool.spec.ts +++ b/src/core/tools/__tests__/updateTodoListTool.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from "vitest" -import { parseMarkdownChecklist } from "../updateTodoListTool" +import { parseMarkdownChecklist } from "../UpdateTodoListTool" import { TodoItem } from "@roo-code/types" describe("parseMarkdownChecklist", () => { diff --git a/src/core/tools/__tests__/useMcpToolTool.spec.ts b/src/core/tools/__tests__/useMcpToolTool.spec.ts index 8738e059e5..130047ae15 100644 --- a/src/core/tools/__tests__/useMcpToolTool.spec.ts +++ b/src/core/tools/__tests__/useMcpToolTool.spec.ts @@ -1,6 +1,6 @@ // npx vitest core/tools/__tests__/useMcpToolTool.spec.ts -import { useMcpToolTool } from "../useMcpToolTool" +import { useMcpToolTool } from "../UseMcpToolTool" import { Task } from "../../task/Task" import { ToolUse } from "../../../shared/tools" @@ -85,14 +85,13 @@ describe("useMcpToolTool", () => { mockTask.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing server_name error") - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockTask.consecutiveMistakeCount).toBe(1) expect(mockTask.recordToolError).toHaveBeenCalledWith("use_mcp_tool") @@ -113,14 +112,13 @@ describe("useMcpToolTool", () => { mockTask.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing tool_name error") - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockTask.consecutiveMistakeCount).toBe(1) expect(mockTask.recordToolError).toHaveBeenCalledWith("use_mcp_tool") @@ -140,14 +138,29 @@ describe("useMcpToolTool", () => { partial: false, } - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + // Mock server exists so we get to the JSON validation step + const mockServers = [ + { + name: "test_server", + tools: [{ name: "test_tool", description: "Test Tool" }], + }, + ] + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + getAllServers: vi.fn().mockReturnValue(mockServers), + callTool: vi.fn(), + }), + postMessageToWebview: vi.fn(), + }) + + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockTask.consecutiveMistakeCount).toBe(1) expect(mockTask.recordToolError).toHaveBeenCalledWith("use_mcp_tool") @@ -171,14 +184,13 @@ describe("useMcpToolTool", () => { mockTask.ask = vi.fn().mockResolvedValue(true) - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockTask.ask).toHaveBeenCalledWith("use_mcp_server", expect.stringContaining("use_mcp_tool"), true) }) @@ -211,14 +223,13 @@ describe("useMcpToolTool", () => { postMessageToWebview: vi.fn(), }) - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockAskApproval).toHaveBeenCalled() @@ -245,14 +256,13 @@ describe("useMcpToolTool", () => { mockAskApproval.mockResolvedValue(false) - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockTask.say).not.toHaveBeenCalledWith("mcp_server_request_started") expect(mockPushToolResult).not.toHaveBeenCalled() @@ -287,14 +297,13 @@ describe("useMcpToolTool", () => { const error = new Error("Unexpected error") mockAskApproval.mockRejectedValue(error) - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockHandleError).toHaveBeenCalledWith("executing MCP tool", error) }) @@ -332,14 +341,13 @@ describe("useMcpToolTool", () => { partial: false, } - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockTask.consecutiveMistakeCount).toBe(1) expect(mockTask.recordToolError).toHaveBeenCalledWith("use_mcp_tool") @@ -379,14 +387,13 @@ describe("useMcpToolTool", () => { partial: false, } - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockTask.consecutiveMistakeCount).toBe(1) expect(mockTask.recordToolError).toHaveBeenCalledWith("use_mcp_tool") @@ -430,14 +437,13 @@ describe("useMcpToolTool", () => { mockAskApproval.mockResolvedValue(true) - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockTask.recordToolError).not.toHaveBeenCalled() @@ -472,14 +478,13 @@ describe("useMcpToolTool", () => { } // Act - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Assert expect(mockTask.consecutiveMistakeCount).toBe(1) @@ -515,14 +520,13 @@ describe("useMcpToolTool", () => { } // Act - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) // Assert expect(mockTask.consecutiveMistakeCount).toBe(1) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 78e60cbaa5..714269795e 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -9,7 +9,7 @@ import { getReadablePath } from "../../../utils/path" import { unescapeHtmlEntities } from "../../../utils/text-normalization" import { everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text" import { ToolUse, ToolResponse } from "../../../shared/tools" -import { writeToFileTool } from "../writeToFileTool" +import { writeToFileTool } from "../WriteToFileTool" vi.mock("path", async () => { const originalPath = await vi.importActual("path") @@ -228,16 +228,17 @@ describe("writeToFileTool", () => { partial: isPartial, } - await writeToFileTool( - mockCline, - toolUse, - mockAskApproval, - mockHandleError, - (result: ToolResponse) => { - toolResult = result - }, - mockRemoveClosingTag, - ) + mockPushToolResult = vi.fn((result: ToolResponse) => { + toolResult = result + }) + + await writeToFileTool.handle(mockCline, toolUse as ToolUse<"write_to_file">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "xml", + }) return toolResult } @@ -412,8 +413,7 @@ describe("writeToFileTool", () => { await executeWriteFileTool({}, { isPartial: true }) - expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) - expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockHandleError).toHaveBeenCalledWith("handling partial write_to_file", expect.any(Error)) }) }) }) diff --git a/src/core/tools/askFollowupQuestionTool.ts b/src/core/tools/askFollowupQuestionTool.ts deleted file mode 100644 index e736936887..0000000000 --- a/src/core/tools/askFollowupQuestionTool.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Task } from "../task/Task" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" -import { formatResponse } from "../prompts/responses" -import { parseXml } from "../../utils/xml" - -export async function askFollowupQuestionTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const question: string | undefined = block.params.question - const follow_up: string | undefined = block.params.follow_up - - try { - if (block.partial) { - await cline.ask("followup", removeClosingTag("question", question), block.partial).catch(() => {}) - return - } else { - if (!question) { - cline.consecutiveMistakeCount++ - cline.recordToolError("ask_followup_question") - pushToolResult(await cline.sayAndCreateMissingParamError("ask_followup_question", "question")) - return - } - - type Suggest = { answer: string; mode?: string } - - let follow_up_json = { - question, - suggest: [] as Suggest[], - } - - if (follow_up) { - // Define the actual structure returned by the XML parser - type ParsedSuggestion = string | { "#text": string; "@_mode"?: string } - - let parsedSuggest: { - suggest: ParsedSuggestion[] | ParsedSuggestion - } - - try { - parsedSuggest = parseXml(follow_up, ["suggest"]) as { - suggest: ParsedSuggestion[] | ParsedSuggestion - } - } catch (error) { - cline.consecutiveMistakeCount++ - cline.recordToolError("ask_followup_question") - await cline.say("error", `Failed to parse operations: ${error.message}`) - pushToolResult(formatResponse.toolError("Invalid operations xml format")) - return - } - - const rawSuggestions = Array.isArray(parsedSuggest?.suggest) - ? parsedSuggest.suggest - : [parsedSuggest?.suggest].filter((sug): sug is ParsedSuggestion => sug !== undefined) - - // Transform parsed XML to our Suggest format - const normalizedSuggest: Suggest[] = rawSuggestions.map((sug) => { - if (typeof sug === "string") { - // Simple string suggestion (no mode attribute) - return { answer: sug } - } else { - // XML object with text content and optional mode attribute - const result: Suggest = { answer: sug["#text"] } - if (sug["@_mode"]) { - result.mode = sug["@_mode"] - } - return result - } - }) - - follow_up_json.suggest = normalizedSuggest - } - - cline.consecutiveMistakeCount = 0 - const { text, images } = await cline.ask("followup", JSON.stringify(follow_up_json), false) - await cline.say("user_feedback", text ?? "", images) - pushToolResult(formatResponse.toolResult(`\n${text}\n`, images)) - - return - } - } catch (error) { - await handleError("asking question", error) - return - } -} diff --git a/src/core/tools/attemptCompletionTool.ts b/src/core/tools/attemptCompletionTool.ts deleted file mode 100644 index 5074d7f4e8..0000000000 --- a/src/core/tools/attemptCompletionTool.ts +++ /dev/null @@ -1,141 +0,0 @@ -import Anthropic from "@anthropic-ai/sdk" -import * as vscode from "vscode" - -import { RooCodeEventName } from "@roo-code/types" -import { TelemetryService } from "@roo-code/telemetry" - -import { Task } from "../task/Task" -import { - ToolResponse, - ToolUse, - AskApproval, - HandleError, - PushToolResult, - RemoveClosingTag, - ToolDescription, - AskFinishSubTaskApproval, -} from "../../shared/tools" -import { formatResponse } from "../prompts/responses" -import { Package } from "../../shared/package" - -export async function attemptCompletionTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, - toolDescription: ToolDescription, - askFinishSubTaskApproval: AskFinishSubTaskApproval, -) { - const result: string | undefined = block.params.result - const command: string | undefined = block.params.command - - // Get the setting for preventing completion with open todos from VSCode configuration - const preventCompletionWithOpenTodos = vscode.workspace - .getConfiguration(Package.name) - .get("preventCompletionWithOpenTodos", false) - - // Check if there are incomplete todos (only if the setting is enabled) - const hasIncompleteTodos = cline.todoList && cline.todoList.some((todo) => todo.status !== "completed") - - if (preventCompletionWithOpenTodos && hasIncompleteTodos) { - cline.consecutiveMistakeCount++ - cline.recordToolError("attempt_completion") - - pushToolResult( - formatResponse.toolError( - "Cannot complete task while there are incomplete todos. Please finish all todos before attempting completion.", - ), - ) - - return - } - - try { - const lastMessage = cline.clineMessages.at(-1) - - if (block.partial) { - if (command) { - // the attempt_completion text is done, now we're getting command - // remove the previous partial attempt_completion ask, replace with say, post state to webview, then stream command - - // const secondLastMessage = cline.clineMessages.at(-2) - if (lastMessage && lastMessage.ask === "command") { - // update command - await cline.ask("command", removeClosingTag("command", command), block.partial).catch(() => {}) - } else { - // last message is completion_result - // we have command string, which means we have the result as well, so finish it (doesnt have to exist yet) - await cline.say("completion_result", removeClosingTag("result", result), undefined, false) - - TelemetryService.instance.captureTaskCompleted(cline.taskId) - cline.emit(RooCodeEventName.TaskCompleted, cline.taskId, cline.getTokenUsage(), cline.toolUsage) - - await cline.ask("command", removeClosingTag("command", command), block.partial).catch(() => {}) - } - } else { - // No command, still outputting partial result - await cline.say("completion_result", removeClosingTag("result", result), undefined, block.partial) - } - return - } else { - if (!result) { - cline.consecutiveMistakeCount++ - cline.recordToolError("attempt_completion") - pushToolResult(await cline.sayAndCreateMissingParamError("attempt_completion", "result")) - return - } - - cline.consecutiveMistakeCount = 0 - - // Command execution is permanently disabled in attempt_completion - // Users must use execute_command tool separately before attempt_completion - await cline.say("completion_result", result, undefined, false) - TelemetryService.instance.captureTaskCompleted(cline.taskId) - cline.emit(RooCodeEventName.TaskCompleted, cline.taskId, cline.getTokenUsage(), cline.toolUsage) - - if (cline.parentTask) { - const didApprove = await askFinishSubTaskApproval() - - if (!didApprove) { - return - } - - // tell the provider to remove the current subtask and resume the previous task in the stack - await cline.providerRef.deref()?.finishSubTask(result) - return - } - - // We already sent completion_result says, an - // empty string asks relinquishes control over - // button and field. - const { response, text, images } = await cline.ask("completion_result", "", false) - - // Signals to recursive loop to stop (for now - // cline never happens since yesButtonClicked - // will trigger a new task). - if (response === "yesButtonClicked") { - pushToolResult("") - return - } - - await cline.say("user_feedback", text ?? "", images) - const toolResults: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = [] - - toolResults.push({ - type: "text", - text: `The user has provided feedback on the results. Consider their input to continue the task, and then attempt completion again.\n\n${text}\n`, - }) - - toolResults.push(...formatResponse.imageBlocks(images)) - cline.userMessageContent.push({ type: "text", text: `${toolDescription()} Result:` }) - cline.userMessageContent.push(...toolResults) - - return - } - } catch (error) { - await handleError("inspecting site", error) - return - } -} diff --git a/src/core/tools/browserActionTool.ts b/src/core/tools/browserActionTool.ts deleted file mode 100644 index 13cb9b0ec2..0000000000 --- a/src/core/tools/browserActionTool.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { Task } from "../task/Task" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" -import { - BrowserAction, - BrowserActionResult, - browserActions, - ClineSayBrowserAction, -} from "../../shared/ExtensionMessage" -import { formatResponse } from "../prompts/responses" - -export async function browserActionTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - 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 - - 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") - pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "action")) - await cline.browserSession.closeBrowser() - } - - return - } - - try { - if (block.partial) { - if (action === "launch") { - await cline.ask("browser_action_launch", removeClosingTag("url", url), block.partial).catch(() => {}) - } else { - await cline.say( - "browser_action", - JSON.stringify({ - action: action as BrowserAction, - coordinate: removeClosingTag("coordinate", coordinate), - text: removeClosingTag("text", text), - } 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") - pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "url")) - await cline.browserSession.closeBrowser() - 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. - // await cline.say("inspect_site_result", "") // No result, starts the loading spinner waiting for result - await cline.say("browser_action_result", "") // Starts loading spinner - await cline.browserSession.launchBrowser() - browserActionResult = await cline.browserSession.navigateToUrl(url) - } else { - if (action === "click" || action === "hover") { - if (!coordinate) { - cline.consecutiveMistakeCount++ - cline.recordToolError("browser_action") - pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "coordinate")) - await cline.browserSession.closeBrowser() - return // can't be within an inner switch - } - } - - if (action === "type") { - if (!text) { - cline.consecutiveMistakeCount++ - cline.recordToolError("browser_action") - pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "text")) - await cline.browserSession.closeBrowser() - return - } - } - - if (action === "resize") { - if (!size) { - cline.consecutiveMistakeCount++ - cline.recordToolError("browser_action") - pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "size")) - await cline.browserSession.closeBrowser() - return - } - } - - cline.consecutiveMistakeCount = 0 - - await cline.say( - "browser_action", - JSON.stringify({ - action: action as BrowserAction, - coordinate, - text, - } satisfies ClineSayBrowserAction), - undefined, - false, - ) - - switch (action) { - case "click": - browserActionResult = await cline.browserSession.click(coordinate!) - break - case "hover": - browserActionResult = await cline.browserSession.hover(coordinate!) - break - case "type": - browserActionResult = await cline.browserSession.type(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 "close": - browserActionResult = await cline.browserSession.closeBrowser() - break - } - } - - switch (action) { - case "launch": - case "click": - case "hover": - case "type": - case "scroll_down": - case "scroll_up": - case "resize": - await cline.say("browser_action_result", JSON.stringify(browserActionResult)) - - pushToolResult( - formatResponse.toolResult( - `The browser action has been executed. The console logs and screenshot have been captured for your analysis.\n\nConsole logs:\n${ - browserActionResult?.logs || "(No new logs)" - }\n\n(REMEMBER: if you need to proceed to using non-\`browser_action\` tools or launch a new browser, you MUST first close cline browser. For example, if after analyzing the logs and screenshot you need to edit a file, you must first close the browser before you can use the write_to_file tool.)`, - browserActionResult?.screenshot ? [browserActionResult.screenshot] : [], - ), - ) - - break - case "close": - pushToolResult( - formatResponse.toolResult( - `The browser has been closed. You may now proceed to using other tools.`, - ), - ) - - break - } - - return - } - } catch (error) { - await cline.browserSession.closeBrowser() // if any error occurs, the browser session is terminated - await handleError("executing browser action", error) - return - } -} diff --git a/src/core/tools/codebaseSearchTool.ts b/src/core/tools/codebaseSearchTool.ts deleted file mode 100644 index 700d1b7c7c..0000000000 --- a/src/core/tools/codebaseSearchTool.ts +++ /dev/null @@ -1,144 +0,0 @@ -import * as vscode from "vscode" - -import { Task } from "../task/Task" -import { CodeIndexManager } from "../../services/code-index/manager" -import { getWorkspacePath } from "../../utils/path" -import { formatResponse } from "../prompts/responses" -import { VectorStoreSearchResult } from "../../services/code-index/interfaces" -import { AskApproval, HandleError, PushToolResult, RemoveClosingTag, ToolUse } from "../../shared/tools" -import path from "path" - -export async function codebaseSearchTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const toolName = "codebase_search" - const workspacePath = (cline.cwd && cline.cwd.trim() !== '') ? cline.cwd : getWorkspacePath() - - if (!workspacePath) { - // This case should ideally not happen if Cline is initialized correctly - await handleError(toolName, new Error("Could not determine workspace path.")) - return - } - - // --- Parameter Extraction and Validation --- - let query: string | undefined = block.params.query - let directoryPrefix: string | undefined = block.params.path - - query = removeClosingTag("query", query) - - if (directoryPrefix) { - directoryPrefix = removeClosingTag("path", directoryPrefix) - directoryPrefix = path.normalize(directoryPrefix) - } - - const sharedMessageProps = { - tool: "codebaseSearch", - query: query, - path: directoryPrefix, - isOutsideWorkspace: false, - } - - if (block.partial) { - await cline.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {}) - return - } - - if (!query) { - cline.consecutiveMistakeCount++ - pushToolResult(await cline.sayAndCreateMissingParamError(toolName, "query")) - return - } - - const didApprove = await askApproval("tool", JSON.stringify(sharedMessageProps)) - if (!didApprove) { - pushToolResult(formatResponse.toolDenied()) - return - } - - cline.consecutiveMistakeCount = 0 - - // --- Core Logic --- - try { - const context = cline.providerRef.deref()?.context - if (!context) { - throw new Error("Extension context is not available.") - } - - const manager = CodeIndexManager.getInstance(context) - - if (!manager) { - throw new Error("CodeIndexManager is not available.") - } - - if (!manager.isFeatureEnabled) { - throw new Error("Code Indexing is disabled in the settings.") - } - if (!manager.isFeatureConfigured) { - throw new Error("Code Indexing is not configured (Missing OpenAI Key or Qdrant URL).") - } - - const searchResults: VectorStoreSearchResult[] = await manager.searchIndex(query, directoryPrefix) - - // 3. Format and push results - if (!searchResults || searchResults.length === 0) { - pushToolResult(`No relevant code snippets found for the query: "${query}"`) // Use simple string for no results - return - } - - const jsonResult = { - query, - results: [], - } as { - query: string - results: Array<{ - filePath: string - score: number - startLine: number - endLine: number - codeChunk: string - }> - } - - searchResults.forEach((result) => { - if (!result.payload) return - if (!("filePath" in result.payload)) return - - const relativePath = vscode.workspace.asRelativePath(result.payload.filePath, false) - - jsonResult.results.push({ - filePath: relativePath, - score: result.score, - startLine: result.payload.startLine, - endLine: result.payload.endLine, - codeChunk: result.payload.codeChunk.trim(), - }) - }) - - // Send results to UI - const payload = { tool: "codebaseSearch", content: jsonResult } - await cline.say("codebase_search_result", JSON.stringify(payload)) - - // Push results to AI - const output = `Query: ${query} -Results: - -${jsonResult.results - .map( - (result) => `File path: ${result.filePath} -Score: ${result.score} -Lines: ${result.startLine}-${result.endLine} -Code Chunk: ${result.codeChunk} -`, - ) - .join("\n")}` - - pushToolResult(output) - } catch (error: any) { - await handleError(toolName, error) // Use the standard error handler - } -} diff --git a/src/core/tools/fetchInstructionsTool.ts b/src/core/tools/fetchInstructionsTool.ts deleted file mode 100644 index 5325f98fbf..0000000000 --- a/src/core/tools/fetchInstructionsTool.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Task } from "../task/Task" -import { fetchInstructions } from "../prompts/instructions/instructions" -import { ClineSayTool } from "../../shared/ExtensionMessage" -import { formatResponse } from "../prompts/responses" -import { ToolUse, AskApproval, HandleError, PushToolResult } from "../../shared/tools" - -export async function fetchInstructionsTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, -) { - const task: string | undefined = block.params.task - const sharedMessageProps: ClineSayTool = { tool: "fetchInstructions", content: task } - - try { - if (block.partial) { - const partialMessage = JSON.stringify({ ...sharedMessageProps, content: undefined } satisfies ClineSayTool) - await cline.ask("tool", partialMessage, block.partial).catch(() => {}) - return - } else { - if (!task) { - cline.consecutiveMistakeCount++ - cline.recordToolError("fetch_instructions") - pushToolResult(await cline.sayAndCreateMissingParamError("fetch_instructions", "task")) - return - } - - cline.consecutiveMistakeCount = 0 - - const completeMessage = JSON.stringify({ ...sharedMessageProps, content: task } satisfies ClineSayTool) - const didApprove = await askApproval("tool", completeMessage) - - if (!didApprove) { - return - } - - // Bow fetch the content and provide it to the agent. - const provider = cline.providerRef.deref() - const mcpHub = provider?.getMcpHub() - - if (!mcpHub) { - throw new Error("MCP hub not available") - } - - const diffStrategy = cline.diffStrategy - const context = provider?.context - const content = await fetchInstructions(task, { mcpHub, diffStrategy, context }) - - if (!content) { - pushToolResult(formatResponse.toolError(`Invalid instructions request: ${task}`)) - return - } - - pushToolResult(content) - - return - } - } catch (error) { - await handleError("fetch instructions", error) - } -} diff --git a/src/core/tools/generateImageTool.ts b/src/core/tools/generateImageTool.ts deleted file mode 100644 index 88a02ac821..0000000000 --- a/src/core/tools/generateImageTool.ts +++ /dev/null @@ -1,263 +0,0 @@ -import path from "path" -import fs from "fs/promises" -import * as vscode from "vscode" -import { Task } from "../task/Task" -import { formatResponse } from "../prompts/responses" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" -import { fileExistsAtPath } from "../../utils/fs" -import { getReadablePath } from "../../utils/path" -import { isPathOutsideWorkspace } from "../../utils/pathUtils" -import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" -import { OpenRouterHandler } from "../../api/providers/openrouter" - -// Hardcoded list of image generation models for now -const IMAGE_GENERATION_MODELS = ["google/gemini-2.5-flash-image", "openai/gpt-5-image", "openai/gpt-5-image-mini"] - -export async function generateImageTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const prompt: string | undefined = block.params.prompt - const relPath: string | undefined = block.params.path - const inputImagePath: string | undefined = block.params.image - - // Check if the experiment is enabled - const provider = cline.providerRef.deref() - const state = await provider?.getState() - const isImageGenerationEnabled = experiments.isEnabled(state?.experiments ?? {}, EXPERIMENT_IDS.IMAGE_GENERATION) - - if (!isImageGenerationEnabled) { - pushToolResult( - formatResponse.toolError( - "Image generation is an experimental feature that must be enabled in settings. Please enable 'Image Generation' in the Experimental Settings section.", - ), - ) - return - } - - if (block.partial) { - return - } - - if (!prompt) { - cline.consecutiveMistakeCount++ - cline.recordToolError("generate_image") - pushToolResult(await cline.sayAndCreateMissingParamError("generate_image", "prompt")) - return - } - - if (!relPath) { - cline.consecutiveMistakeCount++ - cline.recordToolError("generate_image") - pushToolResult(await cline.sayAndCreateMissingParamError("generate_image", "path")) - return - } - - // Validate access permissions - const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) - if (!accessAllowed) { - await cline.say("rooignore_error", relPath) - pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath))) - return - } - - // If input image is provided, validate it exists and can be read - let inputImageData: string | undefined - if (inputImagePath) { - const inputImageFullPath = path.resolve(cline.cwd, inputImagePath) - - // Check if input image exists - const inputImageExists = await fileExistsAtPath(inputImageFullPath) - if (!inputImageExists) { - await cline.say("error", `Input image not found: ${getReadablePath(cline.cwd, inputImagePath)}`) - pushToolResult( - formatResponse.toolError(`Input image not found: ${getReadablePath(cline.cwd, inputImagePath)}`), - ) - return - } - - // Validate input image access permissions - const inputImageAccessAllowed = cline.rooIgnoreController?.validateAccess(inputImagePath) - if (!inputImageAccessAllowed) { - await cline.say("rooignore_error", inputImagePath) - pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(inputImagePath))) - return - } - - // Read the input image file - try { - const imageBuffer = await fs.readFile(inputImageFullPath) - const imageExtension = path.extname(inputImageFullPath).toLowerCase().replace(".", "") - - // Validate image format - const supportedFormats = ["png", "jpg", "jpeg", "gif", "webp"] - if (!supportedFormats.includes(imageExtension)) { - await cline.say( - "error", - `Unsupported image format: ${imageExtension}. Supported formats: ${supportedFormats.join(", ")}`, - ) - pushToolResult( - formatResponse.toolError( - `Unsupported image format: ${imageExtension}. Supported formats: ${supportedFormats.join(", ")}`, - ), - ) - return - } - - // Convert to base64 data URL - const mimeType = imageExtension === "jpg" ? "jpeg" : imageExtension - inputImageData = `data:image/${mimeType};base64,${imageBuffer.toString("base64")}` - } catch (error) { - await cline.say( - "error", - `Failed to read input image: ${error instanceof Error ? error.message : "Unknown error"}`, - ) - pushToolResult( - formatResponse.toolError( - `Failed to read input image: ${error instanceof Error ? error.message : "Unknown error"}`, - ), - ) - return - } - } - - // Check if file is write-protected - const isWriteProtected = cline.rooProtectedController?.isWriteProtected(relPath) || false - - // Get OpenRouter API key from global settings (experimental image generation) - const openRouterApiKey = state?.openRouterImageApiKey - - if (!openRouterApiKey) { - await cline.say( - "error", - "OpenRouter API key is required for image generation. Please configure it in the Image Generation experimental settings.", - ) - pushToolResult( - formatResponse.toolError( - "OpenRouter API key is required for image generation. Please configure it in the Image Generation experimental settings.", - ), - ) - return - } - - // Get selected model from settings or use default - const selectedModel = state?.openRouterImageGenerationSelectedModel || IMAGE_GENERATION_MODELS[0] - - // Determine if the path is outside the workspace - const fullPath = path.resolve(cline.cwd, removeClosingTag("path", relPath)) - const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) - - const sharedMessageProps = { - tool: "generateImage" as const, - path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)), - content: prompt, - isOutsideWorkspace, - isProtected: isWriteProtected, - } - - try { - if (!block.partial) { - cline.consecutiveMistakeCount = 0 - - // Ask for approval before generating the image - const approvalMessage = JSON.stringify({ - ...sharedMessageProps, - content: prompt, - ...(inputImagePath && { inputImage: getReadablePath(cline.cwd, inputImagePath) }), - }) - - const didApprove = await askApproval("tool", approvalMessage, undefined, isWriteProtected) - - if (!didApprove) { - return - } - - // Create a temporary OpenRouter handler with minimal options - const openRouterHandler = new OpenRouterHandler({} as any) - - // Call the generateImage method with the explicit API key and optional input image - const result = await openRouterHandler.generateImage( - prompt, - selectedModel, - openRouterApiKey, - inputImageData, - ) - - if (!result.success) { - await cline.say("error", result.error || "Failed to generate image") - pushToolResult(formatResponse.toolError(result.error || "Failed to generate image")) - return - } - - if (!result.imageData) { - const errorMessage = "No image data received" - await cline.say("error", errorMessage) - pushToolResult(formatResponse.toolError(errorMessage)) - return - } - - // Extract base64 data from data URL - const base64Match = result.imageData.match(/^data:image\/(png|jpeg|jpg);base64,(.+)$/) - if (!base64Match) { - const errorMessage = "Invalid image format received" - await cline.say("error", errorMessage) - pushToolResult(formatResponse.toolError(errorMessage)) - return - } - - const imageFormat = base64Match[1] - const base64Data = base64Match[2] - - // Ensure the file has the correct extension - let finalPath = relPath - if (!finalPath.match(/\.(png|jpg|jpeg)$/i)) { - finalPath = `${finalPath}.${imageFormat === "jpeg" ? "jpg" : imageFormat}` - } - - // Convert base64 to buffer - const imageBuffer = Buffer.from(base64Data, "base64") - - // Create directory if it doesn't exist - const absolutePath = path.resolve(cline.cwd, finalPath) - const directory = path.dirname(absolutePath) - await fs.mkdir(directory, { recursive: true }) - - // Write the image file - await fs.writeFile(absolutePath, imageBuffer) - - // Track file creation - if (finalPath) { - await cline.fileContextTracker.trackFileContext(finalPath, "roo_edited") - } - - cline.didEditFile = true - - // Record successful tool usage - cline.recordToolUsage("generate_image") - - // Get the webview URI for the image - const provider = cline.providerRef.deref() - const fullImagePath = path.join(cline.cwd, finalPath) - - // Convert to webview URI if provider is available - let imageUri = provider?.convertToWebviewUri?.(fullImagePath) ?? vscode.Uri.file(fullImagePath).toString() - - // Add cache-busting parameter to prevent browser caching issues - const cacheBuster = Date.now() - imageUri = imageUri.includes("?") ? `${imageUri}&t=${cacheBuster}` : `${imageUri}?t=${cacheBuster}` - - // Send the image with the webview URI - await cline.say("image", JSON.stringify({ imageUri, imagePath: fullImagePath })) - pushToolResult(formatResponse.toolResult(getReadablePath(cline.cwd, finalPath))) - - return - } - } catch (error) { - await handleError("generating image", error) - return - } -} diff --git a/src/core/tools/helpers/__tests__/toolResultFormatting.spec.ts b/src/core/tools/helpers/__tests__/toolResultFormatting.spec.ts new file mode 100644 index 0000000000..8f83381f17 --- /dev/null +++ b/src/core/tools/helpers/__tests__/toolResultFormatting.spec.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import * as vscode from "vscode" +import { TOOL_PROTOCOL, isNativeProtocol } from "@roo-code/types" +import { formatToolInvocation, getCurrentToolProtocol } from "../toolResultFormatting" + +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn(), + }, +})) + +describe("toolResultFormatting", () => { + let mockGetConfiguration: ReturnType + + beforeEach(() => { + mockGetConfiguration = vi.fn() + ;(vscode.workspace.getConfiguration as any).mockReturnValue({ + get: mockGetConfiguration, + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe("getCurrentToolProtocol", () => { + it("should return configured protocol", () => { + mockGetConfiguration.mockReturnValue(TOOL_PROTOCOL.NATIVE) + expect(getCurrentToolProtocol()).toBe(TOOL_PROTOCOL.NATIVE) + }) + + it("should default to xml when config is not set", () => { + mockGetConfiguration.mockReturnValue("xml") + expect(getCurrentToolProtocol()).toBe("xml") + }) + }) + + describe("isNativeProtocol", () => { + it("should return true for native protocol", () => { + expect(isNativeProtocol(TOOL_PROTOCOL.NATIVE)).toBe(true) + }) + + it("should return false for XML protocol", () => { + expect(isNativeProtocol("xml")).toBe(false) + }) + }) + + describe("formatToolInvocation", () => { + it("should format for XML protocol", () => { + const result = formatToolInvocation("read_file", { path: "test.ts" }, "xml") + + expect(result).toContain("") + expect(result).toContain("") + expect(result).toContain("test.ts") + expect(result).toContain("") + expect(result).toContain("") + }) + + it("should format for native protocol", () => { + const result = formatToolInvocation("read_file", { path: "test.ts" }, TOOL_PROTOCOL.NATIVE) + + expect(result).toBe("Called read_file with path: test.ts") + expect(result).not.toContain("<") + }) + + it("should handle multiple parameters for XML", () => { + const result = formatToolInvocation( + "read_file", + { path: "test.ts", start_line: "1", end_line: "10" }, + "xml", + ) + + expect(result).toContain("\ntest.ts\n") + expect(result).toContain("\n1\n") + expect(result).toContain("\n10\n") + }) + + it("should handle multiple parameters for native", () => { + const result = formatToolInvocation("read_file", { path: "test.ts", start_line: "1" }, TOOL_PROTOCOL.NATIVE) + + expect(result).toContain("Called read_file with") + expect(result).toContain("path: test.ts") + expect(result).toContain("start_line: 1") + }) + + it("should handle empty parameters", () => { + const result = formatToolInvocation("list_files", {}, TOOL_PROTOCOL.NATIVE) + expect(result).toBe("Called list_files") + }) + + it("should use config when protocol not specified", () => { + mockGetConfiguration.mockReturnValue(TOOL_PROTOCOL.NATIVE) + const result = formatToolInvocation("read_file", { path: "test.ts" }) + expect(result).toBe("Called read_file with path: test.ts") + }) + }) +}) diff --git a/src/core/tools/helpers/toolResultFormatting.ts b/src/core/tools/helpers/toolResultFormatting.ts new file mode 100644 index 0000000000..d4c77798c5 --- /dev/null +++ b/src/core/tools/helpers/toolResultFormatting.ts @@ -0,0 +1,31 @@ +import * as vscode from "vscode" +import { Package } from "../../../shared/package" +import { TOOL_PROTOCOL, ToolProtocol, isNativeProtocol } from "@roo-code/types" + +/** + * Gets the current tool protocol from workspace configuration. + */ +export function getCurrentToolProtocol(): ToolProtocol { + return vscode.workspace.getConfiguration(Package.name).get("toolProtocol", "xml") +} + +/** + * Formats tool invocation parameters for display based on protocol. + * Used for legacy conversation history conversion. + */ +export function formatToolInvocation(toolName: string, params: Record, protocol?: ToolProtocol): string { + const effectiveProtocol = protocol ?? getCurrentToolProtocol() + if (isNativeProtocol(effectiveProtocol)) { + // Native protocol: readable format + const paramsList = Object.entries(params) + .map(([key, value]) => `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`) + .join(", ") + return `Called ${toolName}${paramsList ? ` with ${paramsList}` : ""}` + } else { + // XML protocol: preserve XML format + const paramsXml = Object.entries(params) + .map(([key, value]) => `<${key}>\n${value}\n`) + .join("\n") + return `<${toolName}>\n${paramsXml}\n` + } +} diff --git a/src/core/tools/insertContentTool.ts b/src/core/tools/insertContentTool.ts deleted file mode 100644 index 38ca309a3b..0000000000 --- a/src/core/tools/insertContentTool.ts +++ /dev/null @@ -1,198 +0,0 @@ -import delay from "delay" -import fs from "fs/promises" -import path from "path" - -import { getReadablePath } from "../../utils/path" -import { Task } from "../task/Task" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" -import { formatResponse } from "../prompts/responses" -import { ClineSayTool } from "../../shared/ExtensionMessage" -import { RecordSource } from "../context-tracking/FileContextTrackerTypes" -import { fileExistsAtPath } from "../../utils/fs" -import { insertGroups } from "../diff/insert-groups" -import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" -import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" -import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" - -export async function insertContentTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const relPath: string | undefined = block.params.path - const line: string | undefined = block.params.line - const content: string | undefined = block.params.content - - const sharedMessageProps: ClineSayTool = { - tool: "insertContent", - path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)), - diff: content, - lineNumber: line ? parseInt(line, 10) : undefined, - } - - try { - if (block.partial) { - await cline.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {}) - return - } - - // Validate required parameters - if (!relPath) { - cline.consecutiveMistakeCount++ - cline.recordToolError("insert_content") - pushToolResult(await cline.sayAndCreateMissingParamError("insert_content", "path")) - return - } - - if (!line) { - cline.consecutiveMistakeCount++ - cline.recordToolError("insert_content") - pushToolResult(await cline.sayAndCreateMissingParamError("insert_content", "line")) - return - } - - if (content === undefined) { - cline.consecutiveMistakeCount++ - cline.recordToolError("insert_content") - pushToolResult(await cline.sayAndCreateMissingParamError("insert_content", "content")) - return - } - - const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) - - if (!accessAllowed) { - await cline.say("rooignore_error", relPath) - pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath))) - return - } - - // Check if file is write-protected - const isWriteProtected = cline.rooProtectedController?.isWriteProtected(relPath) || false - - const absolutePath = path.resolve(cline.cwd, relPath) - const lineNumber = parseInt(line, 10) - if (isNaN(lineNumber) || lineNumber < 0) { - cline.consecutiveMistakeCount++ - cline.recordToolError("insert_content") - pushToolResult(formatResponse.toolError("Invalid line number. Must be a non-negative integer.")) - return - } - - const fileExists = await fileExistsAtPath(absolutePath) - let fileContent: string = "" - if (!fileExists) { - if (lineNumber > 1) { - cline.consecutiveMistakeCount++ - cline.recordToolError("insert_content") - const formattedError = `Cannot insert content at line ${lineNumber} into a non-existent file. For new files, 'line' must be 0 (to append) or 1 (to insert at the beginning).` - await cline.say("error", formattedError) - pushToolResult(formattedError) - return - } - } else { - fileContent = await fs.readFile(absolutePath, "utf8") - } - - cline.consecutiveMistakeCount = 0 - - cline.diffViewProvider.editType = fileExists ? "modify" : "create" - cline.diffViewProvider.originalContent = fileContent - const lines = fileExists ? fileContent.split("\n") : [] - - let updatedContent = insertGroups(lines, [ - { - index: lineNumber - 1, - elements: content.split("\n"), - }, - ]).join("\n") - - // Check if preventFocusDisruption experiment is enabled - const provider = cline.providerRef.deref() - const state = await provider?.getState() - const diagnosticsEnabled = state?.diagnosticsEnabled ?? true - const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS - const isPreventFocusDisruptionEnabled = experiments.isEnabled( - state?.experiments ?? {}, - EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, - ) - - // Build unified diff for display (normalize EOLs only for diff generation) - let unified: string - if (fileExists) { - const oldForDiff = fileContent.replace(/\r\n/g, "\n") - const newForDiff = updatedContent.replace(/\r\n/g, "\n") - unified = formatResponse.createPrettyPatch(relPath, oldForDiff, newForDiff) - if (!unified) { - pushToolResult(`No changes needed for '${relPath}'`) - return - } - } else { - const newForDiff = updatedContent.replace(/\r\n/g, "\n") - unified = convertNewFileToUnifiedDiff(newForDiff, relPath) - } - unified = sanitizeUnifiedDiff(unified) - const diffStats = computeDiffStats(unified) || undefined - - // Prepare the approval message (same for both flows) - const completeMessage = JSON.stringify({ - ...sharedMessageProps, - // Send unified diff as content for render-only webview - content: unified, - lineNumber: lineNumber, - isProtected: isWriteProtected, - diffStats, - } satisfies ClineSayTool) - - // Show diff view if focus disruption prevention is disabled - if (!isPreventFocusDisruptionEnabled) { - await cline.diffViewProvider.open(relPath) - await cline.diffViewProvider.update(updatedContent, true) - cline.diffViewProvider.scrollToFirstDiff() - } - - // Ask for approval (same for both flows) - const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) - - if (!didApprove) { - // Revert changes if diff view was shown - if (!isPreventFocusDisruptionEnabled) { - await cline.diffViewProvider.revertChanges() - } - pushToolResult("Changes were rejected by the user.") - await cline.diffViewProvider.reset() - return - } - - // Save the changes - if (isPreventFocusDisruptionEnabled) { - // Direct file write without diff view or opening the file - await cline.diffViewProvider.saveDirectly(relPath, updatedContent, false, diagnosticsEnabled, writeDelayMs) - } else { - // Call saveChanges to update the DiffViewProvider properties - await cline.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) - } - - // Track file edit operation - if (relPath) { - await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) - } - - cline.didEditFile = true - - // Get the formatted response message - const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) - - pushToolResult(message) - - await cline.diffViewProvider.reset() - - // Process any queued messages after file edit completes - cline.processQueuedMessages() - } catch (error) { - handleError("insert content", error) - await cline.diffViewProvider.reset() - } -} diff --git a/src/core/tools/listCodeDefinitionNamesTool.ts b/src/core/tools/listCodeDefinitionNamesTool.ts deleted file mode 100644 index 0ec80ce9bd..0000000000 --- a/src/core/tools/listCodeDefinitionNamesTool.ts +++ /dev/null @@ -1,90 +0,0 @@ -import path from "path" -import fs from "fs/promises" - -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" -import { Task } from "../task/Task" -import { ClineSayTool } from "../../shared/ExtensionMessage" -import { getReadablePath } from "../../utils/path" -import { isPathOutsideWorkspace } from "../../utils/pathUtils" -import { parseSourceCodeForDefinitionsTopLevel, parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter" -import { RecordSource } from "../context-tracking/FileContextTrackerTypes" -import { truncateDefinitionsToLineLimit } from "./helpers/truncateDefinitions" - -export async function listCodeDefinitionNamesTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const relPath: string | undefined = block.params.path - - // Calculate if the path is outside workspace - const absolutePath = relPath ? path.resolve(cline.cwd, relPath) : cline.cwd - const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) - - const sharedMessageProps: ClineSayTool = { - tool: "listCodeDefinitionNames", - path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)), - isOutsideWorkspace, - } - - try { - if (block.partial) { - const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "" } satisfies ClineSayTool) - await cline.ask("tool", partialMessage, block.partial).catch(() => {}) - return - } else { - if (!relPath) { - cline.consecutiveMistakeCount++ - cline.recordToolError("list_code_definition_names") - pushToolResult(await cline.sayAndCreateMissingParamError("list_code_definition_names", "path")) - return - } - - cline.consecutiveMistakeCount = 0 - - let result: string - - try { - const stats = await fs.stat(absolutePath) - - if (stats.isFile()) { - const fileResult = await parseSourceCodeDefinitionsForFile(absolutePath, cline.rooIgnoreController) - - // Apply truncation based on maxReadFileLine setting - if (fileResult) { - const { maxReadFileLine = -1 } = (await cline.providerRef.deref()?.getState()) ?? {} - result = truncateDefinitionsToLineLimit(fileResult, maxReadFileLine) - } else { - result = "No source code definitions found in file." - } - } else if (stats.isDirectory()) { - result = await parseSourceCodeForDefinitionsTopLevel(absolutePath, cline.rooIgnoreController) - } else { - result = "The specified path is neither a file nor a directory." - } - } catch { - result = `${absolutePath}: does not exist or cannot be accessed.` - } - - const completeMessage = JSON.stringify({ ...sharedMessageProps, content: result } satisfies ClineSayTool) - const didApprove = await askApproval("tool", completeMessage) - - if (!didApprove) { - return - } - - if (relPath) { - await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - } - - pushToolResult(result) - return - } - } catch (error) { - await handleError("parsing source code definitions", error) - return - } -} diff --git a/src/core/tools/listFilesTool.ts b/src/core/tools/listFilesTool.ts deleted file mode 100644 index e51453c5d9..0000000000 --- a/src/core/tools/listFilesTool.ts +++ /dev/null @@ -1,87 +0,0 @@ -import * as path from "path" - -import { Task } from "../task/Task" -import { ClineSayTool } from "../../shared/ExtensionMessage" -import { formatResponse } from "../prompts/responses" -import { listFiles } from "../../services/glob/list-files" -import { getReadablePath } from "../../utils/path" -import { isPathOutsideWorkspace } from "../../utils/pathUtils" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" - -/** - * Implements the list_files tool. - * - * @param cline - The instance of Cline that is executing this tool. - * @param block - The block of assistant message content that specifies the - * parameters for this tool. - * @param askApproval - A function that asks the user for approval to show a - * message. - * @param handleError - A function that handles an error that occurred while - * executing this tool. - * @param pushToolResult - A function that pushes the result of this tool to the - * conversation. - * @param removeClosingTag - A function that removes a closing tag from a string. - */ - -export async function listFilesTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const relDirPath: string | undefined = block.params.path - const recursiveRaw: string | undefined = block.params.recursive - const recursive = recursiveRaw?.toLowerCase() === "true" - - // Calculate if the path is outside workspace - const absolutePath = relDirPath ? path.resolve(cline.cwd, relDirPath) : cline.cwd - const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) - - const sharedMessageProps: ClineSayTool = { - tool: !recursive ? "listFilesTopLevel" : "listFilesRecursive", - path: getReadablePath(cline.cwd, removeClosingTag("path", relDirPath)), - isOutsideWorkspace, - } - - try { - if (block.partial) { - const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "" } satisfies ClineSayTool) - await cline.ask("tool", partialMessage, block.partial).catch(() => {}) - return - } else { - if (!relDirPath) { - cline.consecutiveMistakeCount++ - cline.recordToolError("list_files") - pushToolResult(await cline.sayAndCreateMissingParamError("list_files", "path")) - return - } - - cline.consecutiveMistakeCount = 0 - - const [files, didHitLimit] = await listFiles(absolutePath, recursive, 200) - const { showRooIgnoredFiles = false } = (await cline.providerRef.deref()?.getState()) ?? {} - - const result = formatResponse.formatFilesList( - absolutePath, - files, - didHitLimit, - cline.rooIgnoreController, - showRooIgnoredFiles, - cline.rooProtectedController, - ) - - const completeMessage = JSON.stringify({ ...sharedMessageProps, content: result } satisfies ClineSayTool) - const didApprove = await askApproval("tool", completeMessage) - - if (!didApprove) { - return - } - - pushToolResult(result) - } - } catch (error) { - await handleError("listing files", error) - } -} diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts deleted file mode 100644 index 53f0643dbb..0000000000 --- a/src/core/tools/readFileTool.ts +++ /dev/null @@ -1,749 +0,0 @@ -import path from "path" -import { isBinaryFile } from "isbinaryfile" - -import { Task } from "../task/Task" -import { ClineSayTool } from "../../shared/ExtensionMessage" -import { formatResponse } from "../prompts/responses" -import { t } from "../../i18n" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" -import { RecordSource } from "../context-tracking/FileContextTrackerTypes" -import { isPathOutsideWorkspace } from "../../utils/pathUtils" -import { getReadablePath } from "../../utils/path" -import { countFileLines } from "../../integrations/misc/line-counter" -import { readLines } from "../../integrations/misc/read-lines" -import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text" -import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter" -import { parseXml } from "../../utils/xml" -import { - DEFAULT_MAX_IMAGE_FILE_SIZE_MB, - DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, - isSupportedImageFormat, - validateImageForProcessing, - processImageFile, - ImageMemoryTracker, -} from "./helpers/imageHelpers" -import { validateFileTokenBudget, truncateFileContent } from "./helpers/fileTokenBudget" -import { truncateDefinitionsToLineLimit } from "./helpers/truncateDefinitions" - -export function getReadFileToolDescription(blockName: string, blockParams: any): string { - // Handle both single path and multiple files via args - if (blockParams.args) { - try { - const parsed = parseXml(blockParams.args) as any - const files = Array.isArray(parsed.file) ? parsed.file : [parsed.file].filter(Boolean) - const paths = files.map((f: any) => f?.path).filter(Boolean) as string[] - - if (paths.length === 0) { - return `[${blockName} with no valid paths]` - } else if (paths.length === 1) { - // Modified part for single file - return `[${blockName} for '${paths[0]}'. Reading multiple files at once is more efficient for the LLM. If other files are relevant to your current task, please read them simultaneously.]` - } else if (paths.length <= 3) { - const pathList = paths.map((p) => `'${p}'`).join(", ") - return `[${blockName} for ${pathList}]` - } else { - return `[${blockName} for ${paths.length} files]` - } - } catch (error) { - console.error("Failed to parse read_file args XML for description:", error) - return `[${blockName} with unparsable args]` - } - } else if (blockParams.path) { - // Fallback for legacy single-path usage - // Modified part for single file (legacy) - return `[${blockName} for '${blockParams.path}'. Reading multiple files at once is more efficient for the LLM. If other files are relevant to your current task, please read them simultaneously.]` - } else { - return `[${blockName} with missing path/args]` - } -} -// Types -interface LineRange { - start: number - end: number -} - -interface FileEntry { - path?: string - lineRanges?: LineRange[] -} - -// New interface to track file processing state -interface FileResult { - path: string - status: "approved" | "denied" | "blocked" | "error" | "pending" - content?: string - error?: string - notice?: string - lineRanges?: LineRange[] - xmlContent?: string // Final XML content for this file - imageDataUrl?: string // Image data URL for image files - feedbackText?: string // User feedback text from approval/denial - feedbackImages?: any[] // User feedback images from approval/denial -} - -export async function readFileTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - _removeClosingTag: RemoveClosingTag, -) { - const argsXmlTag: string | undefined = block.params.args - const legacyPath: string | undefined = block.params.path - const legacyStartLineStr: string | undefined = block.params.start_line - const legacyEndLineStr: string | undefined = block.params.end_line - - // Check if the current model supports images at the beginning - const modelInfo = cline.api.getModel().info - const supportsImages = modelInfo.supportsImages ?? false - - // Handle partial message first - if (block.partial) { - let filePath = "" - // Prioritize args for partial, then legacy path - if (argsXmlTag) { - const match = argsXmlTag.match(/.*?([^<]+)<\/path>/s) - if (match) filePath = match[1] - } - if (!filePath && legacyPath) { - // If args didn't yield a path, try legacy - filePath = legacyPath - } - - const fullPath = filePath ? path.resolve(cline.cwd, filePath) : "" - const sharedMessageProps: ClineSayTool = { - tool: "readFile", - path: getReadablePath(cline.cwd, filePath), - isOutsideWorkspace: filePath ? isPathOutsideWorkspace(fullPath) : false, - } - const partialMessage = JSON.stringify({ - ...sharedMessageProps, - content: undefined, - } satisfies ClineSayTool) - await cline.ask("tool", partialMessage, block.partial).catch(() => {}) - return - } - - const fileEntries: FileEntry[] = [] - - if (argsXmlTag) { - // Parse file entries from XML (new multi-file format) - try { - const parsed = parseXml(argsXmlTag) as any - const files = Array.isArray(parsed.file) ? parsed.file : [parsed.file].filter(Boolean) - - for (const file of files) { - if (!file.path) continue // Skip if no path in a file entry - - const fileEntry: FileEntry = { - path: file.path, - lineRanges: [], - } - - if (file.line_range) { - const ranges = Array.isArray(file.line_range) ? file.line_range : [file.line_range] - for (const range of ranges) { - const match = String(range).match(/(\d+)-(\d+)/) // Ensure range is treated as string - if (match) { - const [, start, end] = match.map(Number) - if (!isNaN(start) && !isNaN(end)) { - fileEntry.lineRanges?.push({ start, end }) - } - } - } - } - fileEntries.push(fileEntry) - } - } catch (error) { - const errorMessage = `Failed to parse read_file XML args: ${error instanceof Error ? error.message : String(error)}` - await handleError("parsing read_file args", new Error(errorMessage)) - pushToolResult(`${errorMessage}`) - return - } - } else if (legacyPath) { - // Handle legacy single file path as a fallback - console.warn("[readFileTool] Received legacy 'path' parameter. Consider updating to use 'args' structure.") - - const fileEntry: FileEntry = { - path: legacyPath, - lineRanges: [], - } - - if (legacyStartLineStr && legacyEndLineStr) { - const start = parseInt(legacyStartLineStr, 10) - const end = parseInt(legacyEndLineStr, 10) - if (!isNaN(start) && !isNaN(end) && start > 0 && end > 0) { - fileEntry.lineRanges?.push({ start, end }) - } else { - console.warn( - `[readFileTool] Invalid legacy line range for ${legacyPath}: start='${legacyStartLineStr}', end='${legacyEndLineStr}'`, - ) - } - } - fileEntries.push(fileEntry) - } - - // If, after trying both new and legacy, no valid file entries are found. - if (fileEntries.length === 0) { - cline.consecutiveMistakeCount++ - cline.recordToolError("read_file") - const errorMsg = await cline.sayAndCreateMissingParamError("read_file", "args (containing valid file paths)") - pushToolResult(`${errorMsg}`) - return - } - - // Create an array to track the state of each file - const fileResults: FileResult[] = fileEntries.map((entry) => ({ - path: entry.path || "", - status: "pending", - lineRanges: entry.lineRanges, - })) - - // Function to update file result status - const updateFileResult = (path: string, updates: Partial) => { - const index = fileResults.findIndex((result) => result.path === path) - if (index !== -1) { - fileResults[index] = { ...fileResults[index], ...updates } - } - } - - try { - // First validate all files and prepare for batch approval - const filesToApprove: FileResult[] = [] - - for (let i = 0; i < fileResults.length; i++) { - const fileResult = fileResults[i] - const relPath = fileResult.path - const fullPath = path.resolve(cline.cwd, relPath) - - // Validate line ranges first - if (fileResult.lineRanges) { - let hasRangeError = false - for (const range of fileResult.lineRanges) { - if (range.start > range.end) { - const errorMsg = "Invalid line range: end line cannot be less than start line" - updateFileResult(relPath, { - status: "blocked", - error: errorMsg, - xmlContent: `${relPath}Error reading file: ${errorMsg}`, - }) - await handleError(`reading file ${relPath}`, new Error(errorMsg)) - hasRangeError = true - break - } - if (isNaN(range.start) || isNaN(range.end)) { - const errorMsg = "Invalid line range values" - updateFileResult(relPath, { - status: "blocked", - error: errorMsg, - xmlContent: `${relPath}Error reading file: ${errorMsg}`, - }) - await handleError(`reading file ${relPath}`, new Error(errorMsg)) - hasRangeError = true - break - } - } - if (hasRangeError) continue - } - - // Then check RooIgnore validation - if (fileResult.status === "pending") { - const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) - if (!accessAllowed) { - await cline.say("rooignore_error", relPath) - const errorMsg = formatResponse.rooIgnoreError(relPath) - updateFileResult(relPath, { - status: "blocked", - error: errorMsg, - xmlContent: `${relPath}${errorMsg}`, - }) - continue - } - - // Add to files that need approval - filesToApprove.push(fileResult) - } - } - - // Handle batch approval if there are multiple files to approve - if (filesToApprove.length > 1) { - const { maxReadFileLine = -1 } = (await cline.providerRef.deref()?.getState()) ?? {} - - // Prepare batch file data - const batchFiles = filesToApprove.map((fileResult) => { - const relPath = fileResult.path - const fullPath = path.resolve(cline.cwd, relPath) - const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) - - // Create line snippet for this file - let lineSnippet = "" - if (fileResult.lineRanges && fileResult.lineRanges.length > 0) { - const ranges = fileResult.lineRanges.map((range) => - t("tools:readFile.linesRange", { start: range.start, end: range.end }), - ) - lineSnippet = ranges.join(", ") - } else if (maxReadFileLine === 0) { - lineSnippet = t("tools:readFile.definitionsOnly") - } else if (maxReadFileLine > 0) { - lineSnippet = t("tools:readFile.maxLines", { max: maxReadFileLine }) - } - - const readablePath = getReadablePath(cline.cwd, relPath) - const key = `${readablePath}${lineSnippet ? ` (${lineSnippet})` : ""}` - - return { - path: readablePath, - lineSnippet, - isOutsideWorkspace, - key, - content: fullPath, // Include full path for content - } - }) - - const completeMessage = JSON.stringify({ - tool: "readFile", - batchFiles, - } satisfies ClineSayTool) - - const { response, text, images } = await cline.ask("tool", completeMessage, false) - - // Process batch response - if (response === "yesButtonClicked") { - // Approve all files - if (text) { - await cline.say("user_feedback", text, images) - } - filesToApprove.forEach((fileResult) => { - updateFileResult(fileResult.path, { - status: "approved", - feedbackText: text, - feedbackImages: images, - }) - }) - } else if (response === "noButtonClicked") { - // Deny all files - if (text) { - await cline.say("user_feedback", text, images) - } - cline.didRejectTool = true - filesToApprove.forEach((fileResult) => { - updateFileResult(fileResult.path, { - status: "denied", - xmlContent: `${fileResult.path}Denied by user`, - feedbackText: text, - feedbackImages: images, - }) - }) - } else { - // Handle individual permissions from objectResponse - // if (text) { - // await cline.say("user_feedback", text, images) - // } - - try { - const individualPermissions = JSON.parse(text || "{}") - let hasAnyDenial = false - - batchFiles.forEach((batchFile, index) => { - const fileResult = filesToApprove[index] - const approved = individualPermissions[batchFile.key] === true - - if (approved) { - updateFileResult(fileResult.path, { - status: "approved", - }) - } else { - hasAnyDenial = true - updateFileResult(fileResult.path, { - status: "denied", - xmlContent: `${fileResult.path}Denied by user`, - }) - } - }) - - if (hasAnyDenial) { - cline.didRejectTool = true - } - } catch (error) { - // Fallback: if JSON parsing fails, deny all files - console.error("Failed to parse individual permissions:", error) - cline.didRejectTool = true - filesToApprove.forEach((fileResult) => { - updateFileResult(fileResult.path, { - status: "denied", - xmlContent: `${fileResult.path}Denied by user`, - }) - }) - } - } - } else if (filesToApprove.length === 1) { - // Handle single file approval (existing logic) - const fileResult = filesToApprove[0] - const relPath = fileResult.path - const fullPath = path.resolve(cline.cwd, relPath) - const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) - const { maxReadFileLine = -1 } = (await cline.providerRef.deref()?.getState()) ?? {} - - // Create line snippet for approval message - let lineSnippet = "" - if (fileResult.lineRanges && fileResult.lineRanges.length > 0) { - const ranges = fileResult.lineRanges.map((range) => - t("tools:readFile.linesRange", { start: range.start, end: range.end }), - ) - lineSnippet = ranges.join(", ") - } else if (maxReadFileLine === 0) { - lineSnippet = t("tools:readFile.definitionsOnly") - } else if (maxReadFileLine > 0) { - lineSnippet = t("tools:readFile.maxLines", { max: maxReadFileLine }) - } - - const completeMessage = JSON.stringify({ - tool: "readFile", - path: getReadablePath(cline.cwd, relPath), - isOutsideWorkspace, - content: fullPath, - reason: lineSnippet, - } satisfies ClineSayTool) - - const { response, text, images } = await cline.ask("tool", completeMessage, false) - - if (response !== "yesButtonClicked") { - // Handle both messageResponse and noButtonClicked with text - if (text) { - await cline.say("user_feedback", text, images) - } - cline.didRejectTool = true - - updateFileResult(relPath, { - status: "denied", - xmlContent: `${relPath}Denied by user`, - feedbackText: text, - feedbackImages: images, - }) - } else { - // Handle yesButtonClicked with text - if (text) { - await cline.say("user_feedback", text, images) - } - - updateFileResult(relPath, { - status: "approved", - feedbackText: text, - feedbackImages: images, - }) - } - } - - // Track total image memory usage across all files - const imageMemoryTracker = new ImageMemoryTracker() - const state = await cline.providerRef.deref()?.getState() - const { - maxReadFileLine = -1, - maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, - maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, - } = state ?? {} - - // Then process only approved files - for (const fileResult of fileResults) { - // Skip files that weren't approved - if (fileResult.status !== "approved") { - continue - } - - const relPath = fileResult.path - const fullPath = path.resolve(cline.cwd, relPath) - - // Process approved files - try { - const [totalLines, isBinary] = await Promise.all([countFileLines(fullPath), isBinaryFile(fullPath)]) - - // Handle binary files (but allow specific file types that extractTextFromFile can handle) - if (isBinary) { - const fileExtension = path.extname(relPath).toLowerCase() - const supportedBinaryFormats = getSupportedBinaryFormats() - - // Check if it's a supported image format - if (isSupportedImageFormat(fileExtension)) { - try { - // Validate image for processing - const validationResult = await validateImageForProcessing( - fullPath, - supportsImages, - maxImageFileSize, - maxTotalImageSize, - imageMemoryTracker.getTotalMemoryUsed(), - ) - - if (!validationResult.isValid) { - // Track file read - await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - - updateFileResult(relPath, { - xmlContent: `${relPath}\n${validationResult.notice}\n`, - }) - continue - } - - // Process the image - const imageResult = await processImageFile(fullPath) - - // Track memory usage for this image - imageMemoryTracker.addMemoryUsage(imageResult.sizeInMB) - - // Track file read - await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - - // Store image data URL separately - NOT in XML - updateFileResult(relPath, { - xmlContent: `${relPath}\n${imageResult.notice}\n`, - imageDataUrl: imageResult.dataUrl, - }) - continue - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - updateFileResult(relPath, { - status: "error", - error: `Error reading image file: ${errorMsg}`, - xmlContent: `${relPath}Error reading image file: ${errorMsg}`, - }) - await handleError( - `reading image file ${relPath}`, - error instanceof Error ? error : new Error(errorMsg), - ) - continue - } - } - - // Check if it's a supported binary format that can be processed - if (supportedBinaryFormats && supportedBinaryFormats.includes(fileExtension)) { - // For supported binary formats (.pdf, .docx, .ipynb), continue to extractTextFromFile - // Fall through to the normal extractTextFromFile processing below - } else { - // Handle unknown binary format - const fileFormat = fileExtension.slice(1) || "bin" // Remove the dot, fallback to "bin" - updateFileResult(relPath, { - notice: `Binary file format: ${fileFormat}`, - xmlContent: `${relPath}\nBinary file - content not displayed\n`, - }) - continue - } - } - - // Handle range reads (bypass maxReadFileLine) - if (fileResult.lineRanges && fileResult.lineRanges.length > 0) { - const rangeResults: string[] = [] - for (const range of fileResult.lineRanges) { - const content = addLineNumbers( - await readLines(fullPath, range.end - 1, range.start - 1), - range.start, - ) - const lineRangeAttr = ` lines="${range.start}-${range.end}"` - rangeResults.push(`\n${content}`) - } - updateFileResult(relPath, { - xmlContent: `${relPath}\n${rangeResults.join("\n")}\n`, - }) - continue - } - - // Handle definitions-only mode - if (maxReadFileLine === 0) { - try { - const defResult = await parseSourceCodeDefinitionsForFile(fullPath, cline.rooIgnoreController) - if (defResult) { - let xmlInfo = `Showing only ${maxReadFileLine} of ${totalLines} total lines. Use line_range if you need to read more lines\n` - updateFileResult(relPath, { - xmlContent: `${relPath}\n${defResult}\n${xmlInfo}`, - }) - } - } catch (error) { - if (error instanceof Error && error.message.startsWith("Unsupported language:")) { - console.warn(`[read_file] Warning: ${error.message}`) - } else { - console.error( - `[read_file] Unhandled error: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - continue - } - - // Handle files exceeding line threshold - if (maxReadFileLine > 0 && totalLines > maxReadFileLine) { - const content = addLineNumbers(await readLines(fullPath, maxReadFileLine - 1, 0)) - const lineRangeAttr = ` lines="1-${maxReadFileLine}"` - let xmlInfo = `\n${content}\n` - - try { - const defResult = await parseSourceCodeDefinitionsForFile(fullPath, cline.rooIgnoreController) - if (defResult) { - // Truncate definitions to match the truncated file content - const truncatedDefs = truncateDefinitionsToLineLimit(defResult, maxReadFileLine) - xmlInfo += `${truncatedDefs}\n` - } - xmlInfo += `Showing only ${maxReadFileLine} of ${totalLines} total lines. Use line_range if you need to read more lines\n` - updateFileResult(relPath, { - xmlContent: `${relPath}\n${xmlInfo}`, - }) - } catch (error) { - if (error instanceof Error && error.message.startsWith("Unsupported language:")) { - console.warn(`[read_file] Warning: ${error.message}`) - } else { - console.error( - `[read_file] Unhandled error: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - continue - } - - // Handle normal file read with token budget validation - const modelInfo = cline.api.getModel().info - const { contextTokens } = cline.getTokenUsage() - const contextWindow = modelInfo.contextWindow - - // Validate if file fits within token budget - const budgetResult = await validateFileTokenBudget(fullPath, contextWindow, contextTokens || 0) - - let content = await extractTextFromFile(fullPath) - let xmlInfo = "" - - if (budgetResult.shouldTruncate && budgetResult.maxChars !== undefined) { - // Truncate the content to fit budget or show preview for large files - const truncateResult = truncateFileContent( - content, - budgetResult.maxChars, - content.length, - budgetResult.isPreview, - ) - content = truncateResult.content - - // Reflect actual displayed line count after truncation (count ALL lines, including empty) - // Handle trailing newline: "line1\nline2\n" should be 2 lines, not 3 - let displayedLines = content.length === 0 ? 0 : content.split(/\r?\n/).length - if (displayedLines > 0 && content.endsWith("\n")) { - displayedLines-- - } - const lineRangeAttr = displayedLines > 0 ? ` lines="1-${displayedLines}"` : "" - xmlInfo = content.length > 0 ? `\n${content}\n` : `` - xmlInfo += `${truncateResult.notice}\n` - } else { - const lineRangeAttr = ` lines="1-${totalLines}"` - xmlInfo = totalLines > 0 ? `\n${content}\n` : `` - - if (totalLines === 0) { - xmlInfo += `File is empty\n` - } - } - - // Track file read - await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - - updateFileResult(relPath, { - xmlContent: `${relPath}\n${xmlInfo}`, - }) - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - updateFileResult(relPath, { - status: "error", - error: `Error reading file: ${errorMsg}`, - xmlContent: `${relPath}Error reading file: ${errorMsg}`, - }) - await handleError(`reading file ${relPath}`, error instanceof Error ? error : new Error(errorMsg)) - } - } - - // Generate final XML result from all file results - const xmlResults = fileResults.filter((result) => result.xmlContent).map((result) => result.xmlContent) - const filesXml = `\n${xmlResults.join("\n")}\n` - - // Collect all image data URLs from file results - const fileImageUrls = fileResults - .filter((result) => result.imageDataUrl) - .map((result) => result.imageDataUrl as string) - - // Process all feedback in a unified way without branching - let statusMessage = "" - let feedbackImages: any[] = [] - - // Handle denial with feedback (highest priority) - const deniedWithFeedback = fileResults.find((result) => result.status === "denied" && result.feedbackText) - - if (deniedWithFeedback && deniedWithFeedback.feedbackText) { - statusMessage = formatResponse.toolDeniedWithFeedback(deniedWithFeedback.feedbackText) - feedbackImages = deniedWithFeedback.feedbackImages || [] - } - // Handle generic denial - else if (cline.didRejectTool) { - statusMessage = formatResponse.toolDenied() - } - // Handle approval with feedback - else { - const approvedWithFeedback = fileResults.find( - (result) => result.status === "approved" && result.feedbackText, - ) - - if (approvedWithFeedback && approvedWithFeedback.feedbackText) { - statusMessage = formatResponse.toolApprovedWithFeedback(approvedWithFeedback.feedbackText) - feedbackImages = approvedWithFeedback.feedbackImages || [] - } - } - - // Combine all images: feedback images first, then file images - const allImages = [...feedbackImages, ...fileImageUrls] - - // Re-check if the model supports images before including them, in case it changed during execution. - const finalModelSupportsImages = cline.api.getModel().info.supportsImages ?? false - const imagesToInclude = finalModelSupportsImages ? allImages : [] - - // Push the result with appropriate formatting - if (statusMessage || imagesToInclude.length > 0) { - // Always use formatResponse.toolResult when we have a status message or images - const result = formatResponse.toolResult( - statusMessage || filesXml, - imagesToInclude.length > 0 ? imagesToInclude : undefined, - ) - - // Handle different return types from toolResult - if (typeof result === "string") { - if (statusMessage) { - pushToolResult(`${result}\n${filesXml}`) - } else { - pushToolResult(result) - } - } else { - // For block-based results, append the files XML as a text block if not already included - if (statusMessage) { - const textBlock = { type: "text" as const, text: filesXml } - pushToolResult([...result, textBlock]) - } else { - pushToolResult(result) - } - } - } else { - // No images or status message, just push the files XML - pushToolResult(filesXml) - } - } catch (error) { - // Handle all errors using per-file format for consistency - const relPath = fileEntries[0]?.path || "unknown" - const errorMsg = error instanceof Error ? error.message : String(error) - - // If we have file results, update the first one with the error - if (fileResults.length > 0) { - updateFileResult(relPath, { - status: "error", - error: `Error reading file: ${errorMsg}`, - xmlContent: `${relPath}Error reading file: ${errorMsg}`, - }) - } - - await handleError(`reading file ${relPath}`, error instanceof Error ? error : new Error(errorMsg)) - - // Generate final XML result from all file results - const xmlResults = fileResults.filter((result) => result.xmlContent).map((result) => result.xmlContent) - - pushToolResult(`\n${xmlResults.join("\n")}\n`) - } -} diff --git a/src/core/tools/runSlashCommandTool.ts b/src/core/tools/runSlashCommandTool.ts deleted file mode 100644 index 06ceb5f19c..0000000000 --- a/src/core/tools/runSlashCommandTool.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { Task } from "../task/Task" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" -import { formatResponse } from "../prompts/responses" -import { getCommand, getCommandNames } from "../../services/command/commands" -import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" - -export async function runSlashCommandTool( - task: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - // Check if run slash command experiment is enabled - const provider = task.providerRef.deref() - const state = await provider?.getState() - const isRunSlashCommandEnabled = experiments.isEnabled(state?.experiments ?? {}, EXPERIMENT_IDS.RUN_SLASH_COMMAND) - - if (!isRunSlashCommandEnabled) { - pushToolResult( - formatResponse.toolError( - "Run slash command is an experimental feature that must be enabled in settings. Please enable 'Run Slash Command' in the Experimental Settings section.", - ), - ) - return - } - - const commandName: string | undefined = block.params.command - const args: string | undefined = block.params.args - - try { - if (block.partial) { - const partialMessage = JSON.stringify({ - tool: "runSlashCommand", - command: removeClosingTag("command", commandName), - args: removeClosingTag("args", args), - }) - - await task.ask("tool", partialMessage, block.partial).catch(() => {}) - return - } else { - if (!commandName) { - task.consecutiveMistakeCount++ - task.recordToolError("run_slash_command") - pushToolResult(await task.sayAndCreateMissingParamError("run_slash_command", "command")) - return - } - - task.consecutiveMistakeCount = 0 - - // Get the command from the commands service - const command = await getCommand(task.cwd, commandName) - - if (!command) { - // Get available commands for error message - const availableCommands = await getCommandNames(task.cwd) - task.recordToolError("run_slash_command") - pushToolResult( - formatResponse.toolError( - `Command '${commandName}' not found. Available commands: ${availableCommands.join(", ") || "(none)"}`, - ), - ) - return - } - - const toolMessage = JSON.stringify({ - tool: "runSlashCommand", - command: commandName, - args: args, - source: command.source, - description: command.description, - }) - - const didApprove = await askApproval("tool", toolMessage) - - if (!didApprove) { - return - } - - // Build the result message - let result = `Command: /${commandName}` - - if (command.description) { - result += `\nDescription: ${command.description}` - } - - if (command.argumentHint) { - result += `\nArgument hint: ${command.argumentHint}` - } - - if (args) { - result += `\nProvided arguments: ${args}` - } - - result += `\nSource: ${command.source}` - result += `\n\n--- Command Content ---\n\n${command.content}` - - // Return the command content as the tool result - pushToolResult(result) - - return - } - } catch (error) { - await handleError("running slash command", error) - return - } -} diff --git a/src/core/tools/searchFilesTool.ts b/src/core/tools/searchFilesTool.ts deleted file mode 100644 index b6ee97f874..0000000000 --- a/src/core/tools/searchFilesTool.ts +++ /dev/null @@ -1,78 +0,0 @@ -import path from "path" - -import { Task } from "../task/Task" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" -import { ClineSayTool } from "../../shared/ExtensionMessage" -import { getReadablePath } from "../../utils/path" -import { isPathOutsideWorkspace } from "../../utils/pathUtils" -import { regexSearchFiles } from "../../services/ripgrep" - -export async function searchFilesTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const relDirPath: string | undefined = block.params.path - const regex: string | undefined = block.params.regex - const filePattern: string | undefined = block.params.file_pattern - - const absolutePath = relDirPath ? path.resolve(cline.cwd, relDirPath) : cline.cwd - const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) - - const sharedMessageProps: ClineSayTool = { - tool: "searchFiles", - path: getReadablePath(cline.cwd, removeClosingTag("path", relDirPath)), - regex: removeClosingTag("regex", regex), - filePattern: removeClosingTag("file_pattern", filePattern), - isOutsideWorkspace, - } - - try { - if (block.partial) { - const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "" } satisfies ClineSayTool) - await cline.ask("tool", partialMessage, block.partial).catch(() => {}) - return - } else { - if (!relDirPath) { - cline.consecutiveMistakeCount++ - cline.recordToolError("search_files") - pushToolResult(await cline.sayAndCreateMissingParamError("search_files", "path")) - return - } - - if (!regex) { - cline.consecutiveMistakeCount++ - cline.recordToolError("search_files") - pushToolResult(await cline.sayAndCreateMissingParamError("search_files", "regex")) - return - } - - cline.consecutiveMistakeCount = 0 - - const results = await regexSearchFiles( - cline.cwd, - absolutePath, - regex, - filePattern, - cline.rooIgnoreController, - ) - - const completeMessage = JSON.stringify({ ...sharedMessageProps, content: results } satisfies ClineSayTool) - const didApprove = await askApproval("tool", completeMessage) - - if (!didApprove) { - return - } - - pushToolResult(results) - - return - } - } catch (error) { - await handleError("searching files", error) - return - } -} diff --git a/src/core/tools/simpleReadFileTool.ts b/src/core/tools/simpleReadFileTool.ts index ee6656c5c8..1b41e9e9d6 100644 --- a/src/core/tools/simpleReadFileTool.ts +++ b/src/core/tools/simpleReadFileTool.ts @@ -13,6 +13,7 @@ import { countFileLines } from "../../integrations/misc/line-counter" import { readLines } from "../../integrations/misc/read-lines" import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text" import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter" +import { ToolProtocol, isNativeProtocol } from "@roo-code/types" import { DEFAULT_MAX_IMAGE_FILE_SIZE_MB, DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, @@ -38,6 +39,7 @@ export async function simpleReadFileTool( handleError: HandleError, pushToolResult: PushToolResult, _removeClosingTag: RemoveClosingTag, + toolProtocol?: ToolProtocol, ) { const filePath: string | undefined = block.params.path diff --git a/src/core/tools/switchModeTool.ts b/src/core/tools/switchModeTool.ts deleted file mode 100644 index 8ce906b41f..0000000000 --- a/src/core/tools/switchModeTool.ts +++ /dev/null @@ -1,81 +0,0 @@ -import delay from "delay" - -import { Task } from "../task/Task" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" -import { formatResponse } from "../prompts/responses" -import { defaultModeSlug, getModeBySlug } from "../../shared/modes" - -export async function switchModeTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const mode_slug: string | undefined = block.params.mode_slug - const reason: string | undefined = block.params.reason - - try { - if (block.partial) { - const partialMessage = JSON.stringify({ - tool: "switchMode", - mode: removeClosingTag("mode_slug", mode_slug), - reason: removeClosingTag("reason", reason), - }) - - await cline.ask("tool", partialMessage, block.partial).catch(() => {}) - return - } else { - if (!mode_slug) { - cline.consecutiveMistakeCount++ - cline.recordToolError("switch_mode") - pushToolResult(await cline.sayAndCreateMissingParamError("switch_mode", "mode_slug")) - return - } - - cline.consecutiveMistakeCount = 0 - - // Verify the mode exists - const targetMode = getModeBySlug(mode_slug, (await cline.providerRef.deref()?.getState())?.customModes) - - if (!targetMode) { - cline.recordToolError("switch_mode") - pushToolResult(formatResponse.toolError(`Invalid mode: ${mode_slug}`)) - return - } - - // Check if already in requested mode - const currentMode = (await cline.providerRef.deref()?.getState())?.mode ?? defaultModeSlug - - if (currentMode === mode_slug) { - cline.recordToolError("switch_mode") - pushToolResult(`Already in ${targetMode.name} mode.`) - return - } - - const completeMessage = JSON.stringify({ tool: "switchMode", mode: mode_slug, reason }) - const didApprove = await askApproval("tool", completeMessage) - - if (!didApprove) { - return - } - - // Switch the mode using shared handler - await cline.providerRef.deref()?.handleModeSwitch(mode_slug) - - pushToolResult( - `Successfully switched from ${getModeBySlug(currentMode)?.name ?? currentMode} mode to ${ - targetMode.name - } mode${reason ? ` because: ${reason}` : ""}.`, - ) - - await delay(500) // Delay to allow mode change to take effect before next tool is executed - - return - } - } catch (error) { - await handleError("switching mode", error) - return - } -} diff --git a/src/core/tools/useMcpToolTool.ts b/src/core/tools/useMcpToolTool.ts deleted file mode 100644 index 41697ab979..0000000000 --- a/src/core/tools/useMcpToolTool.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { Task } from "../task/Task" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" -import { formatResponse } from "../prompts/responses" -import { ClineAskUseMcpServer } from "../../shared/ExtensionMessage" -import { McpExecutionStatus } from "@roo-code/types" -import { t } from "../../i18n" - -interface McpToolParams { - server_name?: string - tool_name?: string - arguments?: string -} - -type ValidationResult = - | { isValid: false } - | { - isValid: true - serverName: string - toolName: string - parsedArguments?: Record - } - -async function handlePartialRequest( - cline: Task, - params: McpToolParams, - removeClosingTag: RemoveClosingTag, -): Promise { - const partialMessage = JSON.stringify({ - type: "use_mcp_tool", - serverName: removeClosingTag("server_name", params.server_name), - toolName: removeClosingTag("tool_name", params.tool_name), - arguments: removeClosingTag("arguments", params.arguments), - } satisfies ClineAskUseMcpServer) - - await cline.ask("use_mcp_server", partialMessage, true).catch(() => {}) -} - -async function validateParams( - cline: Task, - params: McpToolParams, - pushToolResult: PushToolResult, -): Promise { - if (!params.server_name) { - cline.consecutiveMistakeCount++ - cline.recordToolError("use_mcp_tool") - pushToolResult(await cline.sayAndCreateMissingParamError("use_mcp_tool", "server_name")) - return { isValid: false } - } - - if (!params.tool_name) { - cline.consecutiveMistakeCount++ - cline.recordToolError("use_mcp_tool") - pushToolResult(await cline.sayAndCreateMissingParamError("use_mcp_tool", "tool_name")) - return { isValid: false } - } - - let parsedArguments: Record | undefined - - if (params.arguments) { - try { - parsedArguments = JSON.parse(params.arguments) - } catch (error) { - cline.consecutiveMistakeCount++ - cline.recordToolError("use_mcp_tool") - await cline.say("error", t("mcp:errors.invalidJsonArgument", { toolName: params.tool_name })) - - pushToolResult( - formatResponse.toolError( - formatResponse.invalidMcpToolArgumentError(params.server_name, params.tool_name), - ), - ) - return { isValid: false } - } - } - - return { - isValid: true, - serverName: params.server_name, - toolName: params.tool_name, - parsedArguments, - } -} - -async function validateToolExists( - cline: Task, - serverName: string, - toolName: string, - pushToolResult: PushToolResult, -): Promise<{ isValid: boolean; availableTools?: string[] }> { - try { - // Get the MCP hub to access server information - const provider = cline.providerRef.deref() - const mcpHub = provider?.getMcpHub() - - if (!mcpHub) { - // If we can't get the MCP hub, we can't validate, so proceed with caution - return { isValid: true } - } - - // Get all servers to find the specific one - const servers = mcpHub.getAllServers() - const server = servers.find((s) => s.name === serverName) - - if (!server) { - // Fail fast when server is unknown - const availableServersArray = servers.map((s) => s.name) - const availableServers = - availableServersArray.length > 0 ? availableServersArray.join(", ") : "No servers available" - - cline.consecutiveMistakeCount++ - cline.recordToolError("use_mcp_tool") - await cline.say("error", t("mcp:errors.serverNotFound", { serverName, availableServers })) - - pushToolResult(formatResponse.unknownMcpServerError(serverName, availableServersArray)) - return { isValid: false, availableTools: [] } - } - - // Check if the server has tools defined - if (!server.tools || server.tools.length === 0) { - // No tools available on this server - cline.consecutiveMistakeCount++ - cline.recordToolError("use_mcp_tool") - await cline.say( - "error", - t("mcp:errors.toolNotFound", { - toolName, - serverName, - availableTools: "No tools available", - }), - ) - - pushToolResult(formatResponse.unknownMcpToolError(serverName, toolName, [])) - return { isValid: false, availableTools: [] } - } - - // Check if the requested tool exists - const tool = server.tools.find((tool) => tool.name === toolName) - - if (!tool) { - // Tool not found - provide list of available tools - const availableToolNames = server.tools.map((tool) => tool.name) - - cline.consecutiveMistakeCount++ - cline.recordToolError("use_mcp_tool") - await cline.say( - "error", - t("mcp:errors.toolNotFound", { - toolName, - serverName, - availableTools: availableToolNames.join(", "), - }), - ) - - pushToolResult(formatResponse.unknownMcpToolError(serverName, toolName, availableToolNames)) - return { isValid: false, availableTools: availableToolNames } - } - - // Check if the tool is disabled (enabledForPrompt is false) - if (tool.enabledForPrompt === false) { - // Tool is disabled - only show enabled tools - const enabledTools = server.tools.filter((t) => t.enabledForPrompt !== false) - const enabledToolNames = enabledTools.map((t) => t.name) - - cline.consecutiveMistakeCount++ - cline.recordToolError("use_mcp_tool") - await cline.say( - "error", - t("mcp:errors.toolDisabled", { - toolName, - serverName, - availableTools: - enabledToolNames.length > 0 ? enabledToolNames.join(", ") : "No enabled tools available", - }), - ) - - pushToolResult(formatResponse.unknownMcpToolError(serverName, toolName, enabledToolNames)) - return { isValid: false, availableTools: enabledToolNames } - } - - // Tool exists and is enabled - return { isValid: true, availableTools: server.tools.map((tool) => tool.name) } - } catch (error) { - // If there's an error during validation, log it but don't block the tool execution - // The actual tool call might still fail with a proper error - console.error("Error validating MCP tool existence:", error) - return { isValid: true } - } -} - -async function sendExecutionStatus(cline: Task, status: McpExecutionStatus): Promise { - const clineProvider = await cline.providerRef.deref() - clineProvider?.postMessageToWebview({ - type: "mcpExecutionStatus", - text: JSON.stringify(status), - }) -} - -function processToolContent(toolResult: any): string { - if (!toolResult?.content || toolResult.content.length === 0) { - return "" - } - - return toolResult.content - .map((item: any) => { - if (item.type === "text") { - return item.text - } - if (item.type === "resource") { - const { blob: _, ...rest } = item.resource - return JSON.stringify(rest, null, 2) - } - return "" - }) - .filter(Boolean) - .join("\n\n") -} - -async function executeToolAndProcessResult( - cline: Task, - serverName: string, - toolName: string, - parsedArguments: Record | undefined, - executionId: string, - pushToolResult: PushToolResult, -): Promise { - await cline.say("mcp_server_request_started") - - // Send started status - await sendExecutionStatus(cline, { - executionId, - status: "started", - serverName, - toolName, - }) - - const toolResult = await cline.providerRef.deref()?.getMcpHub()?.callTool(serverName, toolName, parsedArguments) - - let toolResultPretty = "(No response)" - - if (toolResult) { - const outputText = processToolContent(toolResult) - - if (outputText) { - await sendExecutionStatus(cline, { - executionId, - status: "output", - response: outputText, - }) - - toolResultPretty = (toolResult.isError ? "Error:\n" : "") + outputText - } - - // Send completion status - await sendExecutionStatus(cline, { - executionId, - status: toolResult.isError ? "error" : "completed", - response: toolResultPretty, - error: toolResult.isError ? "Error executing MCP tool" : undefined, - }) - } else { - // Send error status if no result - await sendExecutionStatus(cline, { - executionId, - status: "error", - error: "No response from MCP server", - }) - } - - await cline.say("mcp_server_response", toolResultPretty) - pushToolResult(formatResponse.toolResult(toolResultPretty)) -} - -export async function useMcpToolTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - try { - const params: McpToolParams = { - server_name: block.params.server_name, - tool_name: block.params.tool_name, - arguments: block.params.arguments, - } - - // Handle partial requests - if (block.partial) { - await handlePartialRequest(cline, params, removeClosingTag) - return - } - - // Validate parameters - const validation = await validateParams(cline, params, pushToolResult) - if (!validation.isValid) { - return - } - - const { serverName, toolName, parsedArguments } = validation - - // Validate that the tool exists on the server - const toolValidation = await validateToolExists(cline, serverName, toolName, pushToolResult) - if (!toolValidation.isValid) { - return - } - - // Reset mistake count on successful validation - cline.consecutiveMistakeCount = 0 - - // Get user approval - const completeMessage = JSON.stringify({ - type: "use_mcp_tool", - serverName, - toolName, - arguments: params.arguments, - } satisfies ClineAskUseMcpServer) - - const executionId = cline.lastMessageTs?.toString() ?? Date.now().toString() - const didApprove = await askApproval("use_mcp_server", completeMessage) - - if (!didApprove) { - return - } - - // Execute the tool and process results - await executeToolAndProcessResult(cline, serverName!, toolName!, parsedArguments, executionId, pushToolResult) - } catch (error) { - await handleError("executing MCP tool", error) - } -} diff --git a/src/core/tools/writeToFileTool.ts b/src/core/tools/writeToFileTool.ts deleted file mode 100644 index b8e6da0caa..0000000000 --- a/src/core/tools/writeToFileTool.ts +++ /dev/null @@ -1,331 +0,0 @@ -import path from "path" -import delay from "delay" -import * as vscode from "vscode" -import fs from "fs/promises" - -import { Task } from "../task/Task" -import { ClineSayTool } from "../../shared/ExtensionMessage" -import { formatResponse } from "../prompts/responses" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" -import { RecordSource } from "../context-tracking/FileContextTrackerTypes" -import { fileExistsAtPath } from "../../utils/fs" -import { stripLineNumbers, everyLineHasLineNumbers } from "../../integrations/misc/extract-text" -import { getReadablePath } from "../../utils/path" -import { isPathOutsideWorkspace } from "../../utils/pathUtils" -import { detectCodeOmission } from "../../integrations/editor/detect-omission" -import { unescapeHtmlEntities } from "../../utils/text-normalization" -import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" -import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" -import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" - -export async function writeToFileTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const relPath: string | undefined = block.params.path - let newContent: string | undefined = block.params.content - let predictedLineCount: number | undefined = parseInt(block.params.line_count ?? "0") - - if (block.partial && (!relPath || newContent === undefined)) { - // checking for newContent ensure relPath is complete - // wait so we can determine if it's a new file or editing an existing file - return - } - - if (!relPath) { - cline.consecutiveMistakeCount++ - cline.recordToolError("write_to_file") - pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "path")) - await cline.diffViewProvider.reset() - return - } - - if (newContent === undefined) { - cline.consecutiveMistakeCount++ - cline.recordToolError("write_to_file") - pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "content")) - await cline.diffViewProvider.reset() - return - } - - const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) - - if (!accessAllowed) { - await cline.say("rooignore_error", relPath) - pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath))) - return - } - - // Check if file is write-protected - const isWriteProtected = cline.rooProtectedController?.isWriteProtected(relPath) || false - - // Check if file exists using cached map or fs.access - let fileExists: boolean - - if (cline.diffViewProvider.editType !== undefined) { - fileExists = cline.diffViewProvider.editType === "modify" - } else { - const absolutePath = path.resolve(cline.cwd, relPath) - fileExists = await fileExistsAtPath(absolutePath) - cline.diffViewProvider.editType = fileExists ? "modify" : "create" - } - - // pre-processing newContent for cases where weaker models might add artifacts like markdown codeblock markers (deepseek/llama) or extra escape characters (gemini) - if (newContent.startsWith("```")) { - // cline handles cases where it includes language specifiers like ```python ```js - newContent = newContent.split("\n").slice(1).join("\n") - } - - if (newContent.endsWith("```")) { - newContent = newContent.split("\n").slice(0, -1).join("\n") - } - - if (!cline.api.getModel().id.includes("claude")) { - newContent = unescapeHtmlEntities(newContent) - } - - // Determine if the path is outside the workspace - const fullPath = relPath ? path.resolve(cline.cwd, removeClosingTag("path", relPath)) : "" - const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) - - const sharedMessageProps: ClineSayTool = { - tool: fileExists ? "editedExistingFile" : "newFileCreated", - path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)), - content: newContent, - isOutsideWorkspace, - isProtected: isWriteProtected, - } - - try { - if (block.partial) { - // Check if preventFocusDisruption experiment is enabled - const provider = cline.providerRef.deref() - const state = await provider?.getState() - const isPreventFocusDisruptionEnabled = experiments.isEnabled( - state?.experiments ?? {}, - EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, - ) - - if (!isPreventFocusDisruptionEnabled) { - // update gui message - const partialMessage = JSON.stringify(sharedMessageProps) - await cline.ask("tool", partialMessage, block.partial).catch(() => {}) - - // update editor - if (!cline.diffViewProvider.isEditing) { - // open the editor and prepare to stream content in - await cline.diffViewProvider.open(relPath) - } - - // editor is open, stream content in - await cline.diffViewProvider.update( - everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, - false, - ) - } - - return - } else { - if (predictedLineCount === undefined) { - cline.consecutiveMistakeCount++ - cline.recordToolError("write_to_file") - - // Calculate the actual number of lines in the content - const actualLineCount = newContent.split("\n").length - - // Check if this is a new file or existing file - const isNewFile = !fileExists - - // Check if diffStrategy is enabled - const diffStrategyEnabled = !!cline.diffStrategy - - // Use more specific error message for line_count that provides guidance based on the situation - await cline.say( - "error", - `Roo tried to use write_to_file${ - relPath ? ` for '${relPath.toPosix()}'` : "" - } but the required parameter 'line_count' was missing or truncated after ${actualLineCount} lines of content were written. Retrying...`, - ) - - pushToolResult( - formatResponse.toolError( - formatResponse.lineCountTruncationError(actualLineCount, isNewFile, diffStrategyEnabled), - ), - ) - await cline.diffViewProvider.revertChanges() - return - } - - cline.consecutiveMistakeCount = 0 - - // Check if preventFocusDisruption experiment is enabled - const provider = cline.providerRef.deref() - const state = await provider?.getState() - const diagnosticsEnabled = state?.diagnosticsEnabled ?? true - const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS - const isPreventFocusDisruptionEnabled = experiments.isEnabled( - state?.experiments ?? {}, - EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, - ) - - if (isPreventFocusDisruptionEnabled) { - // Direct file write without diff view - // Set up diffViewProvider properties needed for diff generation and saveDirectly - cline.diffViewProvider.editType = fileExists ? "modify" : "create" - if (fileExists) { - const absolutePath = path.resolve(cline.cwd, relPath) - cline.diffViewProvider.originalContent = await fs.readFile(absolutePath, "utf-8") - } else { - cline.diffViewProvider.originalContent = "" - } - - // Check for code omissions before proceeding - if (detectCodeOmission(cline.diffViewProvider.originalContent || "", newContent, predictedLineCount)) { - if (cline.diffStrategy) { - pushToolResult( - formatResponse.toolError( - `Content appears to be truncated (file has ${ - newContent.split("\n").length - } lines but was predicted to have ${predictedLineCount} lines), and found comments indicating omitted code (e.g., '// rest of code unchanged', '/* previous code */'). Please provide the complete file content without any omissions if possible, or otherwise use the 'apply_diff' tool to apply the diff to the original file.`, - ), - ) - return - } else { - vscode.window - .showWarningMessage( - "Potential code truncation detected. cline happens when the AI reaches its max output limit.", - "Follow cline guide to fix the issue", - ) - .then((selection) => { - if (selection === "Follow cline guide to fix the issue") { - vscode.env.openExternal( - vscode.Uri.parse( - "https://github.com/cline/cline/wiki/Troubleshooting-%E2%80%90-Cline-Deleting-Code-with-%22Rest-of-Code-Here%22-Comments", - ), - ) - } - }) - } - } - - // Build unified diff for both existing and new files - let unified = fileExists - ? formatResponse.createPrettyPatch(relPath, cline.diffViewProvider.originalContent, newContent) - : convertNewFileToUnifiedDiff(newContent, relPath) - unified = sanitizeUnifiedDiff(unified) - const completeMessage = JSON.stringify({ - ...sharedMessageProps, - content: unified, - diffStats: computeDiffStats(unified) || undefined, - } satisfies ClineSayTool) - - const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) - - if (!didApprove) { - return - } - - // Save directly without showing diff view or opening the file - await cline.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) - } else { - // Original behavior with diff view - // if isEditingFile false, that means we have the full contents of the file already. - // it's important to note how cline function works, you can't make the assumption that the block.partial conditional will always be called since it may immediately get complete, non-partial data. So cline part of the logic will always be called. - // in other words, you must always repeat the block.partial logic here - if (!cline.diffViewProvider.isEditing) { - // show gui message before showing edit animation - const partialMessage = JSON.stringify(sharedMessageProps) - await cline.ask("tool", partialMessage, true).catch(() => {}) // sending true for partial even though it's not a partial, cline shows the edit row before the content is streamed into the editor - await cline.diffViewProvider.open(relPath) - } - - await cline.diffViewProvider.update( - everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, - true, - ) - - await delay(300) // wait for diff view to update - cline.diffViewProvider.scrollToFirstDiff() - - // Check for code omissions before proceeding - if (detectCodeOmission(cline.diffViewProvider.originalContent || "", newContent, predictedLineCount)) { - if (cline.diffStrategy) { - await cline.diffViewProvider.revertChanges() - - pushToolResult( - formatResponse.toolError( - `Content appears to be truncated (file has ${ - newContent.split("\n").length - } lines but was predicted to have ${predictedLineCount} lines), and found comments indicating omitted code (e.g., '// rest of code unchanged', '/* previous code */'). Please provide the complete file content without any omissions if possible, or otherwise use the 'apply_diff' tool to apply the diff to the original file.`, - ), - ) - return - } else { - vscode.window - .showWarningMessage( - "Potential code truncation detected. cline happens when the AI reaches its max output limit.", - "Follow cline guide to fix the issue", - ) - .then((selection) => { - if (selection === "Follow cline guide to fix the issue") { - vscode.env.openExternal( - vscode.Uri.parse( - "https://github.com/cline/cline/wiki/Troubleshooting-%E2%80%90-Cline-Deleting-Code-with-%22Rest-of-Code-Here%22-Comments", - ), - ) - } - }) - } - } - - // Build unified diff for both existing and new files - let unified = fileExists - ? formatResponse.createPrettyPatch(relPath, cline.diffViewProvider.originalContent, newContent) - : convertNewFileToUnifiedDiff(newContent, relPath) - unified = sanitizeUnifiedDiff(unified) - const completeMessage = JSON.stringify({ - ...sharedMessageProps, - content: unified, - diffStats: computeDiffStats(unified) || undefined, - } satisfies ClineSayTool) - - const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) - - if (!didApprove) { - await cline.diffViewProvider.revertChanges() - return - } - - // Call saveChanges to update the DiffViewProvider properties - await cline.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) - } - - // Track file edit operation - if (relPath) { - await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) - } - - cline.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request - - // Get the formatted response message - const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) - - pushToolResult(message) - - await cline.diffViewProvider.reset() - - // Process any queued messages after file edit completes - cline.processQueuedMessages() - - return - } - } catch (error) { - await handleError("writing file", error) - await cline.diffViewProvider.reset() - return - } -} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 828c7da99c..ff97d5f030 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -151,7 +151,7 @@ export class ClineProvider public isViewLaunched = false public settingsImportedAt?: number - public readonly latestAnnouncementId = "nov-2025-v3.30.0-pr-fixer" // v3.30.0 PR Fixer announcement + public readonly latestAnnouncementId = "nov-2025-v3.33.0-gemini-native-tools" // v3.33.0 Gemini 3 & Native Tools announcement public readonly providerSettingsManager: ProviderSettingsManager public readonly customModesManager: CustomModesManager @@ -1196,7 +1196,7 @@ export class ClineProvider - +