diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index df08a36de2..8f1a75d01c 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,5 @@ blank_issues_enabled: false contact_links: - - name: Feature Request - url: https://github.com/zgsm-ai/costrict/discussions/categories/feature-requests - about: Share and vote on feature requests for CoStrict - name: Leave a Review url: https://marketplace.visualstudio.com/items?itemName=zgsm-ai.zgsm&ssr=false#review-details about: Enjoying CoStrict? Leave a review here! diff --git a/apps/cli/src/ui/components/tools/types.ts b/apps/cli/src/ui/components/tools/types.ts index 28a1b5faa0..a16fbd60ea 100644 --- a/apps/cli/src/ui/components/tools/types.ts +++ b/apps/cli/src/ui/components/tools/types.ts @@ -16,15 +16,7 @@ export type ToolCategory = | "other" export function getToolCategory(toolName: string): ToolCategory { - const fileReadTools = [ - "readFile", - "read_file", - "fetchInstructions", - "fetch_instructions", - "listFilesTopLevel", - "listFilesRecursive", - "list_files", - ] + const fileReadTools = ["readFile", "read_file", "skill", "listFilesTopLevel", "listFilesRecursive", "list_files"] const fileWriteTools = [ "editedExistingFile", diff --git a/apps/cli/src/ui/components/tools/utils.ts b/apps/cli/src/ui/components/tools/utils.ts index 5eaee33b12..31acf2cccb 100644 --- a/apps/cli/src/ui/components/tools/utils.ts +++ b/apps/cli/src/ui/components/tools/utils.ts @@ -50,8 +50,7 @@ export function getToolDisplayName(toolName: string): string { // File read operations readFile: "Read", read_file: "Read", - fetchInstructions: "Fetch Instructions", - fetch_instructions: "Fetch Instructions", + skill: "Load Skill", listFilesTopLevel: "List Files", listFilesRecursive: "List Files (Recursive)", list_files: "List Files", @@ -107,8 +106,7 @@ export function getToolIconName(toolName: string): IconName { // File read operations readFile: "file", read_file: "file", - fetchInstructions: "file", - fetch_instructions: "file", + skill: "file", listFilesTopLevel: "folder", listFilesRecursive: "folder", list_files: "folder", diff --git a/apps/vscode-e2e/src/suite/tools/read-file.test.ts b/apps/vscode-e2e/src/suite/tools/read-file.test.ts index 00aca7f58a..6f3e28f60f 100644 --- a/apps/vscode-e2e/src/suite/tools/read-file.test.ts +++ b/apps/vscode-e2e/src/suite/tools/read-file.test.ts @@ -376,7 +376,7 @@ suite.skip("Roo Code read_file Tool", function () { } }) - test("Should read file with line range", async function () { + test("Should read file with slice offset/limit", async function () { const api = globalThis.api const messages: ClineMessage[] = [] let taskCompleted = false @@ -446,7 +446,7 @@ suite.skip("Roo Code read_file Tool", function () { alwaysAllowReadOnly: true, alwaysAllowReadOnlyOutsideWorkspace: true, }, - text: `Use the read_file tool to read the file "${fileName}" and show me what's on lines 2, 3, and 4. The file contains lines like "Line 1", "Line 2", etc. Assume the file exists and you can read it directly.`, + text: `Use the read_file tool to read the file "${fileName}" using slice mode with offset=2 and limit=3 (1-based offset). The file contains lines like "Line 1", "Line 2", etc. After reading, show me the three lines you read.`, }) // Wait for task completion @@ -455,9 +455,8 @@ suite.skip("Roo Code read_file Tool", function () { // Verify tool was executed assert.ok(toolExecuted, "The read_file tool should have been executed") - // Verify the tool returned the correct lines (when line range is used) + // Verify the tool returned the correct lines (offset=2, limit=3 -> lines 2-4) if (toolResult && (toolResult as string).includes(" | ")) { - // The result includes line numbers assert.ok( (toolResult as string).includes("2 | Line 2"), "Tool result should include line 2 with line number", diff --git a/apps/web-evals/next-env.d.ts b/apps/web-evals/next-env.d.ts index 1b3be0840f..7506fe6afb 100644 --- a/apps/web-evals/next-env.d.ts +++ b/apps/web-evals/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +import "./.next/dev/types/routes.d.ts" // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web-evals/next.config.ts b/apps/web-evals/next.config.ts index 08ed853fc3..b5f54a87be 100644 --- a/apps/web-evals/next.config.ts +++ b/apps/web-evals/next.config.ts @@ -1,10 +1,7 @@ import type { NextConfig } from "next" const nextConfig: NextConfig = { - webpack: (config) => { - config.resolve.extensionAlias = { ".js": [".ts", ".tsx", ".js", ".jsx"] } - return config - }, + turbopack: {}, } export default nextConfig diff --git a/apps/web-evals/package.json b/apps/web-evals/package.json index 9ba2c98c2c..0a721bf36c 100644 --- a/apps/web-evals/package.json +++ b/apps/web-evals/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "lint": "next lint --max-warnings 0", + "lint": "eslint src --ext=ts,tsx --max-warnings=0", "check-types": "tsc -b", "dev": "scripts/check-services.sh && next dev -p 3446", "format": "prettier --write src", @@ -27,7 +27,7 @@ "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.2.8", "@roo-code/evals": "workspace:^", - "@roo-code/types": "workspace:^", + "@roo-code/types": "^1.108.0", "@tanstack/react-query": "^5.69.0", "archiver": "^7.0.1", "class-variance-authority": "^0.7.1", @@ -35,7 +35,7 @@ "cmdk": "^1.1.0", "fuzzysort": "^3.1.0", "lucide-react": "^0.518.0", - "next": "~15.2.8", + "next": "^16.1.6", "next-themes": "^0.4.6", "p-map": "^7.0.3", "react": "^18.3.1", diff --git a/apps/web-roo-code/next.config.ts b/apps/web-roo-code/next.config.ts index a2591c1a30..0aaf2849d5 100644 --- a/apps/web-roo-code/next.config.ts +++ b/apps/web-roo-code/next.config.ts @@ -1,9 +1,9 @@ +import path from "path" import type { NextConfig } from "next" const nextConfig: NextConfig = { - webpack: (config) => { - config.resolve.extensionAlias = { ".js": [".ts", ".tsx", ".js", ".jsx"] } - return config + turbopack: { + root: path.join(__dirname, "../.."), }, async redirects() { return [ diff --git a/apps/web-roo-code/package.json b/apps/web-roo-code/package.json index d82cad56ab..90b6e9e306 100644 --- a/apps/web-roo-code/package.json +++ b/apps/web-roo-code/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "lint": "next lint --max-warnings 0", + "lint": "eslint src --ext=ts,tsx --max-warnings=0", "check-types": "tsc --noEmit", "dev": "next dev", "build": "next build", @@ -12,22 +12,23 @@ "clean": "rimraf .next .turbo" }, "dependencies": { - "@radix-ui/react-dialog": "^1.1.14", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-slot": "^1.2.4", "@roo-code/evals": "workspace:^", - "@roo-code/types": "workspace:^", - "@tanstack/react-query": "^5.79.0", - "@vercel/og": "^0.6.2", + "@roo-code/types": "^1.108.0", + "@tanstack/react-query": "^5.90.20", + "@vercel/og": "^0.8.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "embla-carousel-auto-scroll": "^8.6.0", "embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^8.6.0", - "framer-motion": "12.15.0", - "lucide-react": "^0.518.0", - "next": "~15.2.8", + "framer-motion": "^12.29.2", + "lucide-react": "^0.563.0", + "next": "^16.1.6", "next-themes": "^0.4.6", - "posthog-js": "^1.248.1", + "posthog-js": "^1.336.4", "react": "^18.3.1", "react-cookie-consent": "^9.0.0", "react-dom": "^18.3.1", @@ -36,7 +37,7 @@ "recharts": "^2.15.3", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", - "tailwind-merge": "^3.3.0", + "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", "tldts": "^6.1.86", "zod": "^3.25.61" @@ -44,13 +45,13 @@ "devDependencies": { "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", - "@tailwindcss/typography": "^0.5.16", + "@tailwindcss/typography": "^0.5.19", "@types/node": "20.x", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", - "autoprefixer": "^10.4.21", + "autoprefixer": "^10.4.23", "next-sitemap": "^4.2.3", - "postcss": "^8.5.4", + "postcss": "^8.5.6", "tailwindcss": "^3.4.17" } } diff --git a/apps/web-roo-code/src/app/cloud/page.tsx b/apps/web-roo-code/src/app/cloud/page.tsx index 1da9cad2af..68d3c3d2bc 100644 --- a/apps/web-roo-code/src/app/cloud/page.tsx +++ b/apps/web-roo-code/src/app/cloud/page.tsx @@ -22,7 +22,7 @@ import { SEO } from "@/lib/seo" import { ogImageUrl } from "@/lib/og" import { EXTERNAL_LINKS } from "@/lib/constants" // Workaround for next/image choking on these for some reason -import screenshotDark from "/public/heroes/cloud-screen.png" +import screenshotDark from "../../../public/heroes/cloud-screen.png" const TITLE = "Roo Code Cloud" const DESCRIPTION = diff --git a/apps/web-roo-code/src/app/pr-fixer/content-a.tsx b/apps/web-roo-code/src/app/pr-fixer/content-a.tsx index 1935ca2774..c3c1a6dadd 100644 --- a/apps/web-roo-code/src/app/pr-fixer/content-a.tsx +++ b/apps/web-roo-code/src/app/pr-fixer/content-a.tsx @@ -2,7 +2,7 @@ import { type AgentPageContent } from "@/app/shared/agent-page-content" import Link from "next/link" // Workaround for next/image choking on these for some reason -import hero from "/public/heroes/agent-pr-fixer.png" +import hero from "../../../public/heroes/agent-pr-fixer.png" // Re-export for convenience export type { AgentPageContent } diff --git a/apps/web-roo-code/src/app/provider/page.tsx b/apps/web-roo-code/src/app/provider/page.tsx index 6caf1e6928..b42e48cb2e 100644 --- a/apps/web-roo-code/src/app/provider/page.tsx +++ b/apps/web-roo-code/src/app/provider/page.tsx @@ -252,7 +252,7 @@ export default function ProviderPage() { {faqs.map((faq, index) => (

{faq.question}

-

{faq.answer}

+
{faq.answer}
))} diff --git a/apps/web-roo-code/src/app/reviewer/content-b.ts b/apps/web-roo-code/src/app/reviewer/content-b.ts index 0c2f76a2f5..173e654627 100644 --- a/apps/web-roo-code/src/app/reviewer/content-b.ts +++ b/apps/web-roo-code/src/app/reviewer/content-b.ts @@ -1,7 +1,7 @@ import { type AgentPageContent } from "@/app/shared/agent-page-content" // Workaround for next/image choking on these for some reason -import hero from "/public/heroes/agent-reviewer.png" +import hero from "../../../public/heroes/agent-reviewer.png" // Re-export for convenience export type { AgentPageContent } diff --git a/apps/web-roo-code/src/app/reviewer/content.ts b/apps/web-roo-code/src/app/reviewer/content.ts index 0c2f76a2f5..173e654627 100644 --- a/apps/web-roo-code/src/app/reviewer/content.ts +++ b/apps/web-roo-code/src/app/reviewer/content.ts @@ -1,7 +1,7 @@ import { type AgentPageContent } from "@/app/shared/agent-page-content" // Workaround for next/image choking on these for some reason -import hero from "/public/heroes/agent-reviewer.png" +import hero from "../../../public/heroes/agent-reviewer.png" // Re-export for convenience export type { AgentPageContent } diff --git a/apps/web-roo-code/src/components/chromes/nav-bar.tsx b/apps/web-roo-code/src/components/chromes/nav-bar.tsx index fda49dfc87..e09836a45c 100644 --- a/apps/web-roo-code/src/components/chromes/nav-bar.tsx +++ b/apps/web-roo-code/src/components/chromes/nav-bar.tsx @@ -13,7 +13,17 @@ import { EXTERNAL_LINKS } from "@/lib/constants" import { useLogoSrc } from "@/lib/hooks/use-logo-src" import { ScrollButton } from "@/components/ui" import ThemeToggle from "@/components/chromes/theme-toggle" -import { Brain, ChevronDown, Cloud, Puzzle, Slack, X } from "lucide-react" +import { Brain, Cloud, Puzzle, Slack, X } from "lucide-react" +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, + navigationMenuTriggerStyle, +} from "@/components/ui/navigation-menu" +import { cn } from "@/lib/utils" function LinearIcon({ className }: { className?: string }) { return ( @@ -35,95 +45,137 @@ export function NavBar({ stars, downloads }: NavBarProps) { return (
-
+
Roo Code Logo
{/* Desktop Navigation */} - + + + {/* Product Dropdown */} + + Product + +
    +
  • + + + + Roo Code VS Code Extension + + +
  • +
  • + + + + Roo Code Cloud + + +
  • +
  • + + + + Roo Code for Slack + + +
  • +
  • + + + + Roo Code for Linear + + +
  • +
  • + + + + Roo Code Router + + +
  • +
+
+
+ + {/* Resources Dropdown */} + + + Resources + + + + + + + {/* Docs Link */} + + + + Docs + + + + + {/* Pricing Link */} + + + Pricing + + +
+
diff --git a/apps/web-roo-code/src/components/homepage/features.tsx b/apps/web-roo-code/src/components/homepage/features.tsx index 67024563ea..32c25a4b1a 100644 --- a/apps/web-roo-code/src/components/homepage/features.tsx +++ b/apps/web-roo-code/src/components/homepage/features.tsx @@ -92,7 +92,7 @@ export function Features() { opacity: 1, transition: { duration: 1.2, - ease: "easeOut", + ease: "easeOut" as const, }, }, } diff --git a/apps/web-roo-code/src/components/homepage/install-section.tsx b/apps/web-roo-code/src/components/homepage/install-section.tsx index c15e20ee08..22b8073d65 100644 --- a/apps/web-roo-code/src/components/homepage/install-section.tsx +++ b/apps/web-roo-code/src/components/homepage/install-section.tsx @@ -17,7 +17,7 @@ export function InstallSection({ downloads }: InstallSectionProps) { opacity: 1, transition: { duration: 1.2, - ease: "easeOut", + ease: "easeOut" as const, }, }, } diff --git a/apps/web-roo-code/src/components/homepage/testimonials.tsx b/apps/web-roo-code/src/components/homepage/testimonials.tsx index 9bec532c5c..08a118e22e 100644 --- a/apps/web-roo-code/src/components/homepage/testimonials.tsx +++ b/apps/web-roo-code/src/components/homepage/testimonials.tsx @@ -179,7 +179,7 @@ export function Testimonials() { opacity: 1, transition: { duration: 0.6, - ease: [0.21, 0.45, 0.27, 0.9], + ease: [0.21, 0.45, 0.27, 0.9] as const, }, }, } diff --git a/apps/web-roo-code/src/components/homepage/use-examples-section.tsx b/apps/web-roo-code/src/components/homepage/use-examples-section.tsx index f1170321df..f230a7e869 100644 --- a/apps/web-roo-code/src/components/homepage/use-examples-section.tsx +++ b/apps/web-roo-code/src/components/homepage/use-examples-section.tsx @@ -41,6 +41,7 @@ interface PositionedUseCase extends UseCase { scale: number zIndex: number avatar: string + width: number } const SOURCES = { @@ -243,7 +244,7 @@ const LAYER_SCALES = { } function distributeItems(items: UseCase[]): PositionedUseCase[] { - const rng = seededRandom(Math.random() * 12345) + const rng = seededRandom(42) const zones = { rows: 7, cols: 4 } const zoneWidth = 100 / zones.cols const zoneHeight = 100 / zones.rows @@ -284,6 +285,7 @@ function distributeItems(items: UseCase[]): PositionedUseCase[] { }, scale: LAYER_SCALES[layer], zIndex: layer, + width: Math.round(300 + rng() * 100), } }) } @@ -345,7 +347,7 @@ function DesktopUseCaseCard({ item }: { item: PositionedUseCase }) { left: `${item.position.x}%`, top: `${item.position.y}%`, zIndex: item.zIndex, - width: Math.round(300 + Math.random() * 100), + width: item.width, }} initial={{ opacity: 0, scale: 0 }} whileInView={{ diff --git a/apps/web-roo-code/src/components/ui/navigation-menu.tsx b/apps/web-roo-code/src/components/ui/navigation-menu.tsx new file mode 100644 index 0000000000..7ba3696f13 --- /dev/null +++ b/apps/web-roo-code/src/components/ui/navigation-menu.tsx @@ -0,0 +1,117 @@ +import * as React from "react" +import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" +import { cva } from "class-variance-authority" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const NavigationMenu = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + +)) +NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName + +const NavigationMenuList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName + +const NavigationMenuItem = NavigationMenuPrimitive.Item + +const navigationMenuTriggerStyle = cva( + "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent", +) + +const NavigationMenuTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children}{" "} + +)) +NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName + +const NavigationMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName + +const NavigationMenuLink = NavigationMenuPrimitive.Link + +const NavigationMenuViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ +
+)) +NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName + +const NavigationMenuIndicator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +
+ +)) +NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName + +export { + navigationMenuTriggerStyle, + NavigationMenu, + NavigationMenuList, + NavigationMenuItem, + NavigationMenuContent, + NavigationMenuTrigger, + NavigationMenuLink, + NavigationMenuIndicator, + NavigationMenuViewport, +} diff --git a/apps/web-roo-code/src/images.d.ts b/apps/web-roo-code/src/images.d.ts new file mode 100644 index 0000000000..158872ad51 --- /dev/null +++ b/apps/web-roo-code/src/images.d.ts @@ -0,0 +1,30 @@ +declare module "*.png" { + const content: import("next/image").StaticImageData + export default content +} + +declare module "*.jpg" { + const content: import("next/image").StaticImageData + export default content +} + +declare module "*.jpeg" { + const content: import("next/image").StaticImageData + export default content +} + +declare module "*.svg" { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches Next.js built-in SVG type to avoid conflicts with @svgr/webpack + const content: any + export default content +} + +declare module "*.gif" { + const content: import("next/image").StaticImageData + export default content +} + +declare module "*.webp" { + const content: import("next/image").StaticImageData + export default content +} diff --git a/package.json b/package.json index cf8d88338a..f3ba41f282 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "clean": "turbo clean --log-order grouped --output-logs new-only && rimraf dist out bin .vite-port .turbo", "install:vsix": "pnpm install --frozen-lockfile && pnpm clean && pnpm vsix && node scripts/install-vsix.js", "install:vsix:nightly": "pnpm install --frozen-lockfile && pnpm clean && pnpm vsix:nightly && node scripts/install-vsix.js --nightly", + "code-server:install": "node scripts/code-server.js", "changeset:version": "cp CHANGELOG.md src/CHANGELOG.md && changeset version && cp -vf src/CHANGELOG.md .", "knip": "knip --include files", "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", diff --git a/packages/cloud/src/__tests__/CloudSettingsService.parsing.test.ts b/packages/cloud/src/__tests__/CloudSettingsService.parsing.test.ts index 22191ec90a..8d69303c38 100644 --- a/packages/cloud/src/__tests__/CloudSettingsService.parsing.test.ts +++ b/packages/cloud/src/__tests__/CloudSettingsService.parsing.test.ts @@ -81,7 +81,6 @@ describe("CloudSettingsService - Response Parsing", () => { version: 2, defaultSettings: { maxOpenTabsContext: 10, - maxReadFileLine: 1000, }, allowList: { allowAll: false, diff --git a/packages/evals/src/cli/__tests__/messageLogDeduper.test.ts b/packages/evals/src/cli/__tests__/messageLogDeduper.test.ts index 3a7facb8c2..e74fd0211f 100644 --- a/packages/evals/src/cli/__tests__/messageLogDeduper.test.ts +++ b/packages/evals/src/cli/__tests__/messageLogDeduper.test.ts @@ -1,4 +1,4 @@ -import { MessageLogDeduper } from "../messageLogDeduper.js" +import { MessageLogDeduper } from "../messageLogDeduper" describe("MessageLogDeduper", () => { it("dedupes identical messages for same action+ts", () => { diff --git a/packages/evals/src/cli/index.ts b/packages/evals/src/cli/index.ts index bc91f0db8a..8a10ed101d 100644 --- a/packages/evals/src/cli/index.ts +++ b/packages/evals/src/cli/index.ts @@ -2,11 +2,11 @@ import * as fs from "fs" import { run, command, option, flag, number, boolean } from "cmd-ts" -import { EVALS_REPO_PATH } from "../exercises/index.js" +import { EVALS_REPO_PATH } from "../exercises/index" -import { runCi } from "./runCi.js" -import { runEvals } from "./runEvals.js" -import { processTask } from "./processTask.js" +import { runCi } from "./runCi" +import { runEvals } from "./runEvals" +import { processTask } from "./processTask" const main = async () => { await run( diff --git a/packages/evals/src/cli/processTask.ts b/packages/evals/src/cli/processTask.ts index c0348872cc..638dafb5ae 100644 --- a/packages/evals/src/cli/processTask.ts +++ b/packages/evals/src/cli/processTask.ts @@ -2,13 +2,13 @@ import { execa } from "execa" import { type TaskEvent, RooCodeEventName } from "@roo-code/types" -import { findRun, findTask, updateTask } from "../db/index.js" +import { findRun, findTask, updateTask } from "../db/index" -import { Logger, getTag, isDockerContainer } from "./utils.js" -import { redisClient, getPubSubKey, registerRunner, deregisterRunner } from "./redis.js" -import { runUnitTest } from "./runUnitTest.js" -import { runTaskWithCli } from "./runTaskInCli.js" -import { runTaskInVscode } from "./runTaskInVscode.js" +import { Logger, getTag, isDockerContainer } from "./utils" +import { redisClient, getPubSubKey, registerRunner, deregisterRunner } from "./redis" +import { runUnitTest } from "./runUnitTest" +import { runTaskWithCli } from "./runTaskInCli" +import { runTaskInVscode } from "./runTaskInVscode" export const processTask = async ({ taskId, diff --git a/packages/evals/src/cli/runCi.ts b/packages/evals/src/cli/runCi.ts index ca8a88e0e0..4ab87d326f 100644 --- a/packages/evals/src/cli/runCi.ts +++ b/packages/evals/src/cli/runCi.ts @@ -1,9 +1,9 @@ import pMap from "p-map" -import { EVALS_REPO_PATH, exerciseLanguages, getExercisesForLanguage } from "../exercises/index.js" -import { createRun, createTask } from "../db/index.js" +import { EVALS_REPO_PATH, exerciseLanguages, getExercisesForLanguage } from "../exercises/index" +import { createRun, createTask } from "../db/index" -import { runEvals } from "./runEvals.js" +import { runEvals } from "./runEvals" export const runCi = async ({ concurrency = 1, diff --git a/packages/evals/src/cli/runEvals.ts b/packages/evals/src/cli/runEvals.ts index cb327938ea..4b03a48562 100644 --- a/packages/evals/src/cli/runEvals.ts +++ b/packages/evals/src/cli/runEvals.ts @@ -1,11 +1,11 @@ import PQueue from "p-queue" -import { findRun, finishRun, getTasks } from "../db/index.js" -import { EVALS_REPO_PATH } from "../exercises/index.js" +import { findRun, finishRun, getTasks } from "../db/index" +import { EVALS_REPO_PATH } from "../exercises/index" -import { Logger, getTag, isDockerContainer, resetEvalsRepo, commitEvalsRepoChanges } from "./utils.js" -import { startHeartbeat, stopHeartbeat } from "./redis.js" -import { processTask, processTaskInContainer } from "./processTask.js" +import { Logger, getTag, isDockerContainer, resetEvalsRepo, commitEvalsRepoChanges } from "./utils" +import { startHeartbeat, stopHeartbeat } from "./redis" +import { processTask, processTaskInContainer } from "./processTask" export const runEvals = async (runId: number) => { const run = await findRun(runId) diff --git a/packages/evals/src/cli/runTaskInCli.ts b/packages/evals/src/cli/runTaskInCli.ts index 79de380452..031136f8ea 100644 --- a/packages/evals/src/cli/runTaskInCli.ts +++ b/packages/evals/src/cli/runTaskInCli.ts @@ -7,11 +7,11 @@ import { execa } from "execa" import { type ToolUsage, TaskCommandName, RooCodeEventName, IpcMessageType } from "@roo-code/types" import { IpcClient } from "@roo-code/ipc" -import { updateTask, createTaskMetrics, updateTaskMetrics, createToolError } from "../db/index.js" -import { EVALS_REPO_PATH } from "../exercises/index.js" +import { updateTask, createTaskMetrics, updateTaskMetrics, createToolError } from "../db/index" +import { EVALS_REPO_PATH } from "../exercises/index" -import { type RunTaskOptions } from "./types.js" -import { mergeToolUsage, waitForSubprocessWithTimeout } from "./utils.js" +import { type RunTaskOptions } from "./types" +import { mergeToolUsage, waitForSubprocessWithTimeout } from "./utils" /** * Run a task using the Roo Code CLI (headless mode). diff --git a/packages/evals/src/cli/runTaskInVscode.ts b/packages/evals/src/cli/runTaskInVscode.ts index f6e87a4bda..07b7bd7e29 100644 --- a/packages/evals/src/cli/runTaskInVscode.ts +++ b/packages/evals/src/cli/runTaskInVscode.ts @@ -15,12 +15,12 @@ import { } from "@roo-code/types" import { IpcClient } from "@roo-code/ipc" -import { updateTask, createTaskMetrics, updateTaskMetrics, createToolError } from "../db/index.js" -import { EVALS_REPO_PATH } from "../exercises/index.js" +import { updateTask, createTaskMetrics, updateTaskMetrics, createToolError } from "../db/index" +import { EVALS_REPO_PATH } from "../exercises/index" -import { type RunTaskOptions } from "./types.js" -import { isDockerContainer, copyConversationHistory, mergeToolUsage, waitForSubprocessWithTimeout } from "./utils.js" -import { MessageLogDeduper } from "./messageLogDeduper.js" +import { type RunTaskOptions } from "./types" +import { isDockerContainer, copyConversationHistory, mergeToolUsage, waitForSubprocessWithTimeout } from "./utils" +import { MessageLogDeduper } from "./messageLogDeduper" export const runTaskInVscode = async ({ run, task, publish, logger, jobToken }: RunTaskOptions) => { const { language, exercise } = task diff --git a/packages/evals/src/cli/runUnitTest.ts b/packages/evals/src/cli/runUnitTest.ts index 6f8fbac619..1d1bcbea22 100644 --- a/packages/evals/src/cli/runUnitTest.ts +++ b/packages/evals/src/cli/runUnitTest.ts @@ -3,10 +3,10 @@ import * as path from "path" import { execa, parseCommandString } from "execa" import psTree from "ps-tree" -import type { Task } from "../db/index.js" -import { type ExerciseLanguage, EVALS_REPO_PATH } from "../exercises/index.js" +import type { Task } from "../db/index" +import { type ExerciseLanguage, EVALS_REPO_PATH } from "../exercises/index" -import { Logger } from "./utils.js" +import { Logger } from "./utils" const UNIT_TEST_TIMEOUT = 2 * 60 * 1_000 diff --git a/packages/evals/src/cli/types.ts b/packages/evals/src/cli/types.ts index bb6012ddeb..e661af1e3d 100644 --- a/packages/evals/src/cli/types.ts +++ b/packages/evals/src/cli/types.ts @@ -1,7 +1,7 @@ import { type TaskEvent } from "@roo-code/types" -import type { Run, Task } from "../db/index.js" -import { Logger } from "./utils.js" +import type { Run, Task } from "../db/index" +import { Logger } from "./utils" export class SubprocessTimeoutError extends Error { constructor(timeout: number) { diff --git a/packages/evals/src/cli/utils.ts b/packages/evals/src/cli/utils.ts index 2475ebd992..f76e7c8445 100644 --- a/packages/evals/src/cli/utils.ts +++ b/packages/evals/src/cli/utils.ts @@ -6,9 +6,9 @@ import { execa, type ResultPromise } from "execa" import type { ToolUsage } from "@roo-code/types" -import type { Run, Task } from "../db/index.js" +import type { Run, Task } from "../db/index" -import { SubprocessTimeoutError } from "./types.js" +import { SubprocessTimeoutError } from "./types" export const getTag = (caller: string, { run, task }: { run: Run; task?: Task }) => task diff --git a/packages/evals/src/db/db.ts b/packages/evals/src/db/db.ts index 9f2c046b57..562a198a86 100644 --- a/packages/evals/src/db/db.ts +++ b/packages/evals/src/db/db.ts @@ -1,7 +1,7 @@ import { drizzle } from "drizzle-orm/postgres-js" import postgres from "postgres" -import * as schema from "./schema.js" +import * as schema from "./schema" const pgClient = postgres(process.env.DATABASE_URL!, { prepare: false }) const client = drizzle({ client: pgClient, schema }) diff --git a/packages/evals/src/db/index.ts b/packages/evals/src/db/index.ts index 03d39253bc..de90e193ba 100644 --- a/packages/evals/src/db/index.ts +++ b/packages/evals/src/db/index.ts @@ -1,9 +1,9 @@ -export * from "./schema.js" +export * from "./schema" -export * from "./queries/runs.js" -export * from "./queries/tasks.js" -export * from "./queries/taskMetrics.js" -export * from "./queries/toolErrors.js" -export * from "./queries/copyRun.js" +export * from "./queries/runs" +export * from "./queries/tasks" +export * from "./queries/taskMetrics" +export * from "./queries/toolErrors" +export * from "./queries/copyRun" -export * from "./db.js" +export * from "./db" diff --git a/packages/evals/src/db/queries/__tests__/copyRun.spec.ts b/packages/evals/src/db/queries/__tests__/copyRun.spec.ts index 079373d568..1537ac1ddb 100644 --- a/packages/evals/src/db/queries/__tests__/copyRun.spec.ts +++ b/packages/evals/src/db/queries/__tests__/copyRun.spec.ts @@ -2,14 +2,14 @@ import { eq } from "drizzle-orm" -import { copyRun } from "../copyRun.js" -import { createRun } from "../runs.js" -import { createTask } from "../tasks.js" -import { createTaskMetrics } from "../taskMetrics.js" -import { createToolError } from "../toolErrors.js" -import { RecordNotFoundError } from "../errors.js" -import { schema } from "../../schema.js" -import { client as db } from "../../db.js" +import { copyRun } from "../copyRun" +import { createRun } from "../runs" +import { createTask } from "../tasks" +import { createTaskMetrics } from "../taskMetrics" +import { createToolError } from "../toolErrors" +import { RecordNotFoundError } from "../errors" +import { schema } from "../../schema" +import { client as db } from "../../db" describe("copyRun", () => { let sourceRunId: number diff --git a/packages/evals/src/db/queries/__tests__/runs.test.ts b/packages/evals/src/db/queries/__tests__/runs.test.ts index 9032871176..b02973af1f 100644 --- a/packages/evals/src/db/queries/__tests__/runs.test.ts +++ b/packages/evals/src/db/queries/__tests__/runs.test.ts @@ -1,6 +1,6 @@ -import { createRun, finishRun } from "../runs.js" -import { createTask } from "../tasks.js" -import { createTaskMetrics } from "../taskMetrics.js" +import { createRun, finishRun } from "../runs" +import { createTask } from "../tasks" +import { createTaskMetrics } from "../taskMetrics" describe("finishRun", () => { it("aggregates task metrics, including tool usage", async () => { diff --git a/packages/evals/src/db/queries/copyRun.ts b/packages/evals/src/db/queries/copyRun.ts index 6b14dd6a80..accf83b858 100644 --- a/packages/evals/src/db/queries/copyRun.ts +++ b/packages/evals/src/db/queries/copyRun.ts @@ -1,10 +1,10 @@ import { eq } from "drizzle-orm" import type { NodePgDatabase } from "drizzle-orm/node-postgres" -import type { InsertRun, InsertTask, InsertTaskMetrics, InsertToolError } from "../schema.js" -import { schema } from "../schema.js" +import type { InsertRun, InsertTask, InsertTaskMetrics, InsertToolError } from "../schema" +import { schema } from "../schema" -import { RecordNotFoundError, RecordNotCreatedError } from "./errors.js" +import { RecordNotFoundError, RecordNotCreatedError } from "./errors" export const copyRun = async ({ sourceDb, diff --git a/packages/evals/src/db/queries/runs.ts b/packages/evals/src/db/queries/runs.ts index df850bfaed..7985902580 100644 --- a/packages/evals/src/db/queries/runs.ts +++ b/packages/evals/src/db/queries/runs.ts @@ -2,12 +2,12 @@ import { desc, eq, inArray, sql, sum } from "drizzle-orm" import type { ToolUsage } from "@roo-code/types" -import { RecordNotFoundError, RecordNotCreatedError } from "./errors.js" -import type { InsertRun, UpdateRun } from "../schema.js" -import { schema } from "../schema.js" -import { client as db } from "../db.js" -import { createTaskMetrics } from "./taskMetrics.js" -import { getTasks } from "./tasks.js" +import { RecordNotFoundError, RecordNotCreatedError } from "./errors" +import type { InsertRun, UpdateRun } from "../schema" +import { schema } from "../schema" +import { client as db } from "../db" +import { createTaskMetrics } from "./taskMetrics" +import { getTasks } from "./tasks" export const findRun = async (id: number) => { const run = await db.query.runs.findFirst({ where: eq(schema.runs.id, id) }) diff --git a/packages/evals/src/db/queries/taskMetrics.ts b/packages/evals/src/db/queries/taskMetrics.ts index 3ddf353edd..c10a165ffd 100644 --- a/packages/evals/src/db/queries/taskMetrics.ts +++ b/packages/evals/src/db/queries/taskMetrics.ts @@ -1,9 +1,9 @@ import { eq } from "drizzle-orm" -import { RecordNotFoundError, RecordNotCreatedError } from "./errors.js" -import type { InsertTaskMetrics, UpdateTaskMetrics } from "../schema.js" -import { taskMetrics } from "../schema.js" -import { client as db } from "../db.js" +import { RecordNotFoundError, RecordNotCreatedError } from "./errors" +import type { InsertTaskMetrics, UpdateTaskMetrics } from "../schema" +import { taskMetrics } from "../schema" +import { client as db } from "../db" export const findTaskMetrics = async (id: number) => { const run = await db.query.taskMetrics.findFirst({ where: eq(taskMetrics.id, id) }) diff --git a/packages/evals/src/db/queries/tasks.ts b/packages/evals/src/db/queries/tasks.ts index 4f9fee0f9a..26e3dbe3fc 100644 --- a/packages/evals/src/db/queries/tasks.ts +++ b/packages/evals/src/db/queries/tasks.ts @@ -1,11 +1,11 @@ import { and, asc, eq, sql } from "drizzle-orm" -import type { ExerciseLanguage } from "../../exercises/index.js" +import type { ExerciseLanguage } from "../../exercises/index" -import { RecordNotFoundError, RecordNotCreatedError } from "./errors.js" -import type { InsertTask, UpdateTask } from "../schema.js" -import { tasks } from "../schema.js" -import { client as db } from "../db.js" +import { RecordNotFoundError, RecordNotCreatedError } from "./errors" +import type { InsertTask, UpdateTask } from "../schema" +import { tasks } from "../schema" +import { client as db } from "../db" export const findTask = async (id: number) => { const run = await db.query.tasks.findFirst({ where: eq(tasks.id, id) }) diff --git a/packages/evals/src/db/queries/toolErrors.ts b/packages/evals/src/db/queries/toolErrors.ts index 213dc38592..c9e283ed39 100644 --- a/packages/evals/src/db/queries/toolErrors.ts +++ b/packages/evals/src/db/queries/toolErrors.ts @@ -1,7 +1,7 @@ -import { RecordNotCreatedError } from "./errors.js" -import type { InsertToolError } from "../schema.js" -import { toolErrors } from "../schema.js" -import { client as db } from "../db.js" +import { RecordNotCreatedError } from "./errors" +import type { InsertToolError } from "../schema" +import { toolErrors } from "../schema" +import { client as db } from "../db" export const createToolError = async (args: InsertToolError) => { const records = await db diff --git a/packages/evals/src/db/schema.ts b/packages/evals/src/db/schema.ts index 4d159fe29b..5e24207068 100644 --- a/packages/evals/src/db/schema.ts +++ b/packages/evals/src/db/schema.ts @@ -3,7 +3,7 @@ import { relations } from "drizzle-orm" import type { RooCodeSettings, ToolName, ToolUsage } from "@roo-code/types" -import type { ExerciseLanguage } from "../exercises/index.js" +import type { ExerciseLanguage } from "../exercises/index" /** * ExecutionMethod diff --git a/packages/evals/src/index.ts b/packages/evals/src/index.ts index d626fd43b9..99989b9dd7 100644 --- a/packages/evals/src/index.ts +++ b/packages/evals/src/index.ts @@ -1,2 +1,2 @@ -export * from "./db/index.js" -export * from "./exercises/index.js" +export * from "./db" +export * from "./exercises" diff --git a/packages/evals/tsconfig.json b/packages/evals/tsconfig.json index 811519a302..32720c6173 100644 --- a/packages/evals/tsconfig.json +++ b/packages/evals/tsconfig.json @@ -1,6 +1,9 @@ { "extends": "@roo-code/config-typescript/base.json", "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, "types": ["vitest/globals"] }, "include": ["src", "drizzle.config.ts", "vitest-global-setup.ts"], diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index f14f14370b..206a5647b3 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -95,7 +95,6 @@ export const organizationDefaultSettingsSchema = globalSettingsSchema .pick({ enableCheckpoints: true, maxOpenTabsContext: true, - maxReadFileLine: true, maxWorkspaceFiles: true, showRooIgnoredFiles: true, terminalCommandDelay: true, @@ -107,7 +106,6 @@ export const organizationDefaultSettingsSchema = globalSettingsSchema .merge( z.object({ maxOpenTabsContext: z.number().int().nonnegative().optional(), - maxReadFileLine: z.number().int().gte(-1).optional(), maxWorkspaceFiles: z.number().int().nonnegative().optional(), terminalCommandDelay: z.number().int().nonnegative().optional(), terminalShellIntegrationTimeout: z.number().int().nonnegative().optional(), diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index ff8a454654..5c8241d58a 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -139,7 +139,6 @@ export const globalSettingsSchema = z.object({ allowedMaxCost: z.number().nullish(), autoCondenseContext: z.boolean().optional(), autoCondenseContextPercent: z.number().optional(), - maxConcurrentFileReads: z.number().optional(), /** * Whether to include current time in the environment details @@ -193,11 +192,9 @@ export const globalSettingsSchema = z.object({ maxWorkspaceFiles: z.number().optional(), showRooIgnoredFiles: z.boolean().optional(), enableSubfolderRules: z.boolean().optional(), - maxReadFileLine: z.number().optional(), maxImageFileSize: z.number().optional(), maxTotalImageSize: z.number().optional(), - maxReadCharacterLimit: z.number().optional(), terminalOutputPreviewSize: z.enum(["small", "medium", "large"]).optional(), terminalShellIntegrationTimeout: z.number().optional(), terminalShellIntegrationDisabled: z.boolean().optional(), @@ -222,7 +219,6 @@ export const globalSettingsSchema = z.object({ telemetrySetting: telemetrySettingsSchema.optional(), mcpEnabled: z.boolean().optional(), - enableMcpServerCreation: z.boolean().optional(), mode: z.string().optional(), prevMode: z.string().optional(), @@ -400,7 +396,7 @@ export const EVALS_SETTINGS: RooCodeSettings = { soundEnabled: false, soundVolume: 0.5, - maxReadCharacterLimit: DEFAULT_FILE_READ_CHARACTER_LIMIT, + // maxReadCharacterLimit: DEFAULT_FILE_READ_CHARACTER_LIMIT, terminalShellIntegrationTimeout: 30000, terminalCommandDelay: 150, terminalPowershellCounter: false, @@ -419,7 +415,6 @@ export const EVALS_SETTINGS: RooCodeSettings = { maxWorkspaceFiles: MAX_WORKSPACE_FILES, maxGitStatusFiles: 20, showRooIgnoredFiles: true, - maxReadFileLine: 500, // -1 to enable full file reading. includeDiagnosticMessages: true, maxDiagnosticMessages: 50, diff --git a/packages/types/src/skills.ts b/packages/types/src/skills.ts index 2c4ac176b0..b50b4e6d47 100644 --- a/packages/types/src/skills.ts +++ b/packages/types/src/skills.ts @@ -5,8 +5,8 @@ export interface SkillMetadata { name: string // Required: skill identifier description: string // Required: when to use this skill - path: string // Absolute path to SKILL.md - source: "global" | "project" // Where the skill was discovered + path: string // Absolute path to SKILL.md (or "" for built-in skills) + source: "global" | "project" | "built-in" // Where the skill was discovered mode?: string // If set, skill is only available in this mode } diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index 4e6b6d7cdc..fab6512688 100644 --- a/packages/types/src/telemetry.ts +++ b/packages/types/src/telemetry.ts @@ -78,6 +78,7 @@ export enum TelemetryEventName { ERROR = "Error", TELEMETRY_SETTINGS_CHANGED = "Telemetry Settings Changed", MODEL_CACHE_EMPTY_RESPONSE = "Model Cache Empty Response", + READ_FILE_LEGACY_FORMAT_USED = "Read File Legacy Format Used", } /** @@ -208,6 +209,7 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [ TelemetryEventName.TAB_SHOWN, TelemetryEventName.MODE_SETTINGS_CHANGED, TelemetryEventName.CUSTOM_MODE_CREATED, + TelemetryEventName.READ_FILE_LEGACY_FORMAT_USED, ]), properties: telemetryPropertiesSchema, }), diff --git a/packages/types/src/tool-params.ts b/packages/types/src/tool-params.ts index f8708b0c2b..75be318d8c 100644 --- a/packages/types/src/tool-params.ts +++ b/packages/types/src/tool-params.ts @@ -2,16 +2,96 @@ * Tool parameter type definitions for native protocol */ +/** + * Read mode for the read_file tool. + * - "slice": Simple offset/limit reading (default) + * - "indentation": Semantic block extraction based on code structure + */ +export type ReadFileMode = "slice" | "indentation" + +/** + * Indentation-mode configuration for the read_file tool. + */ +export interface IndentationParams { + /** 1-based line number to anchor indentation extraction (defaults to offset) */ + anchor_line?: number + /** Maximum indentation levels to include above anchor (0 = unlimited) */ + max_levels?: number + /** Include sibling blocks at the same indentation level */ + include_siblings?: boolean + /** Include file header (imports, comments at top) */ + include_header?: boolean + /** Hard cap on lines returned for indentation mode */ + max_lines?: number +} + +/** + * Parameters for the read_file tool (new format). + * + * NOTE: This is the canonical, single-file-per-call shape. + */ +export interface ReadFileParams { + /** Path to the file, relative to workspace */ + path: string + /** Reading mode: "slice" (default) or "indentation" */ + mode?: ReadFileMode + /** 1-based line number to start reading from (slice mode, default: 1) */ + offset?: number + /** Maximum number of lines to read (default: 2000) */ + limit?: number + /** Indentation-mode configuration (only used when mode === "indentation") */ + indentation?: IndentationParams +} + +// ─── Legacy Format Types (Backward Compatibility) ───────────────────────────── + +/** + * Line range specification for legacy read_file format. + * Represents a contiguous range of lines [start, end] (1-based, inclusive). + */ export interface LineRange { start: number end: number } +/** + * File entry for legacy read_file format. + * Supports reading multiple disjoint line ranges from a single file. + */ export interface FileEntry { + /** Path to the file, relative to workspace */ path: string + /** Optional list of line ranges to read (if omitted, reads entire file) */ lineRanges?: LineRange[] } +/** + * Legacy parameters for the read_file tool (pre-refactor format). + * Supports reading multiple files in a single call with optional line ranges. + * + * @deprecated Use ReadFileParams instead. This format is maintained for + * backward compatibility with existing chat histories. + */ +export interface LegacyReadFileParams { + /** Array of file entries to read */ + files: FileEntry[] + /** Discriminant flag for type narrowing */ + _legacyFormat: true +} + +/** + * Union type for read_file tool parameters. + * Supports both new single-file format and legacy multi-file format. + */ +export type ReadFileToolParams = ReadFileParams | LegacyReadFileParams + +/** + * Type guard to check if params are in legacy format. + */ +export function isLegacyReadFileParams(params: ReadFileToolParams): params is LegacyReadFileParams { + return "_legacyFormat" in params && params._legacyFormat === true +} + export interface Coordinate { x: number y: number diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index fa3881c165..bf7851c1cb 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -35,10 +35,10 @@ export const toolNames = [ "attempt_completion", "switch_mode", "new_task", - "fetch_instructions", "codebase_search", "update_todo_list", "run_slash_command", + "skill", "generate_image", "custom_tool", ] as const diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index fbb0405ff5..2f334cc896 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -79,7 +79,6 @@ export interface ExtensionMessage { | "remoteBrowserEnabled" | "ttsStart" | "ttsStop" - | "maxReadFileLine" | "fileSearchResults" | "toggleApiConfigPin" | "acceptInput" @@ -340,8 +339,6 @@ export type ExtensionState = Pick< | "ttsSpeed" | "soundEnabled" | "soundVolume" - | "maxConcurrentFileReads" - | "maxReadCharacterLimit" | "terminalOutputPreviewSize" | "terminalShellIntegrationTimeout" | "terminalShellIntegrationDisabled" @@ -397,14 +394,12 @@ export type ExtensionState = Pick< maxWorkspaceFiles: number // Maximum number of files to include in current working directory details (0-500) showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings enableSubfolderRules: boolean // Whether to load rules from subdirectories - maxReadFileLine: number // Maximum number of lines to read from a file before truncating maxImageFileSize: number // Maximum size of image files to process in MB maxTotalImageSize: number // Maximum total size for all images in a single read operation in MB experiments: Experiments // Map of experiment IDs to their enabled state mcpEnabled: boolean - enableMcpServerCreation: boolean // mode: Mode zgsmCodeMode?: "vibe" | "strict" | "raw" | "plan" @@ -580,7 +575,6 @@ export interface WebviewMessage { | "deleteMessageConfirm" | "submitEditedMessage" | "editMessageConfirm" - | "enableMcpServerCreation" | "remoteControlEnabled" | "taskSyncEnabled" | "searchCommits" @@ -690,6 +684,7 @@ export interface WebviewMessage { | "requestSkills" | "createSkill" | "deleteSkill" + | "moveSkill" | "openSkillFile" text?: string // costrict-start @@ -730,9 +725,10 @@ export interface WebviewMessage { modeConfig?: ModeConfig timeout?: number payload?: WebViewMessagePayload - source?: "global" | "project" - skillName?: string // For skill operations (createSkill, deleteSkill, openSkillFile) - skillMode?: string // For skill operations (mode restriction) + source?: "global" | "project" | "built-in" + skillName?: string // For skill operations (createSkill, deleteSkill, moveSkill, openSkillFile) + skillMode?: string // For skill operations (current mode restriction) + newSkillMode?: string // For moveSkill (target mode) skillDescription?: string // For createSkill (skill description) requestId?: string ids?: string[] @@ -892,7 +888,6 @@ export interface ClineSayTool { | "codebaseSearch" | "readFile" | "readCommandOutput" - | "fetchInstructions" | "listFilesTopLevel" | "listFilesRecursive" | "searchFiles" @@ -903,6 +898,7 @@ export interface ClineSayTool { | "imageGenerated" | "runSlashCommand" | "updateTodoList" + | "skill" parentTaskId?: string path?: string // For readCommandOutput @@ -923,6 +919,7 @@ export interface ClineSayTool { isProtected?: boolean additionalFileCount?: number // Number of additional files in the same read_file request lineNumber?: number + startLine?: number // Starting line for read_file operations (for navigation on click) query?: string batchFiles?: Array<{ path: string @@ -950,6 +947,8 @@ export interface ClineSayTool { args?: string source?: string description?: string + // Properties for skill tool + skill?: string } // Must keep in sync with system prompt. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5cad73bcf..d1186d9b1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -245,8 +245,8 @@ importers: specifier: workspace:^ version: link:../../packages/evals '@roo-code/types': - specifier: workspace:^ - version: link:../../packages/types + specifier: ^1.108.0 + version: 1.108.0 '@tanstack/react-query': specifier: ^5.69.0 version: 5.90.20(react@18.3.1) @@ -269,8 +269,8 @@ importers: specifier: ^0.518.0 version: 0.518.0(react@18.3.1) next: - specifier: ~15.2.8 - version: 15.2.9(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^16.1.6 + version: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -339,23 +339,26 @@ importers: apps/web-roo-code: dependencies: '@radix-ui/react-dialog': - specifier: ^1.1.14 + specifier: ^1.1.15 version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-navigation-menu': + specifier: ^1.2.14 + version: 1.2.14(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': - specifier: ^1.2.3 + specifier: ^1.2.4 version: 1.2.4(@types/react@18.3.27)(react@18.3.1) '@roo-code/evals': specifier: workspace:^ version: link:../../packages/evals '@roo-code/types': - specifier: workspace:^ - version: link:../../packages/types + specifier: ^1.108.0 + version: 1.108.0 '@tanstack/react-query': - specifier: ^5.79.0 + specifier: ^5.90.20 version: 5.90.20(react@18.3.1) '@vercel/og': - specifier: ^0.6.2 - version: 0.6.8 + specifier: ^0.8.6 + version: 0.8.6 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -372,20 +375,20 @@ importers: specifier: ^8.6.0 version: 8.6.0(react@18.3.1) framer-motion: - specifier: 12.15.0 - version: 12.15.0(@emotion/is-prop-valid@1.4.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^12.29.2 + version: 12.29.2(@emotion/is-prop-valid@1.4.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lucide-react: - specifier: ^0.518.0 - version: 0.518.0(react@18.3.1) + specifier: ^0.563.0 + version: 0.563.0(react@18.3.1) next: - specifier: ~15.2.8 - version: 15.2.9(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^16.1.6 + version: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) posthog-js: - specifier: ^1.248.1 - version: 1.336.1 + specifier: ^1.336.4 + version: 1.336.4 react: specifier: ^18.3.1 version: 18.3.1 @@ -411,7 +414,7 @@ importers: specifier: ^4.0.1 version: 4.0.1 tailwind-merge: - specifier: ^3.3.0 + specifier: ^3.4.0 version: 3.4.0 tailwindcss-animate: specifier: ^1.0.7 @@ -430,7 +433,7 @@ importers: specifier: workspace:^ version: link:../../packages/config-typescript '@tailwindcss/typography': - specifier: ^0.5.16 + specifier: ^0.5.19 version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) '@types/node': specifier: 20.x @@ -442,13 +445,13 @@ importers: specifier: ^18.3.5 version: 18.3.7(@types/react@18.3.27) autoprefixer: - specifier: ^10.4.21 + specifier: ^10.4.23 version: 10.4.23(postcss@8.5.6) next-sitemap: specifier: ^4.2.3 - version: 4.2.3(next@15.2.9(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 4.2.3(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) postcss: - specifier: ^8.5.4 + specifier: ^8.5.6 version: 8.5.6 tailwindcss: specifier: ^3.4.17 @@ -747,6 +750,18 @@ importers: src: dependencies: + '@ai-sdk/cerebras': + specifier: ^1.0.0 + version: 1.0.36(zod@3.25.76) + '@ai-sdk/deepseek': + specifier: ^2.0.14 + version: 2.0.15(zod@3.25.76) + '@ai-sdk/fireworks': + specifier: ^2.0.26 + version: 2.0.26(zod@3.25.76) + '@ai-sdk/groq': + specifier: ^3.0.19 + version: 3.0.19(zod@3.25.76) '@anthropic-ai/bedrock-sdk': specifier: ^0.10.2 version: 0.10.4 @@ -1439,18 +1454,54 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ai-sdk/cerebras@1.0.36': + resolution: {integrity: sha512-zoJYL33+ieyd86FSP0Whm86D79d1lKPR7wUzh1SZ1oTxwYmsGyvIrmMf2Ll0JA9Ds2Es6qik4VaFCrjwGYRTIQ==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + + '@ai-sdk/deepseek@2.0.15': + resolution: {integrity: sha512-3wJUjNjGrTZS3K8OEfHD1PZYhzkcXuoL8KIVtzi6WrC5xrDQPjCBPATmdKPV7DgDCF+wujQOaMz5cv40Yg+hog==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + + '@ai-sdk/fireworks@2.0.26': + resolution: {integrity: sha512-vBqSSksHhDGrSNYnmEmVGvLicHFjL4yAxFZfCb6ydrg+qgnlW2bdyTQDMI69BKG4spNZ1/iHMxRNIQpx19Yf6w==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + '@ai-sdk/gateway@3.0.27': resolution: {integrity: sha512-Pr+ApS9k6/jcR3kNltJNxo60OdYvnVU4DeRhzVtxUAYTXCHx4qO+qTMG9nNRn+El1acJnNRA//Su47srjXkT/w==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 + '@ai-sdk/groq@3.0.19': + resolution: {integrity: sha512-WAeGVnp9rvU3RUvu6S1HiD8hAjKgNlhq+z3m4j5Z1fIKRXqcKjOscVZGwL36If8qxsqXNVCtG3ltXawM5UAa8w==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + '@ai-sdk/openai-compatible@1.0.31': resolution: {integrity: sha512-znBvaVHM0M6yWNerIEy3hR+O8ZK2sPcE7e2cxfb6kYLEX3k//JH5VDnRnajseVofg7LXtTCFFdjsB7WLf1BdeQ==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 + '@ai-sdk/openai-compatible@1.0.32': + resolution: {integrity: sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + + '@ai-sdk/openai-compatible@2.0.24': + resolution: {integrity: sha512-3QrCKpQCn3g6sIMoFGuEroaqk7Xg+qfsohRp4dKszjto5stjBg4SdtOKqHg+CpE3X4woj2O62w2qr5dSekMZeQ==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + '@ai-sdk/provider-utils@3.0.20': resolution: {integrity: sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ==} engines: {node: '>=18'} @@ -1463,6 +1514,12 @@ packages: peerDependencies: zod: 3.25.76 + '@ai-sdk/provider-utils@4.0.11': + resolution: {integrity: sha512-y/WOPpcZaBjvNaogy83mBsCRPvbtaK0y1sY9ckRrrbTGMvG2HC/9Y/huqNXKnLAxUIME2PGa2uvF2CDwIsxoXQ==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + '@ai-sdk/provider@2.0.1': resolution: {integrity: sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==} engines: {node: '>=18'} @@ -1471,6 +1528,10 @@ packages: resolution: {integrity: sha512-2Xmoq6DBJqmSl80U6V9z5jJSJP7ehaJJQMy2iFUqTay06wdCqTnPVBBQbtEL8RCChenL+q5DC5H5WzU3vV3v8w==} engines: {node: '>=18'} + '@ai-sdk/provider@3.0.6': + resolution: {integrity: sha512-hSfoJtLtpMd7YxKM+iTqlJ0ZB+kJ83WESMiWuWrNVey3X8gg97x0OdAAaeAeclZByCX3UdPOTqhvJdK8qYA3ww==} + engines: {node: '>=18'} + '@alcalzone/ansi-tokenize@0.2.4': resolution: {integrity: sha512-HTgrrTgZ9Jgeo6Z3oqbQ7lifOVvRR14vaDuBGPPUxk9Thm+vObaO4QfYYYWw4Zo5CWQDBEfsinFA6Gre+AqwNQ==} engines: {node: '>=18'} @@ -2208,107 +2269,139 @@ packages: '@iconify/utils@3.1.0': resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} - '@img/sharp-darwin-arm64@0.33.5': - resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + '@img/colour@1.0.0': + resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.33.5': - resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.0.4': - resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.0.4': - resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.0.4': - resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linux-arm@1.0.5': - resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - '@img/sharp-libvips-linux-s390x@1.0.4': - resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - '@img/sharp-libvips-linux-x64@1.0.4': - resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - '@img/sharp-libvips-linuxmusl-arm64@1.0.4': - resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linuxmusl-x64@1.0.4': - resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - '@img/sharp-linux-arm64@0.33.5': - resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - '@img/sharp-linux-arm@0.33.5': - resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - '@img/sharp-linux-s390x@0.33.5': - resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - '@img/sharp-linux-x64@0.33.5': - resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - '@img/sharp-linuxmusl-arm64@0.33.5': - resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - '@img/sharp-linuxmusl-x64@0.33.5': - resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - '@img/sharp-wasm32@0.33.5': - resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] - '@img/sharp-win32-ia32@0.33.5': - resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.33.5': - resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] @@ -2439,56 +2532,56 @@ packages: '@next/env@13.5.11': resolution: {integrity: sha512-fbb2C7HChgM7CemdCY+y3N1n8pcTKdqtQLbC7/EQtPdLvlMUT9JX/dBYl8MMZAtYG4uVMyPFHXckb68q/NRwqg==} - '@next/env@15.2.9': - resolution: {integrity: sha512-0JJ6OlIb1kZiAbY/Hi5XHb2ZT7B5/l8CyGX3GxtTY8LNl1Inm9EU8PnCtVzUR8N2Si3a1pX02PbKBlDcsHNvUQ==} + '@next/env@16.1.6': + resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} '@next/eslint-plugin-next@15.5.11': resolution: {integrity: sha512-tS/HYQOjIoX9ZNDQitba/baS8sTvo3ekY6Vgdx5lmhN4jov082bdApIChXr94qhMZHvEciz9DZglFFnhguQp/A==} - '@next/swc-darwin-arm64@15.2.5': - resolution: {integrity: sha512-4OimvVlFTbgzPdA0kh8A1ih6FN9pQkL4nPXGqemEYgk+e7eQhsst/p35siNNqA49eQA6bvKZ1ASsDtu9gtXuog==} + '@next/swc-darwin-arm64@16.1.6': + resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.2.5': - resolution: {integrity: sha512-ohzRaE9YbGt1ctE0um+UGYIDkkOxHV44kEcHzLqQigoRLaiMtZzGrA11AJh2Lu0lv51XeiY1ZkUvkThjkVNBMA==} + '@next/swc-darwin-x64@16.1.6': + resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.2.5': - resolution: {integrity: sha512-FMSdxSUt5bVXqqOoZCc/Seg4LQep9w/fXTazr/EkpXW2Eu4IFI9FD7zBDlID8TJIybmvKk7mhd9s+2XWxz4flA==} + '@next/swc-linux-arm64-gnu@16.1.6': + resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.2.5': - resolution: {integrity: sha512-4ZNKmuEiW5hRKkGp2HWwZ+JrvK4DQLgf8YDaqtZyn7NYdl0cHfatvlnLFSWUayx9yFAUagIgRGRk8pFxS8Qniw==} + '@next/swc-linux-arm64-musl@16.1.6': + resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@15.2.5': - resolution: {integrity: sha512-bE6lHQ9GXIf3gCDE53u2pTl99RPZW5V1GLHSRMJ5l/oB/MT+cohu9uwnCK7QUph2xIOu2a6+27kL0REa/kqwZw==} + '@next/swc-linux-x64-gnu@16.1.6': + resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.2.5': - resolution: {integrity: sha512-y7EeQuSkQbTAkCEQnJXm1asRUuGSWAchGJ3c+Qtxh8LVjXleZast8Mn/rL7tZOm7o35QeIpIcid6ufG7EVTTcA==} + '@next/swc-linux-x64-musl@16.1.6': + resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.2.5': - resolution: {integrity: sha512-gQMz0yA8/dskZM2Xyiq2FRShxSrsJNha40Ob/M2n2+JGRrZ0JwTVjLdvtN6vCxuq4ByhOd4a9qEf60hApNR2gQ==} + '@next/swc-win32-arm64-msvc@16.1.6': + resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.2.5': - resolution: {integrity: sha512-tBDNVUcI7U03+3oMvJ11zrtVin5p0NctiuKmTGyaTIEAVj9Q77xukLXGXRnWxKRIIdFG4OTA2rUVGZDYOwgmAA==} + '@next/swc-win32-x64-msvc@16.1.6': + resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2798,9 +2891,15 @@ packages: '@posthog/core@1.15.0': resolution: {integrity: sha512-n2/Yy0+qc8xhmlcOFiYqTcGHBZuuaQjVolfFXk7yTCynzdMe8Fx1zYvPPUrbdQK5tWwXyilkzybpqhK6I7aV4Q==} + '@posthog/core@1.17.0': + resolution: {integrity: sha512-8pDNL+/u9ojzXloA5wILVDXBCV5daJ7w2ipCALQlEEZmL752cCKhRpbyiHn3tjKXh3Hy6aOboJneYa1JdlVHrQ==} + '@posthog/types@1.336.1': resolution: {integrity: sha512-KSGst/a/HK7GhfLSbwAy35HtU3KjDqjLtq3+PoDlGfbz9SbO0owjc6jo6hAHnMz67QTSvrn/r0xgimDO4NQ+rA==} + '@posthog/types@1.336.4': + resolution: {integrity: sha512-BY3cq/8segbXEvHbEXx9SWmaKJEM0AGgsOgMFH2yy13AV+rUHsGcp4Z5LDI5pU25DURN9EAZvzcoVyYy/Iokmw==} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -3059,6 +3158,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-navigation-menu@1.2.14': + resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} + peerDependencies: + '@types/react': ^18.3.23 + '@types/react-dom': ^18.3.5 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popover@1.1.15': resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} peerDependencies: @@ -3533,6 +3645,9 @@ packages: cpu: [x64] os: [win32] + '@roo-code/types@1.108.0': + resolution: {integrity: sha512-0Of0DOuU125i1VTI2OTdL0j47xA+JrQr6KyYinYS+CPwUsszUJt2PeWyy/AZYI1w23FYrcCvh8FqycDuwXocSA==} + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -3891,9 +4006,6 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} - '@swc/counter@0.1.3': - resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} - '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -4423,8 +4535,8 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - '@vercel/og@0.6.8': - resolution: {integrity: sha512-e4kQK9mP8ntpo3dACWirGod/hHv4qO5JMj9a/0a2AZto7b4persj5YP7t1Er372gTtYFTYxNhMx34jRvHooglw==} + '@vercel/og@0.8.6': + resolution: {integrity: sha512-hBcWIOppZV14bi+eAmCZj8Elj8hVSUZJTpf1lgGBhVD85pervzQ1poM/qYfFUlPraYSZYP+ASg6To5BwYmUSGQ==} engines: {node: '>=16'} '@vercel/oidc@3.1.0': @@ -4921,10 +5033,6 @@ packages: peerDependencies: esbuild: '>=0.25.0' - busboy@1.6.0: - resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} - engines: {node: '>=10.16.0'} - bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -5156,13 +5264,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - color-string@1.9.1: - resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} - - color@4.2.3: - resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} - engines: {node: '>=12.5.0'} - colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -5899,6 +6000,10 @@ packages: embla-carousel@8.6.0: resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + emoji-regex-xs@2.0.1: + resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==} + engines: {node: '>=10.0.0'} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -6378,8 +6483,8 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - framer-motion@12.15.0: - resolution: {integrity: sha512-XKg/LnKExdLGugZrDILV7jZjI599785lDIJZLxMiiIFidCsy0a4R2ZEf+Izm67zyOuJgQYTHOmodi7igQsw3vg==} + framer-motion@12.29.2: + resolution: {integrity: sha512-lSNRzBJk4wuIy0emYQ/nfZ7eWhqud2umPKw2QAQki6uKhZPKm2hRQHeQoHTG9MIvfobb+A/LbEWPJU794ZUKrg==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -6882,9 +6987,6 @@ packages: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} - is-arrayish@0.3.4: - resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} - is-async-function@2.1.1: resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} engines: {node: '>= 0.4'} @@ -7588,6 +7690,11 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lucide-react@0.563.0: + resolution: {integrity: sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -7981,13 +8088,13 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@15.2.9: - resolution: {integrity: sha512-jXEBIPi+kIkMe5KI4okvGIWvot9hyiDz2fT4OqxxsSeZTA6zhSwrQkJwTE3GmQ1HQlolcQjTNMjHMvc8hhog7g==} - engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + next@16.1.6: + resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} + engines: {node: '>=20.9.0'} hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.41.2 + '@playwright/test': ^1.51.1 babel-plugin-react-compiler: '*' react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 @@ -8486,6 +8593,9 @@ packages: posthog-js@1.336.1: resolution: {integrity: sha512-YphbVhXnImmZoALvf2oh129Cxu6IRQ9P9sWhuyY+dGe7jqt1jBp6Dg7QEK39stB4rzxmT/N3OLFcWZM7ZYQzCg==} + posthog-js@1.336.4: + resolution: {integrity: sha512-NX81XaqOjS/gue3UsbAAuJxi6vD0AGy1HUvywBIhAArCwbTXKS04NhEFwUcYJdrmwXUf94MntEIWGoc1pTFDtg==} + posthog-node@5.24.4: resolution: {integrity: sha512-U90zdez3jbqAZ4HNxCM/n6SK9h2W59DPS0l2rboWiaKiBi47rN+YJmFQqx1rQsQA47JhNsmAAh2iwqYQ+VTjow==} engines: {node: ^20.20.0 || >=22.22.0} @@ -9050,8 +9160,8 @@ packages: sanitize-filename@1.6.3: resolution: {integrity: sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==} - satori@0.12.2: - resolution: {integrity: sha512-3C/laIeE6UUe9A+iQ0A48ywPVCCMKCNSTU5Os101Vhgsjd3AAxGNjyq0uAA8kulMPK5n0csn8JlxPN9riXEjLA==} + satori@0.16.0: + resolution: {integrity: sha512-ZvHN3ygzZ8FuxjSNB+mKBiF/NIoqHzlBGbD0MJiT+MvSsFOvotnWOhdTjxKzhHRT2wPC1QbhLzx2q/Y83VhfYQ==} engines: {node: '>=16'} sax@1.2.4: @@ -9160,8 +9270,8 @@ packages: shallowequal@1.1.0: resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} - sharp@0.33.5: - resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} shebang-command@2.0.0: @@ -9218,9 +9328,6 @@ packages: resolution: {integrity: sha512-1sbhsxqI+I2tqlmjbz99GXNmZtr6tKIyEgGGnJw/MKGblalqk/XoOYYFJlBzTKZCxx8kLaD3FD5s9BEEjx5Pyg==} engines: {node: '>=10'} - simple-swizzle@0.2.4: - resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} - sirv@3.0.2: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} @@ -9353,10 +9460,6 @@ packages: stream-json@1.9.1: resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} - streamsearch@1.1.0: - resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} - engines: {node: '>=10.0.0'} - streamx@2.23.0: resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} @@ -10442,9 +10545,6 @@ packages: yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} - yoga-wasm-web@0.3.3: - resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==} - zip-stream@4.1.1: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} @@ -10492,6 +10592,26 @@ snapshots: '@adobe/css-tools@4.4.4': {} + '@ai-sdk/cerebras@1.0.36(zod@3.25.76)': + dependencies: + '@ai-sdk/openai-compatible': 1.0.32(zod@3.25.76) + '@ai-sdk/provider': 2.0.1 + '@ai-sdk/provider-utils': 3.0.20(zod@3.25.76) + zod: 3.25.76 + + '@ai-sdk/deepseek@2.0.15(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.6 + '@ai-sdk/provider-utils': 4.0.11(zod@3.25.76) + zod: 3.25.76 + + '@ai-sdk/fireworks@2.0.26(zod@3.25.76)': + dependencies: + '@ai-sdk/openai-compatible': 2.0.24(zod@3.25.76) + '@ai-sdk/provider': 3.0.6 + '@ai-sdk/provider-utils': 4.0.11(zod@3.25.76) + zod: 3.25.76 + '@ai-sdk/gateway@3.0.27(zod@3.25.76)': dependencies: '@ai-sdk/provider': 3.0.5 @@ -10499,12 +10619,30 @@ snapshots: '@vercel/oidc': 3.1.0 zod: 3.25.76 + '@ai-sdk/groq@3.0.19(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.6 + '@ai-sdk/provider-utils': 4.0.11(zod@3.25.76) + zod: 3.25.76 + '@ai-sdk/openai-compatible@1.0.31(zod@3.25.76)': dependencies: '@ai-sdk/provider': 2.0.1 '@ai-sdk/provider-utils': 3.0.20(zod@3.25.76) zod: 3.25.76 + '@ai-sdk/openai-compatible@1.0.32(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 2.0.1 + '@ai-sdk/provider-utils': 3.0.20(zod@3.25.76) + zod: 3.25.76 + + '@ai-sdk/openai-compatible@2.0.24(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.6 + '@ai-sdk/provider-utils': 4.0.11(zod@3.25.76) + zod: 3.25.76 + '@ai-sdk/provider-utils@3.0.20(zod@3.25.76)': dependencies: '@ai-sdk/provider': 2.0.1 @@ -10519,6 +10657,13 @@ snapshots: eventsource-parser: 3.0.6 zod: 3.25.76 + '@ai-sdk/provider-utils@4.0.11(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.6 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 3.25.76 + '@ai-sdk/provider@2.0.1': dependencies: json-schema: 0.4.0 @@ -10527,6 +10672,10 @@ snapshots: dependencies: json-schema: 0.4.0 + '@ai-sdk/provider@3.0.6': + dependencies: + json-schema: 0.4.0 + '@alcalzone/ansi-tokenize@0.2.4': dependencies: ansi-styles: 6.2.3 @@ -11846,79 +11995,101 @@ snapshots: '@iconify/types': 2.0.0 mlly: 1.8.0 - '@img/sharp-darwin-arm64@0.33.5': + '@img/colour@1.0.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true - '@img/sharp-darwin-x64@0.33.5': + '@img/sharp-darwin-x64@0.34.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': optional: true - '@img/sharp-libvips-darwin-arm64@1.0.4': + '@img/sharp-libvips-linux-arm@1.2.4': optional: true - '@img/sharp-libvips-darwin-x64@1.0.4': + '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true - '@img/sharp-libvips-linux-arm64@1.0.4': + '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true - '@img/sharp-libvips-linux-arm@1.0.5': + '@img/sharp-libvips-linux-s390x@1.2.4': optional: true - '@img/sharp-libvips-linux-s390x@1.0.4': + '@img/sharp-libvips-linux-x64@1.2.4': optional: true - '@img/sharp-libvips-linux-x64@1.0.4': + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.0.4': + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 optional: true - '@img/sharp-linux-arm64@0.33.5': + '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true - '@img/sharp-linux-arm@0.33.5': + '@img/sharp-linux-riscv64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true - '@img/sharp-linux-s390x@0.33.5': + '@img/sharp-linux-s390x@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.0.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true - '@img/sharp-linux-x64@0.33.5': + '@img/sharp-linux-x64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.2.4 optional: true - '@img/sharp-linuxmusl-arm64@0.33.5': + '@img/sharp-linuxmusl-arm64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true - '@img/sharp-linuxmusl-x64@0.33.5': + '@img/sharp-linuxmusl-x64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 optional: true - '@img/sharp-wasm32@0.33.5': + '@img/sharp-wasm32@0.34.5': dependencies: '@emnapi/runtime': 1.8.1 optional: true - '@img/sharp-win32-ia32@0.33.5': + '@img/sharp-win32-arm64@0.34.5': optional: true - '@img/sharp-win32-x64@0.33.5': + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': optional: true '@inkjs/ui@2.0.0(ink@6.6.0(@types/react@18.3.27)(react@19.2.4))': @@ -12105,34 +12276,34 @@ snapshots: '@next/env@13.5.11': {} - '@next/env@15.2.9': {} + '@next/env@16.1.6': {} '@next/eslint-plugin-next@15.5.11': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@15.2.5': + '@next/swc-darwin-arm64@16.1.6': optional: true - '@next/swc-darwin-x64@15.2.5': + '@next/swc-darwin-x64@16.1.6': optional: true - '@next/swc-linux-arm64-gnu@15.2.5': + '@next/swc-linux-arm64-gnu@16.1.6': optional: true - '@next/swc-linux-arm64-musl@15.2.5': + '@next/swc-linux-arm64-musl@16.1.6': optional: true - '@next/swc-linux-x64-gnu@15.2.5': + '@next/swc-linux-x64-gnu@16.1.6': optional: true - '@next/swc-linux-x64-musl@15.2.5': + '@next/swc-linux-x64-musl@16.1.6': optional: true - '@next/swc-win32-arm64-msvc@15.2.5': + '@next/swc-win32-arm64-msvc@16.1.6': optional: true - '@next/swc-win32-x64-msvc@15.2.5': + '@next/swc-win32-x64-msvc@16.1.6': optional: true '@noble/ciphers@1.3.0': {} @@ -12374,8 +12545,14 @@ snapshots: dependencies: cross-spawn: 7.0.6 + '@posthog/core@1.17.0': + dependencies: + cross-spawn: 7.0.6 + '@posthog/types@1.336.1': {} + '@posthog/types@1.336.4': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -12646,6 +12823,28 @@ snapshots: '@types/react': 18.3.27 '@types/react-dom': 18.3.7(@types/react@18.3.27) + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -13068,6 +13267,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.0': optional: true + '@roo-code/types@1.108.0': + dependencies: + zod: 3.25.76 + '@sec-ant/readable-stream@0.4.1': {} '@shikijs/core@3.21.0': @@ -13605,8 +13808,6 @@ snapshots: '@standard-schema/utils@0.3.0': {} - '@swc/counter@0.1.3': {} - '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -14190,11 +14391,10 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vercel/og@0.6.8': + '@vercel/og@0.8.6': dependencies: '@resvg/resvg-wasm': 2.4.0 - satori: 0.12.2 - yoga-wasm-web: 0.3.3 + satori: 0.16.0 '@vercel/oidc@3.1.0': {} @@ -14808,10 +15008,6 @@ snapshots: esbuild: 0.27.2 load-tsconfig: 0.2.5 - busboy@1.6.0: - dependencies: - streamsearch: 1.1.0 - bytes@3.1.2: {} c8@9.1.0: @@ -15077,18 +15273,6 @@ snapshots: color-name@1.1.4: {} - color-string@1.9.1: - dependencies: - color-name: 1.1.4 - simple-swizzle: 0.2.4 - optional: true - - color@4.2.3: - dependencies: - color-convert: 2.0.1 - color-string: 1.9.1 - optional: true - colorette@2.0.20: {} combined-stream@1.0.8: @@ -15717,6 +15901,8 @@ snapshots: embla-carousel@8.6.0: {} + emoji-regex-xs@2.0.1: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -16375,7 +16561,7 @@ snapshots: fraction.js@5.3.4: {} - framer-motion@12.15.0(@emotion/is-prop-valid@1.4.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + framer-motion@12.29.2(@emotion/is-prop-valid@1.4.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: motion-dom: 12.29.2 motion-utils: 12.29.2 @@ -17007,9 +17193,6 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 - is-arrayish@0.3.4: - optional: true - is-async-function@2.1.1: dependencies: async-function: 1.0.0 @@ -17674,6 +17857,10 @@ snapshots: dependencies: react: 18.3.1 + lucide-react@0.563.0(react@18.3.1): + dependencies: + react: 18.3.1 + lz-string@1.5.0: {} macos-release@3.4.0: {} @@ -18307,42 +18494,41 @@ snapshots: netmask@2.0.2: {} - next-sitemap@4.2.3(next@15.2.9(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): + next-sitemap@4.2.3(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): dependencies: '@corex/deepmerge': 4.0.43 '@next/env': 13.5.11 fast-glob: 3.3.3 minimist: 1.2.8 - next: 15.2.9(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes@0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - next@15.2.9(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@next/env': 15.2.9 - '@swc/counter': 0.1.3 + '@next/env': 16.1.6 '@swc/helpers': 0.5.15 - busboy: 1.6.0 + baseline-browser-mapping: 2.9.19 caniuse-lite: 1.0.30001766 postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) styled-jsx: 5.1.6(react@18.3.1) optionalDependencies: - '@next/swc-darwin-arm64': 15.2.5 - '@next/swc-darwin-x64': 15.2.5 - '@next/swc-linux-arm64-gnu': 15.2.5 - '@next/swc-linux-arm64-musl': 15.2.5 - '@next/swc-linux-x64-gnu': 15.2.5 - '@next/swc-linux-x64-musl': 15.2.5 - '@next/swc-win32-arm64-msvc': 15.2.5 - '@next/swc-win32-x64-msvc': 15.2.5 + '@next/swc-darwin-arm64': 16.1.6 + '@next/swc-darwin-x64': 16.1.6 + '@next/swc-linux-arm64-gnu': 16.1.6 + '@next/swc-linux-arm64-musl': 16.1.6 + '@next/swc-linux-x64-gnu': 16.1.6 + '@next/swc-linux-x64-musl': 16.1.6 + '@next/swc-win32-arm64-msvc': 16.1.6 + '@next/swc-win32-x64-msvc': 16.1.6 '@opentelemetry/api': 1.9.0 babel-plugin-react-compiler: 1.0.0 - sharp: 0.33.5 + sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros @@ -18888,6 +19074,22 @@ snapshots: query-selector-shadow-dom: 1.0.1 web-vitals: 5.1.0 + posthog-js@1.336.4: + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + '@posthog/core': 1.17.0 + '@posthog/types': 1.336.4 + core-js: 3.48.0 + dompurify: 3.3.1 + fflate: 0.4.8 + preact: 10.28.2 + query-selector-shadow-dom: 1.0.1 + web-vitals: 5.1.0 + posthog-node@5.24.4: dependencies: '@posthog/core': 1.15.0 @@ -19641,19 +19843,19 @@ snapshots: dependencies: truncate-utf8-bytes: 1.0.2 - satori@0.12.2: + satori@0.16.0: dependencies: '@shuding/opentype.js': 1.4.0-beta.0 css-background-parser: 0.1.0 css-box-shadow: 1.0.0-3 css-gradient-parser: 0.0.16 css-to-react-native: 3.2.0 - emoji-regex: 10.6.0 + emoji-regex-xs: 2.0.1 escape-html: 1.0.3 linebreak: 1.1.0 parse-css-color: 0.2.1 postcss-value-parser: 4.2.0 - yoga-wasm-web: 0.3.3 + yoga-layout: 3.2.1 sax@1.2.4: {} @@ -19772,31 +19974,36 @@ snapshots: shallowequal@1.1.0: {} - sharp@0.33.5: + sharp@0.34.5: dependencies: - color: 4.2.3 + '@img/colour': 1.0.0 detect-libc: 2.1.2 semver: 7.7.3 optionalDependencies: - '@img/sharp-darwin-arm64': 0.33.5 - '@img/sharp-darwin-x64': 0.33.5 - '@img/sharp-libvips-darwin-arm64': 1.0.4 - '@img/sharp-libvips-darwin-x64': 1.0.4 - '@img/sharp-libvips-linux-arm': 1.0.5 - '@img/sharp-libvips-linux-arm64': 1.0.4 - '@img/sharp-libvips-linux-s390x': 1.0.4 - '@img/sharp-libvips-linux-x64': 1.0.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 - '@img/sharp-libvips-linuxmusl-x64': 1.0.4 - '@img/sharp-linux-arm': 0.33.5 - '@img/sharp-linux-arm64': 0.33.5 - '@img/sharp-linux-s390x': 0.33.5 - '@img/sharp-linux-x64': 0.33.5 - '@img/sharp-linuxmusl-arm64': 0.33.5 - '@img/sharp-linuxmusl-x64': 0.33.5 - '@img/sharp-wasm32': 0.33.5 - '@img/sharp-win32-ia32': 0.33.5 - '@img/sharp-win32-x64': 0.33.5 + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 optional: true shebang-command@2.0.0: @@ -19872,11 +20079,6 @@ snapshots: simple-invariant@2.0.1: {} - simple-swizzle@0.2.4: - dependencies: - is-arrayish: 0.3.4 - optional: true - sirv@3.0.2: dependencies: '@polka/url': 1.0.0-next.29 @@ -20010,8 +20212,6 @@ snapshots: dependencies: stream-chain: 2.2.5 - streamsearch@1.1.0: {} - streamx@2.23.0: dependencies: events-universal: 1.0.1 @@ -21255,8 +21455,6 @@ snapshots: yoga-layout@3.2.1: {} - yoga-wasm-web@0.3.3: {} - zip-stream@4.1.1: dependencies: archiver-utils: 3.0.4 diff --git a/scripts/code-server.js b/scripts/code-server.js new file mode 100644 index 0000000000..1b6b434840 --- /dev/null +++ b/scripts/code-server.js @@ -0,0 +1,71 @@ +/** + * Serve script for Roo Code extension development + * + * Usage: + * pnpm code-server:install # Build and install the extension into code-server + * + * After making code changes, run `pnpm code-server:install` again and reload the window + * (Cmd+Shift+P → "Developer: Reload Window") + */ + +const { execSync } = require("child_process") +const path = require("path") +const os = require("os") + +const RESET = "\x1b[0m" +const BOLD = "\x1b[1m" +const GREEN = "\x1b[32m" +const YELLOW = "\x1b[33m" +const CYAN = "\x1b[36m" +const RED = "\x1b[31m" + +// Build vsix to a fixed path in temp directory +const VSIX_PATH = path.join(os.tmpdir(), "roo-code-serve.vsix") + +function log(message) { + console.log(`${CYAN}[code-server]${RESET} ${message}`) +} + +function logSuccess(message) { + console.log(`${GREEN}✓${RESET} ${message}`) +} + +function logWarning(message) { + console.log(`${YELLOW}⚠${RESET} ${message}`) +} + +function logError(message) { + console.error(`${RED}✗${RESET} ${message}`) +} + +async function main() { + console.log(`\n${BOLD}🔧 Roo Code - Install Extension${RESET}\n`) + + // Build vsix to temp directory + log(`Building vsix to ${VSIX_PATH}...`) + try { + execSync(`pnpm vsix -- --out "${VSIX_PATH}"`, { stdio: "inherit" }) + logSuccess("Build complete") + } catch (error) { + logError("Build failed") + process.exit(1) + } + + // Install extension into code-server + log("Installing extension into code-server...") + try { + execSync(`code-server --install-extension "${VSIX_PATH}"`, { stdio: "inherit" }) + logSuccess("Extension installed") + } catch (error) { + logWarning("Extension installation had warnings (this is usually fine)") + } + + console.log(`\n${GREEN}✓ Extension built and installed.${RESET}`) + console.log(` If code-server is running, reload the window to pick up changes.`) + console.log(` (Cmd+Shift+P → "Developer: Reload Window")\n`) +} + +main().catch((error) => { + logError(error.message) + process.exit(1) +}) diff --git a/src/__tests__/command-mentions.spec.ts b/src/__tests__/command-mentions.spec.ts index 1b3ccc01aa..7b69d245d8 100644 --- a/src/__tests__/command-mentions.spec.ts +++ b/src/__tests__/command-mentions.spec.ts @@ -36,7 +36,6 @@ describe("Command Mentions", () => { false, // showRooIgnoredFiles true, // includeDiagnosticMessages 50, // maxDiagnosticMessages - undefined, // maxReadFileLine ) } diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index b4c0db9584..06001f241a 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -9,17 +9,28 @@ vi.mock("vscode", async (importOriginal) => ({ createOutputChannel: vi.fn().mockReturnValue({ appendLine: vi.fn(), }), + createTextEditorDecorationType: vi.fn(), + createStatusBarItem: vi.fn().mockReturnValue({ + text: "", + tooltip: "", + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), + }), registerWebviewViewProvider: vi.fn(), registerUriHandler: vi.fn(), tabGroups: { onDidChangeTabs: vi.fn(), }, onDidChangeActiveTextEditor: vi.fn(), + onDidChangeVisibleTextEditors: vi.fn(), + onDidChangeTextEditorSelection: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, workspace: { registerTextDocumentContentProvider: vi.fn(), getConfiguration: vi.fn().mockReturnValue({ get: vi.fn().mockReturnValue([]), + update: vi.fn().mockResolvedValue(undefined), }), createFileSystemWatcher: vi.fn().mockReturnValue({ onDidCreate: vi.fn(), @@ -28,20 +39,37 @@ vi.mock("vscode", async (importOriginal) => ({ dispose: vi.fn(), }), onDidChangeWorkspaceFolders: vi.fn(), + onDidChangeConfiguration: vi.fn(), + onDidChangeTextDocument: vi.fn(), + onDidOpenTextDocument: vi.fn(), + onDidCloseTextDocument: vi.fn(), }, languages: { registerCodeActionsProvider: vi.fn(), + registerCodeLensProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }), + registerInlineCompletionItemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, commands: { executeCommand: vi.fn(), registerCommand: vi.fn().mockReturnValue({ dispose: vi.fn() }), + registerTextEditorCommand: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, env: { language: "en", + appName: "roo-code", }, ExtensionMode: { Production: 1, }, + ConfigurationTarget: { + Global: 1, + Workspace: 2, + WorkspaceFolder: 3, + }, + StatusBarAlignment: { + Left: 1, + Right: 2, + }, version: "1.80.0", RelativePattern: vi.fn().mockImplementation((base, pattern) => ({ base, @@ -53,6 +81,17 @@ vi.mock("@dotenvx/dotenvx", () => ({ config: vi.fn(), })) +// Mock fs so the extension module can safely check for optional .env. +vi.mock("fs", () => { + const mockFs = { + existsSync: vi.fn().mockReturnValue(false), + } + return { + default: mockFs, + existsSync: mockFs.existsSync, + } +}) + const mockBridgeOrchestratorDisconnect = vi.fn().mockResolvedValue(undefined) const mockCloudServiceInstance = { @@ -190,7 +229,17 @@ vi.mock("../core/webview/ClineProvider", async () => { resolveWebviewView: vi.fn(), postMessageToWebview: vi.fn(), postStateToWebview: vi.fn(), - getState: vi.fn().mockResolvedValue({}), + getState: vi.fn().mockResolvedValue({ + apiConfiguration: { + zgsmAccessToken: undefined, + zgsmRefreshToken: undefined, + zgsmState: undefined, + }, + }), + getValue: vi.fn().mockReturnValue(undefined), + setValue: vi.fn().mockResolvedValue(undefined), + setZgsmAuthCommands: vi.fn(), + log: vi.fn(), remoteControlEnabled: vi.fn().mockImplementation(async (enabled: boolean) => { if (!enabled) { await BridgeOrchestrator.disconnect() @@ -245,6 +294,46 @@ describe("extension.ts", () => { authStateChangedHandler = undefined }) + test("does not call dotenvx.config when optional .env does not exist", async () => { + vi.resetModules() + vi.clearAllMocks() + + // Re-import modules after resetModules to get fresh references + const fs = await import("fs") + const dotenvx = await import("@dotenvx/dotenvx") + + // Set mocks after module reset + vi.mocked(fs.existsSync).mockReturnValue(false) + + // Clear any previous calls on dotenvx.config + vi.mocked(dotenvx.config).mockClear() + + const { activate } = await import("../extension") + await activate(mockContext) + + expect(dotenvx.config).not.toHaveBeenCalled() + }) + + test("calls dotenvx.config when optional .env exists", async () => { + vi.resetModules() + vi.clearAllMocks() + + // Re-import modules after resetModules to get fresh references + const fs = await import("fs") + const dotenvx = await import("@dotenvx/dotenvx") + + // Set mocks after module reset + vi.mocked(fs.existsSync).mockReturnValue(true) + + // Clear any previous calls on dotenvx.config to ensure we count only this test's call + vi.mocked(dotenvx.config).mockClear() + + const { activate } = await import("../extension") + await activate(mockContext) + + expect(dotenvx.config).toHaveBeenCalledTimes(1) + }) + test("authStateChangedHandler calls BridgeOrchestrator.disconnect when logged-out event fires", async () => { const { CloudService, BridgeOrchestrator } = await import("@roo-code/cloud") diff --git a/src/api/providers/__tests__/bedrock-native-tools.spec.ts b/src/api/providers/__tests__/bedrock-native-tools.spec.ts index d3f54d65b8..e95b2c34b6 100644 --- a/src/api/providers/__tests__/bedrock-native-tools.spec.ts +++ b/src/api/providers/__tests__/bedrock-native-tools.spec.ts @@ -135,23 +135,18 @@ describe("AwsBedrockHandler Native Tool Calling", () => { parameters: { type: "object", properties: { - files: { - type: "array", - items: { - type: "object", - properties: { - path: { type: "string" }, - line_ranges: { - type: ["array", "null"], - items: { type: "integer" }, - description: "Optional line ranges", - }, + path: { type: "string" }, + indentation: { + type: ["object", "null"], + properties: { + anchor_line: { + type: ["integer", "null"], + description: "Optional anchor line", }, - required: ["path", "line_ranges"], }, }, }, - required: ["files"], + required: ["path"], }, }, }, @@ -167,15 +162,14 @@ describe("AwsBedrockHandler Native Tool Calling", () => { expect(executeCommandSchema.properties.cwd.type).toBeUndefined() expect(executeCommandSchema.properties.cwd.description).toBe("Working directory (optional)") - // Second tool: line_ranges should be transformed from type: ["array", "null"] to anyOf - // with items moved inside the array variant (required by GPT-5-mini strict schema validation) + // Second tool: nested nullable object should be transformed from type: ["object", "null"] to anyOf const readFileSchema = bedrockTools[1].toolSpec.inputSchema.json as any - const lineRanges = readFileSchema.properties.files.items.properties.line_ranges - expect(lineRanges.anyOf).toEqual([{ type: "array", items: { type: "integer" } }, { type: "null" }]) - expect(lineRanges.type).toBeUndefined() - // items should now be inside the array variant, not at root - expect(lineRanges.items).toBeUndefined() - expect(lineRanges.description).toBe("Optional line ranges") + const indentation = readFileSchema.properties.indentation + expect(indentation.anyOf).toBeDefined() + expect(indentation.type).toBeUndefined() + // Object-level schema properties are preserved at the root, not inside the anyOf object variant + expect(indentation.additionalProperties).toBe(false) + expect(indentation.properties.anchor_line.anyOf).toEqual([{ type: "integer" }, { type: "null" }]) }) it("should filter non-function tools", () => { diff --git a/src/api/providers/__tests__/cerebras.spec.ts b/src/api/providers/__tests__/cerebras.spec.ts index 3c2db812e2..caf8861b46 100644 --- a/src/api/providers/__tests__/cerebras.spec.ts +++ b/src/api/providers/__tests__/cerebras.spec.ts @@ -1,249 +1,455 @@ -// Mock i18n -vi.mock("../../i18n", () => ({ - t: vi.fn((key: string, params?: Record) => { - // Return a simplified mock translation for testing - if (key.startsWith("common:errors.cerebras.")) { - return `Mocked: ${key.replace("common:errors.cerebras.", "")}` - } - return key - }), +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), })) -// Mock DEFAULT_HEADERS - use lazy evaluation to avoid initialization issues -vi.mock("../constants", () => ({ - DEFAULT_HEADERS: { - "HTTP-Referer": "https://github.com/zgsm-ai/zgsm", - "X-Title": "Costrict", - "X-Costrict-Version": "test-version", - }, +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) + +vi.mock("@ai-sdk/cerebras", () => ({ + createCerebras: vi.fn(() => { + // Return a function that returns a mock language model + return vi.fn(() => ({ + modelId: "llama-3.3-70b", + provider: "cerebras", + })) + }), })) -import { CerebrasHandler } from "../cerebras" -import { cerebrasModels, type CerebrasModelId } from "@roo-code/types" +import type { Anthropic } from "@anthropic-ai/sdk" + +import { cerebrasDefaultModelId, cerebrasModels, type CerebrasModelId } from "@roo-code/types" -// Mock fetch globally -global.fetch = vi.fn() +import type { ApiHandlerOptions } from "../../../shared/api" + +import { CerebrasHandler } from "../cerebras" describe("CerebrasHandler", () => { let handler: CerebrasHandler - const mockOptions = { - cerebrasApiKey: "test-api-key", - apiModelId: "llama-3.3-70b" as CerebrasModelId, - } + let mockOptions: ApiHandlerOptions beforeEach(() => { - vi.clearAllMocks() + mockOptions = { + cerebrasApiKey: "test-api-key", + apiModelId: "llama-3.3-70b" as CerebrasModelId, + } handler = new CerebrasHandler(mockOptions) + vi.clearAllMocks() }) describe("constructor", () => { - it("should throw error when API key is missing", () => { - expect(() => new CerebrasHandler({ cerebrasApiKey: "" })).toThrow("Cerebras API key is required") + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(CerebrasHandler) + expect(handler.getModel().id).toBe(mockOptions.apiModelId) }) - it("should initialize with valid API key", () => { - expect(() => new CerebrasHandler(mockOptions)).not.toThrow() + it("should use default model ID if not provided", () => { + const handlerWithoutModel = new CerebrasHandler({ + ...mockOptions, + apiModelId: undefined, + }) + expect(handlerWithoutModel.getModel().id).toBe(cerebrasDefaultModelId) }) }) describe("getModel", () => { - it("should return correct model info", () => { - const { id, info } = handler.getModel() - expect(id).toBe("llama-3.3-70b") - expect(info).toEqual(cerebrasModels["llama-3.3-70b"]) - }) - - it("should fallback to default model when apiModelId is not provided", () => { - const handlerWithoutModel = new CerebrasHandler({ cerebrasApiKey: "test" }) - const { id } = handlerWithoutModel.getModel() - expect(id).toBe("gpt-oss-120b") // cerebrasDefaultModelId + it("should return model info for valid model ID", () => { + const model = handler.getModel() + expect(model.id).toBe(mockOptions.apiModelId) + expect(model.info).toBeDefined() + expect(model.info.maxTokens).toBe(16384) + expect(model.info.contextWindow).toBe(64000) + expect(model.info.supportsImages).toBe(false) + expect(model.info.supportsPromptCache).toBe(false) }) - }) - describe("message conversion", () => { - it("should strip thinking tokens from assistant messages", () => { - // This would test the stripThinkingTokens function - // Implementation details would test the regex functionality + it("should return provided model ID with default model info if model does not exist", () => { + const handlerWithInvalidModel = new CerebrasHandler({ + ...mockOptions, + apiModelId: "invalid-model", + }) + const model = handlerWithInvalidModel.getModel() + expect(model.id).toBe("invalid-model") // Returns provided ID + expect(model.info).toBeDefined() + // Should have the same base properties as default model + expect(model.info.contextWindow).toBe(cerebrasModels[cerebrasDefaultModelId].contextWindow) }) - it("should flatten complex message content to strings", () => { - // This would test the flattenMessageContent function - // Test various content types: strings, arrays, image objects + it("should return default model if no model ID is provided", () => { + const handlerWithoutModel = new CerebrasHandler({ + ...mockOptions, + apiModelId: undefined, + }) + const model = handlerWithoutModel.getModel() + expect(model.id).toBe(cerebrasDefaultModelId) + expect(model.info).toBeDefined() }) - it("should convert OpenAI messages to Cerebras format", () => { - // This would test the convertToCerebrasMessages function - // Ensure all messages have string content and proper role/content structure + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") }) }) describe("createMessage", () => { - it("should make correct API request", async () => { - // Mock successful API response - const mockResponse = { - ok: true, - body: { - getReader: () => ({ - read: vi.fn().mockResolvedValueOnce({ done: true, value: new Uint8Array() }), - releaseLock: vi.fn(), - }), - }, + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text" as const, + text: "Hello!", + }, + ], + }, + ] + + it("should handle streaming responses", async () => { + // Mock the fullStream async generator + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } } - vi.mocked(fetch).mockResolvedValueOnce(mockResponse as any) - const generator = handler.createMessage("System prompt", []) - await generator.next() // Actually start the generator to trigger the fetch call + // Mock usage promise + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) - // Test that fetch was called with correct parameters - expect(fetch).toHaveBeenCalledWith( - "https://api.cerebras.ai/v1/chat/completions", - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - "Content-Type": "application/json", - Authorization: "Bearer test-api-key", - "HTTP-Referer": "https://github.com/zgsm-ai/zgsm", - "X-Title": "Costrict", - "X-Costrict-Version": "test-version", - }), - }), - ) - }) + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) - it("should handle API errors properly", async () => { - const mockErrorResponse = { - ok: false, - status: 400, - text: () => Promise.resolve('{"error": {"message": "Bad Request"}}'), + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - vi.mocked(fetch).mockResolvedValueOnce(mockErrorResponse as any) - const generator = handler.createMessage("System prompt", []) - // Since the mock isn't working, let's just check that an error is thrown - await expect(generator.next()).rejects.toThrow() + expect(chunks.length).toBeGreaterThan(0) + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response") }) - it("should parse streaming responses correctly", async () => { - // Test streaming response parsing - // Mock ReadableStream with various data chunks - // Verify thinking token extraction and usage tracking + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(5) }) - it("should handle temperature clamping", async () => { - const handlerWithTemp = new CerebrasHandler({ - ...mockOptions, - modelTemperature: 2.0, // Above Cerebras max of 1.5 + it("should handle reasoning content in streaming responses", async () => { + // Mock the fullStream async generator with reasoning content + async function* mockFullStream() { + yield { type: "reasoning", text: "Let me think about this..." } + yield { type: "reasoning", text: " I'll analyze step by step." } + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: { + reasoningTokens: 15, + }, }) - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - body: { getReader: () => ({ read: () => Promise.resolve({ done: true }), releaseLock: vi.fn() }) }, - } as any) + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } - await handlerWithTemp.createMessage("test", []).next() + // Should have reasoning chunks + const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") + expect(reasoningChunks.length).toBe(2) + expect(reasoningChunks[0].text).toBe("Let me think about this...") + expect(reasoningChunks[1].text).toBe(" I'll analyze step by step.") - const requestBody = JSON.parse(vi.mocked(fetch).mock.calls[0][1]?.body as string) - expect(requestBody.temperature).toBe(1.5) // Should be clamped + // Should also have text chunks + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks.length).toBe(1) + expect(textChunks[0].text).toBe("Test response") }) }) describe("completePrompt", () => { - it("should handle non-streaming completion", async () => { - const mockResponse = { - ok: true, - json: () => - Promise.resolve({ - choices: [{ message: { content: "Test response" } }], - }), - } - vi.mocked(fetch).mockResolvedValueOnce(mockResponse as any) + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion", + }) const result = await handler.completePrompt("Test prompt") - expect(result).toBe("Test response") + + expect(result).toBe("Test completion") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) }) }) - describe("token usage and cost calculation", () => { - it("should track token usage properly", () => { - // Test that lastUsage is updated correctly - // Test getApiCost returns calculated cost based on actual usage - }) + describe("processUsageMetrics", () => { + it("should correctly process usage metrics", () => { + // We need to access the protected method, so we'll create a test subclass + class TestCerebrasHandler extends CerebrasHandler { + public testProcessUsageMetrics(usage: any) { + return this.processUsageMetrics(usage) + } + } - it("should provide usage estimates when API doesn't return usage", () => { - // Test fallback token estimation logic - }) - }) + const testHandler = new TestCerebrasHandler(mockOptions) - describe("convertToolsForOpenAI", () => { - it("should set all tools to strict: false for Cerebras API consistency", () => { - // Access the protected method through a test subclass - const regularTool = { - type: "function", - function: { - name: "read_file", - parameters: { - type: "object", - properties: { - path: { type: "string" }, - }, - required: ["path"], - }, + const usage = { + inputTokens: 100, + outputTokens: 50, + details: { + cachedInputTokens: 20, + reasoningTokens: 30, }, } - // MCP tool with the 'mcp--' prefix - const mcpTool = { - type: "function", - function: { - name: "mcp--server--tool", - parameters: { - type: "object", - properties: { - arg: { type: "string" }, - }, - }, - }, + const result = testHandler.testProcessUsageMetrics(usage) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheReadTokens).toBe(20) + expect(result.reasoningTokens).toBe(30) + }) + + it("should handle missing cache metrics gracefully", () => { + class TestCerebrasHandler extends CerebrasHandler { + public testProcessUsageMetrics(usage: any) { + return this.processUsageMetrics(usage) + } } - // Create a test wrapper to access protected method + const testHandler = new TestCerebrasHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + } + + const result = testHandler.testProcessUsageMetrics(usage) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheReadTokens).toBeUndefined() + expect(result.reasoningTokens).toBeUndefined() + }) + }) + + describe("getMaxOutputTokens", () => { + it("should return maxTokens from model info", () => { class TestCerebrasHandler extends CerebrasHandler { - public testConvertToolsForOpenAI(tools: any[]) { - return this.convertToolsForOpenAI(tools) + public testGetMaxOutputTokens() { + return this.getMaxOutputTokens() } } - const testHandler = new TestCerebrasHandler({ cerebrasApiKey: "test" }) - const converted = testHandler.testConvertToolsForOpenAI([regularTool, mcpTool]) + const testHandler = new TestCerebrasHandler(mockOptions) + const result = testHandler.testGetMaxOutputTokens() - // Both tools should have strict: false - expect(converted).toHaveLength(2) - expect(converted![0].function.strict).toBe(false) - expect(converted![1].function.strict).toBe(false) + // llama-3.3-70b maxTokens is 16384 + expect(result).toBe(16384) }) - it("should return undefined when tools is undefined", () => { + it("should use modelMaxTokens when provided", () => { class TestCerebrasHandler extends CerebrasHandler { - public testConvertToolsForOpenAI(tools: any[] | undefined) { - return this.convertToolsForOpenAI(tools) + public testGetMaxOutputTokens() { + return this.getMaxOutputTokens() } } - const testHandler = new TestCerebrasHandler({ cerebrasApiKey: "test" }) - expect(testHandler.testConvertToolsForOpenAI(undefined)).toBeUndefined() + const customMaxTokens = 5000 + const testHandler = new TestCerebrasHandler({ + ...mockOptions, + modelMaxTokens: customMaxTokens, + }) + + const result = testHandler.testGetMaxOutputTokens() + expect(result).toBe(customMaxTokens) }) - it("should pass through non-function tools unchanged", () => { + it("should fall back to modelInfo.maxTokens when modelMaxTokens is not provided", () => { class TestCerebrasHandler extends CerebrasHandler { - public testConvertToolsForOpenAI(tools: any[]) { - return this.convertToolsForOpenAI(tools) + public testGetMaxOutputTokens() { + return this.getMaxOutputTokens() } } - const nonFunctionTool = { type: "other", data: "test" } - const testHandler = new TestCerebrasHandler({ cerebrasApiKey: "test" }) - const converted = testHandler.testConvertToolsForOpenAI([nonFunctionTool]) + const testHandler = new TestCerebrasHandler(mockOptions) + const result = testHandler.testGetMaxOutputTokens() + + // llama-3.3-70b has maxTokens of 16384 + expect(result).toBe(16384) + }) + }) + + describe("tool handling", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] + + it("should handle tool calls in streaming", async () => { + async function* mockFullStream() { + yield { + type: "tool-input-start", + id: "tool-call-1", + toolName: "read_file", + } + yield { + type: "tool-input-delta", + id: "tool-call-1", + delta: '{"path":"test.ts"}', + } + yield { + type: "tool-input-end", + id: "tool-call-1", + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + ], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start") + const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta") + const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end") + + expect(toolCallStartChunks.length).toBe(1) + expect(toolCallStartChunks[0].id).toBe("tool-call-1") + expect(toolCallStartChunks[0].name).toBe("read_file") + + expect(toolCallDeltaChunks.length).toBe(1) + expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}') + + expect(toolCallEndChunks.length).toBe(1) + expect(toolCallEndChunks[0].id).toBe("tool-call-1") + }) + + it("should ignore tool-call events to prevent duplicate tools in UI", async () => { + // tool-call events are intentionally ignored because tool-input-start/delta/end + // already provide complete tool call information. Emitting tool-call would cause + // duplicate tools in the UI for AI SDK providers (e.g., DeepSeek, Moonshot, Cerebras). + async function* mockFullStream() { + yield { + type: "tool-call", + toolCallId: "tool-call-1", + toolName: "read_file", + input: { path: "test.ts" }, + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + ], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } - expect(converted![0]).toEqual(nonFunctionTool) + // tool-call events are ignored, so no tool_call chunks should be emitted + const toolCallChunks = chunks.filter((c) => c.type === "tool_call") + expect(toolCallChunks.length).toBe(0) }) }) }) diff --git a/src/api/providers/__tests__/deepseek.spec.ts b/src/api/providers/__tests__/deepseek.spec.ts index 1aac662d9a..ece03c068e 100644 --- a/src/api/providers/__tests__/deepseek.spec.ts +++ b/src/api/providers/__tests__/deepseek.spec.ts @@ -1,125 +1,28 @@ -// Mocks must come first, before imports -const mockCreate = vi.fn() -vi.mock("openai", () => { +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - __esModule: true, - default: vi.fn().mockImplementation(() => ({ - chat: { - completions: { - create: mockCreate.mockImplementation(async (options) => { - if (!options.stream) { - return { - id: "test-completion", - choices: [ - { - message: { role: "assistant", content: "Test response", refusal: null }, - finish_reason: "stop", - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - prompt_tokens_details: { - cache_miss_tokens: 8, - cached_tokens: 2, - }, - }, - } - } - - // Check if this is a reasoning_content test by looking at model - const isReasonerModel = options.model?.includes("deepseek-reasoner") - const isToolCallTest = options.tools?.length > 0 - - // Return async iterator for streaming - return { - [Symbol.asyncIterator]: async function* () { - // For reasoner models, emit reasoning_content first - if (isReasonerModel) { - yield { - choices: [ - { - delta: { reasoning_content: "Let me think about this..." }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: { reasoning_content: " I'll analyze step by step." }, - index: 0, - }, - ], - usage: null, - } - } - - // For tool call tests with reasoner, emit tool call - if (isReasonerModel && isToolCallTest) { - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_123", - function: { - name: "get_weather", - arguments: '{"location":"SF"}', - }, - }, - ], - }, - index: 0, - }, - ], - usage: null, - } - } else { - yield { - choices: [ - { - delta: { content: "Test response" }, - index: 0, - }, - ], - usage: null, - } - } - - yield { - choices: [ - { - delta: {}, - index: 0, - finish_reason: isToolCallTest ? "tool_calls" : "stop", - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - prompt_tokens_details: { - cache_miss_tokens: 8, - cached_tokens: 2, - }, - }, - } - }, - } - }), - }, - }, - })), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) -import OpenAI from "openai" +vi.mock("@ai-sdk/deepseek", () => ({ + createDeepSeek: vi.fn(() => { + // Return a function that returns a mock language model + return vi.fn(() => ({ + modelId: "deepseek-chat", + provider: "deepseek", + })) + }), +})) + import type { Anthropic } from "@anthropic-ai/sdk" import { deepSeekDefaultModelId, type ModelInfo } from "@roo-code/types" @@ -148,15 +51,6 @@ describe("DeepSeekHandler", () => { expect(handler.getModel().id).toBe(mockOptions.apiModelId) }) - it.skip("should throw error if API key is missing", () => { - expect(() => { - new DeepSeekHandler({ - ...mockOptions, - deepSeekApiKey: undefined, - }) - }).toThrow("DeepSeek API key is required") - }) - it("should use default model ID if not provided", () => { const handlerWithoutModel = new DeepSeekHandler({ ...mockOptions, @@ -171,12 +65,6 @@ describe("DeepSeekHandler", () => { deepSeekBaseUrl: undefined, }) expect(handlerWithoutBaseUrl).toBeInstanceOf(DeepSeekHandler) - // The base URL is passed to OpenAI client internally - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ - baseURL: "https://api.deepseek.com", - }), - ) }) it("should use custom base URL if provided", () => { @@ -186,18 +74,6 @@ describe("DeepSeekHandler", () => { deepSeekBaseUrl: customBaseUrl, }) expect(handlerWithCustomUrl).toBeInstanceOf(DeepSeekHandler) - // The custom base URL is passed to OpenAI client - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ - baseURL: customBaseUrl, - }), - ) - }) - - it("should set includeMaxTokens to true", () => { - // Create a new handler and verify OpenAI client was called with includeMaxTokens - const _handler = new DeepSeekHandler(mockOptions) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: mockOptions.deepSeekApiKey })) }) }) @@ -296,6 +172,31 @@ describe("DeepSeekHandler", () => { ] it("should handle streaming responses", async () => { + // Mock the fullStream async generator + // Note: processAiSdkStreamPart expects 'text' property for text-delta type + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + // Mock usage and providerMetadata promises + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({ + deepseek: { + promptCacheHitTokens: 2, + promptCacheMissTokens: 8, + }, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { @@ -309,6 +210,28 @@ describe("DeepSeekHandler", () => { }) it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({ + deepseek: { + promptCacheHitTokens: 2, + promptCacheMissTokens: 8, + }, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { @@ -321,7 +244,30 @@ describe("DeepSeekHandler", () => { expect(usageChunks[0].outputTokens).toBe(5) }) - it("should include cache metrics in usage information", async () => { + it("should include cache metrics in usage information from providerMetadata", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + // DeepSeek provides cache metrics via providerMetadata + const mockProviderMetadata = Promise.resolve({ + deepseek: { + promptCacheHitTokens: 2, + promptCacheMissTokens: 8, + }, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { @@ -330,29 +276,76 @@ describe("DeepSeekHandler", () => { const usageChunks = chunks.filter((chunk) => chunk.type === "usage") expect(usageChunks.length).toBeGreaterThan(0) - expect(usageChunks[0].cacheWriteTokens).toBe(8) - expect(usageChunks[0].cacheReadTokens).toBe(2) + expect(usageChunks[0].cacheWriteTokens).toBe(8) // promptCacheMissTokens + expect(usageChunks[0].cacheReadTokens).toBe(2) // promptCacheHitTokens + }) + }) + + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion", + }) + + const result = await handler.completePrompt("Test prompt") + + expect(result).toBe("Test completion") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) }) }) describe("processUsageMetrics", () => { - it("should correctly process usage metrics including cache information", () => { + it("should correctly process usage metrics including cache information from providerMetadata", () => { // We need to access the protected method, so we'll create a test subclass class TestDeepSeekHandler extends DeepSeekHandler { - public testProcessUsageMetrics(usage: any) { - return this.processUsageMetrics(usage) + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestDeepSeekHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + } + + // DeepSeek provides cache metrics via providerMetadata + const providerMetadata = { + deepseek: { + promptCacheHitTokens: 20, + promptCacheMissTokens: 80, + }, + } + + const result = testHandler.testProcessUsageMetrics(usage, providerMetadata) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBe(80) // promptCacheMissTokens + expect(result.cacheReadTokens).toBe(20) // promptCacheHitTokens + }) + + it("should handle usage with details.cachedInputTokens when providerMetadata is not available", () => { + class TestDeepSeekHandler extends DeepSeekHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) } } const testHandler = new TestDeepSeekHandler(mockOptions) const usage = { - prompt_tokens: 100, - completion_tokens: 50, - total_tokens: 150, - prompt_tokens_details: { - cache_miss_tokens: 80, - cached_tokens: 20, + inputTokens: 100, + outputTokens: 50, + details: { + cachedInputTokens: 25, + reasoningTokens: 30, }, } @@ -361,24 +354,24 @@ describe("DeepSeekHandler", () => { expect(result.type).toBe("usage") expect(result.inputTokens).toBe(100) expect(result.outputTokens).toBe(50) - expect(result.cacheWriteTokens).toBe(80) - expect(result.cacheReadTokens).toBe(20) + expect(result.cacheReadTokens).toBe(25) // from details.cachedInputTokens + expect(result.cacheWriteTokens).toBeUndefined() + expect(result.reasoningTokens).toBe(30) }) it("should handle missing cache metrics gracefully", () => { class TestDeepSeekHandler extends DeepSeekHandler { - public testProcessUsageMetrics(usage: any) { - return this.processUsageMetrics(usage) + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) } } const testHandler = new TestDeepSeekHandler(mockOptions) const usage = { - prompt_tokens: 100, - completion_tokens: 50, - total_tokens: 150, - // No prompt_tokens_details + inputTokens: 100, + outputTokens: 50, + // No details or providerMetadata } const result = testHandler.testProcessUsageMetrics(usage) @@ -391,7 +384,7 @@ describe("DeepSeekHandler", () => { }) }) - describe("interleaved thinking mode", () => { + describe("reasoning content with deepseek-reasoner", () => { const systemPrompt = "You are a helpful assistant." const messages: Anthropic.Messages.MessageParam[] = [ { @@ -405,12 +398,41 @@ describe("DeepSeekHandler", () => { }, ] - it("should handle reasoning_content in streaming responses for deepseek-reasoner", async () => { + it("should handle reasoning content in streaming responses for deepseek-reasoner", async () => { const reasonerHandler = new DeepSeekHandler({ ...mockOptions, apiModelId: "deepseek-reasoner", }) + // Mock the fullStream async generator with reasoning content + // Note: processAiSdkStreamPart expects 'text' property for reasoning type + async function* mockFullStream() { + yield { type: "reasoning", text: "Let me think about this..." } + yield { type: "reasoning", text: " I'll analyze step by step." } + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: { + reasoningTokens: 15, + }, + }) + + const mockProviderMetadata = Promise.resolve({ + deepseek: { + promptCacheHitTokens: 2, + promptCacheMissTokens: 8, + }, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + const stream = reasonerHandler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { @@ -419,54 +441,91 @@ describe("DeepSeekHandler", () => { // Should have reasoning chunks const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") - expect(reasoningChunks.length).toBeGreaterThan(0) + expect(reasoningChunks.length).toBe(2) expect(reasoningChunks[0].text).toBe("Let me think about this...") expect(reasoningChunks[1].text).toBe(" I'll analyze step by step.") + + // Should also have text chunks + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks.length).toBe(1) + expect(textChunks[0].text).toBe("Test response") }) - it("should pass thinking parameter for deepseek-reasoner model", async () => { + it("should include reasoningTokens in usage for deepseek-reasoner", async () => { const reasonerHandler = new DeepSeekHandler({ ...mockOptions, apiModelId: "deepseek-reasoner", }) - const stream = reasonerHandler.createMessage(systemPrompt, messages) - for await (const _chunk of stream) { - // Consume the stream + async function* mockFullStream() { + yield { type: "reasoning", text: "Thinking..." } + yield { type: "text-delta", text: "Answer" } } - // Verify that the thinking parameter was passed to the API - // Note: mockCreate receives two arguments - request options and path options - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - thinking: { type: "enabled" }, - }), - {}, // Empty path options for non-Azure URLs - ) - }) + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: { + reasoningTokens: 15, + }, + }) - it("should NOT pass thinking parameter for deepseek-chat model", async () => { - const chatHandler = new DeepSeekHandler({ - ...mockOptions, - apiModelId: "deepseek-chat", + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, }) - const stream = chatHandler.createMessage(systemPrompt, messages) - for await (const _chunk of stream) { - // Consume the stream + const stream = reasonerHandler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - // Verify that the thinking parameter was NOT passed to the API - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs.thinking).toBeUndefined() + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBe(1) + expect(usageChunks[0].reasoningTokens).toBe(15) }) - it("should handle tool calls with reasoning_content", async () => { + it("should handle tool calls with reasoning content", async () => { const reasonerHandler = new DeepSeekHandler({ ...mockOptions, apiModelId: "deepseek-reasoner", }) + // Mock stream with reasoning followed by tool call via streaming events + // (tool-input-start/delta/end, NOT tool-call which is ignored to prevent duplicates) + async function* mockFullStream() { + yield { type: "reasoning", text: "Let me think about this..." } + yield { type: "reasoning", text: " I'll analyze step by step." } + yield { type: "tool-input-start", id: "call_123", toolName: "get_weather" } + yield { type: "tool-input-delta", id: "call_123", delta: '{"location":"SF"}' } + yield { type: "tool-input-end", id: "call_123" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: { + reasoningTokens: 15, + }, + }) + + const mockProviderMetadata = Promise.resolve({ + deepseek: { + promptCacheHitTokens: 2, + promptCacheMissTokens: 8, + }, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + const tools: any[] = [ { type: "function", @@ -486,12 +545,192 @@ describe("DeepSeekHandler", () => { // Should have reasoning chunks const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") - expect(reasoningChunks.length).toBeGreaterThan(0) + expect(reasoningChunks.length).toBe(2) + + // Should have tool call streaming chunks (start/delta/end, NOT tool_call) + const toolCallStartChunks = chunks.filter((chunk) => chunk.type === "tool_call_start") + expect(toolCallStartChunks.length).toBe(1) + expect(toolCallStartChunks[0].name).toBe("get_weather") + }) + }) + + describe("tool handling", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] + + it("should handle tool calls in streaming", async () => { + async function* mockFullStream() { + yield { + type: "tool-input-start", + id: "tool-call-1", + toolName: "read_file", + } + yield { + type: "tool-input-delta", + id: "tool-call-1", + delta: '{"path":"test.ts"}', + } + yield { + type: "tool-input-end", + id: "tool-call-1", + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + ], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start") + const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta") + const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end") + + expect(toolCallStartChunks.length).toBe(1) + expect(toolCallStartChunks[0].id).toBe("tool-call-1") + expect(toolCallStartChunks[0].name).toBe("read_file") + + expect(toolCallDeltaChunks.length).toBe(1) + expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}') + + expect(toolCallEndChunks.length).toBe(1) + expect(toolCallEndChunks[0].id).toBe("tool-call-1") + }) + + it("should ignore tool-call events to prevent duplicate tools in UI", async () => { + // tool-call events are intentionally ignored because tool-input-start/delta/end + // already provide complete tool call information. Emitting tool-call would cause + // duplicate tools in the UI for AI SDK providers (e.g., DeepSeek, Moonshot). + async function* mockFullStream() { + yield { + type: "tool-call", + toolCallId: "tool-call-1", + toolName: "read_file", + input: { path: "test.ts" }, + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + ], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // tool-call events are ignored, so no tool_call chunks should be emitted + const toolCallChunks = chunks.filter((c) => c.type === "tool_call") + expect(toolCallChunks.length).toBe(0) + }) + }) + + describe("getMaxOutputTokens", () => { + it("should return maxTokens from model info", () => { + class TestDeepSeekHandler extends DeepSeekHandler { + public testGetMaxOutputTokens() { + return this.getMaxOutputTokens() + } + } + + const testHandler = new TestDeepSeekHandler(mockOptions) + const result = testHandler.testGetMaxOutputTokens() + + // Default model maxTokens is 8192 + expect(result).toBe(8192) + }) + + it("should use modelMaxTokens when provided", () => { + class TestDeepSeekHandler extends DeepSeekHandler { + public testGetMaxOutputTokens() { + return this.getMaxOutputTokens() + } + } + + const customMaxTokens = 5000 + const testHandler = new TestDeepSeekHandler({ + ...mockOptions, + modelMaxTokens: customMaxTokens, + }) + + const result = testHandler.testGetMaxOutputTokens() + expect(result).toBe(customMaxTokens) + }) + + it("should fall back to modelInfo.maxTokens when modelMaxTokens is not provided", () => { + class TestDeepSeekHandler extends DeepSeekHandler { + public testGetMaxOutputTokens() { + return this.getMaxOutputTokens() + } + } + + const testHandler = new TestDeepSeekHandler(mockOptions) + const result = testHandler.testGetMaxOutputTokens() - // Should have tool call chunks - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") - expect(toolCallChunks.length).toBeGreaterThan(0) - expect(toolCallChunks[0].name).toBe("get_weather") + // deepseek-chat has maxTokens of 8192 + expect(result).toBe(8192) }) }) }) diff --git a/src/api/providers/__tests__/fireworks.spec.ts b/src/api/providers/__tests__/fireworks.spec.ts index 79f69f868b..77c4b10f45 100644 --- a/src/api/providers/__tests__/fireworks.spec.ts +++ b/src/api/providers/__tests__/fireworks.spec.ts @@ -1,594 +1,845 @@ -// npx vitest run api/providers/__tests__/fireworks.spec.ts +// npx vitest run src/api/providers/__tests__/fireworks.spec.ts -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) -import { type FireworksModelId, fireworksDefaultModelId, fireworksModels } from "@roo-code/types" +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) -import { FireworksHandler } from "../fireworks" +vi.mock("@ai-sdk/fireworks", () => ({ + createFireworks: vi.fn(() => { + // Return a function that returns a mock language model + return vi.fn(() => ({ + modelId: "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507", + provider: "fireworks", + })) + }), +})) -// Create mock functions -const mockCreate = vi.fn() +import type { Anthropic } from "@anthropic-ai/sdk" -// Mock OpenAI module -vi.mock("openai", () => ({ - default: vi.fn(() => ({ - chat: { - completions: { - create: mockCreate, - }, - }, - })), -})) +import { fireworksDefaultModelId, fireworksModels, type FireworksModelId } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../../shared/api" + +import { FireworksHandler } from "../fireworks" describe("FireworksHandler", () => { let handler: FireworksHandler + let mockOptions: ApiHandlerOptions beforeEach(() => { + mockOptions = { + fireworksApiKey: "test-fireworks-api-key", + apiModelId: "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507", + } + handler = new FireworksHandler(mockOptions) vi.clearAllMocks() - // Set up default mock implementation - mockCreate.mockImplementation(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { content: "Test response" }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } - }, - })) - handler = new FireworksHandler({ fireworksApiKey: "test-key" }) }) - afterEach(() => { - vi.restoreAllMocks() - }) + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(FireworksHandler) + expect(handler.getModel().id).toBe(mockOptions.apiModelId) + }) - it("should use the correct Fireworks base URL", () => { - new FireworksHandler({ fireworksApiKey: "test-fireworks-api-key" }) - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ baseURL: "https://api.fireworks.ai/inference/v1" }), - ) + it("should use default model ID if not provided", () => { + const handlerWithoutModel = new FireworksHandler({ + ...mockOptions, + apiModelId: undefined, + }) + expect(handlerWithoutModel.getModel().id).toBe(fireworksDefaultModelId) + }) }) - it("should use the provided API key", () => { - const fireworksApiKey = "test-fireworks-api-key" - new FireworksHandler({ fireworksApiKey }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: fireworksApiKey })) - }) + describe("getModel", () => { + it("should return default model when no model is specified", () => { + const handlerWithoutModel = new FireworksHandler({ + fireworksApiKey: "test-fireworks-api-key", + }) + const model = handlerWithoutModel.getModel() + expect(model.id).toBe(fireworksDefaultModelId) + expect(model.info).toEqual(fireworksModels[fireworksDefaultModelId]) + }) - it("should throw error when API key is not provided", () => { - expect(() => new FireworksHandler({})).toThrow("API key is required") - }) + it("should return specified model when valid model is provided", () => { + const testModelId: FireworksModelId = "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507" + const handlerWithModel = new FireworksHandler({ + apiModelId: testModelId, + fireworksApiKey: "test-fireworks-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual(fireworksModels[testModelId]) + }) - it("should return default model when no model is specified", () => { - const model = handler.getModel() - expect(model.id).toBe(fireworksDefaultModelId) - expect(model.info).toEqual(expect.objectContaining(fireworksModels[fireworksDefaultModelId])) - }) + it("should return Kimi K2 Instruct model with correct configuration", () => { + const testModelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-instruct" + const handlerWithModel = new FireworksHandler({ + apiModelId: testModelId, + fireworksApiKey: "test-fireworks-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 16384, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.6, + outputPrice: 2.5, + description: expect.stringContaining("Kimi K2 is a state-of-the-art mixture-of-experts"), + }), + ) + }) - it("should return specified model when valid model is provided", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", + it("should return Kimi K2 Thinking model with correct configuration", () => { + const testModelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-thinking" + const handlerWithModel = new FireworksHandler({ + apiModelId: testModelId, + fireworksApiKey: "test-fireworks-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 16000, + contextWindow: 256000, + supportsImages: false, + supportsPromptCache: true, + supportsTemperature: true, + preserveReasoning: true, + defaultTemperature: 1.0, + inputPrice: 0.6, + outputPrice: 2.5, + cacheReadsPrice: 0.15, + }), + ) }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual(expect.objectContaining(fireworksModels[testModelId])) - }) - it("should return Kimi K2 Instruct model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-instruct" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", + it("should return MiniMax M2 model with correct configuration", () => { + const testModelId: FireworksModelId = "accounts/fireworks/models/minimax-m2" + const handlerWithModel = new FireworksHandler({ + apiModelId: testModelId, + fireworksApiKey: "test-fireworks-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 4096, + contextWindow: 204800, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.3, + outputPrice: 1.2, + description: expect.stringContaining("MiniMax M2 is a high-performance language model"), + }), + ) }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 16384, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.6, - outputPrice: 2.5, - description: expect.stringContaining("Kimi K2 is a state-of-the-art mixture-of-experts"), - }), - ) - }) - it("should return Kimi K2 Thinking model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-thinking" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", + it("should return Qwen3 235B model with correct configuration", () => { + const testModelId: FireworksModelId = "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507" + const handlerWithModel = new FireworksHandler({ + apiModelId: testModelId, + fireworksApiKey: "test-fireworks-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 32768, + contextWindow: 256000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.22, + outputPrice: 0.88, + description: + "Latest Qwen3 thinking model, competitive against the best closed source models in Jul 2025.", + }), + ) }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 16000, - contextWindow: 256000, - supportsImages: false, - supportsPromptCache: true, - supportsTemperature: true, - preserveReasoning: true, - defaultTemperature: 1.0, - inputPrice: 0.6, - outputPrice: 2.5, - cacheReadsPrice: 0.15, - }), - ) - }) - it("should return MiniMax M2 model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/minimax-m2" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", + it("should return DeepSeek R1 model with correct configuration", () => { + const testModelId: FireworksModelId = "accounts/fireworks/models/deepseek-r1-0528" + const handlerWithModel = new FireworksHandler({ + apiModelId: testModelId, + fireworksApiKey: "test-fireworks-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 20480, + contextWindow: 160000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 3, + outputPrice: 8, + description: expect.stringContaining("05/28 updated checkpoint of Deepseek R1"), + }), + ) }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 4096, - contextWindow: 204800, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.3, - outputPrice: 1.2, - description: expect.stringContaining("MiniMax M2 is a high-performance language model"), - }), - ) - }) - it("should return Qwen3 235B model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", + it("should return DeepSeek V3 model with correct configuration", () => { + const testModelId: FireworksModelId = "accounts/fireworks/models/deepseek-v3" + const handlerWithModel = new FireworksHandler({ + apiModelId: testModelId, + fireworksApiKey: "test-fireworks-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 16384, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.9, + outputPrice: 0.9, + description: expect.stringContaining("strong Mixture-of-Experts (MoE) language model"), + }), + ) }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 32768, - contextWindow: 256000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.22, - outputPrice: 0.88, - description: - "Latest Qwen3 thinking model, competitive against the best closed source models in Jul 2025.", - }), - ) - }) - it("should return DeepSeek R1 model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/deepseek-r1-0528" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", + it("should return DeepSeek V3.1 model with correct configuration", () => { + const testModelId: FireworksModelId = "accounts/fireworks/models/deepseek-v3p1" + const handlerWithModel = new FireworksHandler({ + apiModelId: testModelId, + fireworksApiKey: "test-fireworks-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 16384, + contextWindow: 163840, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.56, + outputPrice: 1.68, + description: expect.stringContaining("DeepSeek v3.1 is an improved version"), + }), + ) }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 20480, - contextWindow: 160000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 3, - outputPrice: 8, - description: expect.stringContaining("05/28 updated checkpoint of Deepseek R1"), - }), - ) - }) - it("should return DeepSeek V3 model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/deepseek-v3" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", + it("should return GLM-4.5 model with correct configuration", () => { + const testModelId: FireworksModelId = "accounts/fireworks/models/glm-4p5" + const handlerWithModel = new FireworksHandler({ + apiModelId: testModelId, + fireworksApiKey: "test-fireworks-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 16384, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.55, + outputPrice: 2.19, + description: expect.stringContaining("Z.ai GLM-4.5 with 355B total parameters"), + }), + ) }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 16384, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.9, - outputPrice: 0.9, - description: expect.stringContaining("strong Mixture-of-Experts (MoE) language model"), - }), - ) - }) - it("should return DeepSeek V3.1 model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/deepseek-v3p1" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", + it("should return GLM-4.5-Air model with correct configuration", () => { + const testModelId: FireworksModelId = "accounts/fireworks/models/glm-4p5-air" + const handlerWithModel = new FireworksHandler({ + apiModelId: testModelId, + fireworksApiKey: "test-fireworks-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 16384, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.55, + outputPrice: 2.19, + description: expect.stringContaining("Z.ai GLM-4.5-Air with 106B total parameters"), + }), + ) }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 16384, - contextWindow: 163840, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.56, - outputPrice: 1.68, - description: expect.stringContaining("DeepSeek v3.1 is an improved version"), - }), - ) - }) - it("should return GLM-4.5 model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/glm-4p5" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", + it("should return GLM-4.6 model with correct configuration", () => { + const testModelId: FireworksModelId = "accounts/fireworks/models/glm-4p6" + const handlerWithModel = new FireworksHandler({ + apiModelId: testModelId, + fireworksApiKey: "test-fireworks-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 25344, + contextWindow: 198000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.55, + outputPrice: 2.19, + description: expect.stringContaining("Z.ai GLM-4.6 is an advanced coding model"), + }), + ) }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 16384, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.55, - outputPrice: 2.19, - description: expect.stringContaining("Z.ai GLM-4.5 with 355B total parameters"), - }), - ) - }) - it("should return GLM-4.5-Air model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/glm-4p5-air" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", + it("should return gpt-oss-20b model with correct configuration", () => { + const testModelId: FireworksModelId = "accounts/fireworks/models/gpt-oss-20b" + const handlerWithModel = new FireworksHandler({ + apiModelId: testModelId, + fireworksApiKey: "test-fireworks-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 16384, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.07, + outputPrice: 0.3, + description: expect.stringContaining( + "OpenAI gpt-oss-20b: Compact model for local/edge deployments", + ), + }), + ) }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 16384, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.55, - outputPrice: 2.19, - description: expect.stringContaining("Z.ai GLM-4.5-Air with 106B total parameters"), - }), - ) - }) - it("should return GLM-4.6 model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/glm-4p6" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", + it("should return gpt-oss-120b model with correct configuration", () => { + const testModelId: FireworksModelId = "accounts/fireworks/models/gpt-oss-120b" + const handlerWithModel = new FireworksHandler({ + apiModelId: testModelId, + fireworksApiKey: "test-fireworks-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 16384, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.15, + outputPrice: 0.6, + description: expect.stringContaining( + "OpenAI gpt-oss-120b: Production-grade, general-purpose model", + ), + }), + ) }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 25344, - contextWindow: 198000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.55, - outputPrice: 2.19, - description: expect.stringContaining("Z.ai GLM-4.6 is an advanced coding model"), - }), - ) - }) - it("should return gpt-oss-20b model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/gpt-oss-20b" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", + it("should return provided model ID with default model info if model does not exist", () => { + const handlerWithInvalidModel = new FireworksHandler({ + ...mockOptions, + apiModelId: "invalid-model", + }) + const model = handlerWithInvalidModel.getModel() + expect(model.id).toBe("invalid-model") + expect(model.info).toBeDefined() + // Should use default model info + expect(model.info).toBe(fireworksModels[fireworksDefaultModelId]) }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 16384, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.07, - outputPrice: 0.3, - description: expect.stringContaining("OpenAI gpt-oss-20b: Compact model for local/edge deployments"), - }), - ) - }) - it("should return gpt-oss-120b model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/gpt-oss-120b" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 16384, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.15, - outputPrice: 0.6, - description: expect.stringContaining("OpenAI gpt-oss-120b: Production-grade, general-purpose model"), - }), - ) }) - it("completePrompt method should return text from Fireworks API", async () => { - const expectedResponse = "This is a test response from Fireworks" - mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] }) - const result = await handler.completePrompt("test prompt") - expect(result).toBe(expectedResponse) - }) + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text" as const, + text: "Hello!", + }, + ], + }, + ] - it("should handle errors in completePrompt", async () => { - const errorMessage = "Fireworks API error" - mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - `Fireworks completion error: ${errorMessage}`, - ) - }) + it("should handle streaming responses", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response from Fireworks" } + } - it("createMessage should yield text content from stream", async () => { - const testContent = "This is test content from Fireworks stream" - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: testContent } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } + + expect(chunks.length).toBeGreaterThan(0) + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response from Fireworks") }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "text", text: testContent }) - }) + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 20, + }) - it("createMessage should yield usage data from stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } }, - }) - .mockResolvedValueOnce({ done: true }), - }), + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(20) }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + it("should handle cached tokens in usage data from providerMetadata", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 }) - }) + const mockUsage = Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + }) - it("createMessage should pass correct parameters to Fireworks client", async () => { - const modelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-instruct" - const modelInfo = fireworksModels[modelId] - const handlerWithModel = new FireworksHandler({ - apiModelId: modelId, - fireworksApiKey: "test-fireworks-api-key", + // Fireworks provides cache metrics via providerMetadata for supported models + const mockProviderMetadata = Promise.resolve({ + fireworks: { + promptCacheHitTokens: 30, + promptCacheMissTokens: 70, + }, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(100) + expect(usageChunks[0].outputTokens).toBe(50) + expect(usageChunks[0].cacheReadTokens).toBe(30) + expect(usageChunks[0].cacheWriteTokens).toBe(70) }) - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, + it("should handle usage with details.cachedInputTokens when providerMetadata is not available", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + details: { + cachedInputTokens: 25, + }, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].cacheReadTokens).toBe(25) + expect(usageChunks[0].cacheWriteTokens).toBeUndefined() + }) + + it("should pass correct temperature (0.5 default) to streamText", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const handlerWithDefaultTemp = new FireworksHandler({ + fireworksApiKey: "test-key", + apiModelId: "accounts/fireworks/models/kimi-k2-instruct", + }) + + const stream = handlerWithDefaultTemp.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume stream + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.5, }), + ) + }) + + it("should use model defaultTemperature (1.0) over provider default (0.5) for kimi-k2-thinking", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const handlerWithThinkingModel = new FireworksHandler({ + fireworksApiKey: "test-key", + apiModelId: "accounts/fireworks/models/kimi-k2-thinking", + }) + + const stream = handlerWithThinkingModel.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume stream } + + // Model's defaultTemperature (1.0) should take precedence over provider's default (0.5) + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 1.0, + }), + ) }) - const systemPrompt = "Test system prompt for Fireworks" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for Fireworks" }] + it("should use user-specified temperature over model and provider defaults", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } - const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages) - await messageGenerator.next() + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const handlerWithCustomTemp = new FireworksHandler({ + fireworksApiKey: "test-key", + apiModelId: "accounts/fireworks/models/kimi-k2-thinking", + modelTemperature: 0.7, + }) + + const stream = handlerWithCustomTemp.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume stream + } - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: modelId, - max_tokens: modelInfo.maxTokens, - temperature: 0.5, - messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), - stream: true, - stream_options: { include_usage: true }, - }), - undefined, - ) + // User-specified temperature should take precedence over everything + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, + }), + ) + }) + + it("should handle stream with multiple chunks", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Hello" } + yield { type: "text-delta", text: " world" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 5, outputTokens: 10 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks[0]).toEqual({ type: "text", text: "Hello" }) + expect(textChunks[1]).toEqual({ type: "text", text: " world" }) + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks[0]).toMatchObject({ type: "usage", inputTokens: 5, outputTokens: 10 }) + }) }) - it("should use provider default temperature of 0.5 for models without defaultTemperature", async () => { - const modelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-instruct" - const handlerWithModel = new FireworksHandler({ - apiModelId: modelId, - fireworksApiKey: "test-fireworks-api-key", + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion from Fireworks", + }) + + const result = await handler.completePrompt("Test prompt") + + expect(result).toBe("Test completion from Fireworks") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) }) - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - })) + it("should use default temperature in completePrompt", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion", + }) - const messageGenerator = handlerWithModel.createMessage("system", []) - await messageGenerator.next() + await handler.completePrompt("Test prompt") - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 0.5, - }), - undefined, - ) + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.5, + }), + ) + }) }) - it("should use model defaultTemperature (1.0) over provider default (0.5) for kimi-k2-thinking", async () => { - const modelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-thinking" - const handlerWithModel = new FireworksHandler({ - apiModelId: modelId, - fireworksApiKey: "test-fireworks-api-key", - }) + describe("processUsageMetrics", () => { + it("should correctly process usage metrics including cache information from providerMetadata", () => { + class TestFireworksHandler extends FireworksHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestFireworksHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + } - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } + const providerMetadata = { + fireworks: { + promptCacheHitTokens: 20, + promptCacheMissTokens: 80, }, - }), - })) + } - const messageGenerator = handlerWithModel.createMessage("system", []) - await messageGenerator.next() + const result = testHandler.testProcessUsageMetrics(usage, providerMetadata) - // Model's defaultTemperature (1.0) should take precedence over provider's default (0.5) - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 1.0, - }), - undefined, - ) - }) + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBe(80) + expect(result.cacheReadTokens).toBe(20) + }) - it("should use user-specified temperature over model and provider defaults", async () => { - const modelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-thinking" - const handlerWithModel = new FireworksHandler({ - apiModelId: modelId, - fireworksApiKey: "test-fireworks-api-key", - modelTemperature: 0.7, + it("should handle missing cache metrics gracefully", () => { + class TestFireworksHandler extends FireworksHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestFireworksHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + } + + const result = testHandler.testProcessUsageMetrics(usage) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBeUndefined() + expect(result.cacheReadTokens).toBeUndefined() }) - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - })) + it("should include reasoning tokens when provided", () => { + class TestFireworksHandler extends FireworksHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } - const messageGenerator = handlerWithModel.createMessage("system", []) - await messageGenerator.next() + const testHandler = new TestFireworksHandler(mockOptions) - // User-specified temperature should take precedence over everything - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 0.7, - }), - undefined, - ) - }) + const usage = { + inputTokens: 100, + outputTokens: 50, + details: { + reasoningTokens: 30, + }, + } - it("should handle empty response in completePrompt", async () => { - mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: null } }] }) - const result = await handler.completePrompt("test prompt") - expect(result).toBe("") - }) + const result = testHandler.testProcessUsageMetrics(usage) - it("should handle missing choices in completePrompt", async () => { - mockCreate.mockResolvedValueOnce({ choices: [] }) - const result = await handler.completePrompt("test prompt") - expect(result).toBe("") + expect(result.reasoningTokens).toBe(30) + }) }) - it("createMessage should handle stream with multiple chunks", async () => { - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { + describe("tool handling", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] + + it("should handle tool calls in streaming", async () => { + async function* mockFullStream() { yield { - choices: [ - { - delta: { content: "Hello" }, - index: 0, - }, - ], - usage: null, + type: "tool-input-start", + id: "tool-call-1", + toolName: "read_file", } yield { - choices: [ - { - delta: { content: " world" }, - index: 0, - }, - ], - usage: null, + type: "tool-input-delta", + id: "tool-call-1", + delta: '{"path":"test.ts"}', } yield { - choices: [ - { - delta: {}, - index: 0, + type: "tool-input-end", + id: "tool-call-1", + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, }, - ], - usage: { - prompt_tokens: 5, - completion_tokens: 10, - total_tokens: 15, }, + ], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start") + const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta") + const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end") + + expect(toolCallStartChunks.length).toBe(1) + expect(toolCallStartChunks[0].id).toBe("tool-call-1") + expect(toolCallStartChunks[0].name).toBe("read_file") + + expect(toolCallDeltaChunks.length).toBe(1) + expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}') + + expect(toolCallEndChunks.length).toBe(1) + expect(toolCallEndChunks[0].id).toBe("tool-call-1") + }) + + it("should ignore tool-call events to prevent duplicate tools in UI", async () => { + async function* mockFullStream() { + yield { + type: "tool-call", + toolCallId: "tool-call-1", + toolName: "read_file", + input: { path: "test.ts" }, } - }, - })) + } - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) - const stream = handler.createMessage(systemPrompt, messages) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) - expect(chunks[0]).toEqual({ type: "text", text: "Hello" }) - expect(chunks[1]).toEqual({ type: "text", text: " world" }) - expect(chunks[2]).toMatchObject({ type: "usage", inputTokens: 5, outputTokens: 10 }) + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // tool-call events should be ignored (only tool-input-start/delta/end are processed) + const toolCallChunks = chunks.filter( + (c) => c.type === "tool_call_start" || c.type === "tool_call_delta" || c.type === "tool_call_end", + ) + expect(toolCallChunks.length).toBe(0) + }) }) }) diff --git a/src/api/providers/__tests__/groq.spec.ts b/src/api/providers/__tests__/groq.spec.ts index f89fd62a7f..efb5712cb9 100644 --- a/src/api/providers/__tests__/groq.spec.ts +++ b/src/api/providers/__tests__/groq.spec.ts @@ -1,192 +1,578 @@ // npx vitest run src/api/providers/__tests__/groq.spec.ts -import OpenAI from "openai" -import { Anthropic } from "@anthropic-ai/sdk" +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) -import { type GroqModelId, groqDefaultModelId, groqModels } from "@roo-code/types" - -import { GroqHandler } from "../groq" - -vitest.mock("openai", () => { - const createMock = vitest.fn() +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - default: vitest.fn(() => ({ chat: { completions: { create: createMock } } })), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) +vi.mock("@ai-sdk/groq", () => ({ + createGroq: vi.fn(() => { + // Return a function that returns a mock language model + return vi.fn(() => ({ + modelId: "moonshotai/kimi-k2-instruct-0905", + provider: "groq", + })) + }), +})) + +import type { Anthropic } from "@anthropic-ai/sdk" + +import { groqDefaultModelId, groqModels, type GroqModelId } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../../shared/api" + +import { GroqHandler } from "../groq" + describe("GroqHandler", () => { let handler: GroqHandler - let mockCreate: any + let mockOptions: ApiHandlerOptions beforeEach(() => { - vitest.clearAllMocks() - mockCreate = (OpenAI as unknown as any)().chat.completions.create - handler = new GroqHandler({ groqApiKey: "test-groq-api-key" }) + mockOptions = { + groqApiKey: "test-groq-api-key", + apiModelId: "moonshotai/kimi-k2-instruct-0905", + } + handler = new GroqHandler(mockOptions) + vi.clearAllMocks() }) - it("should use the correct Groq base URL", () => { - new GroqHandler({ groqApiKey: "test-groq-api-key" }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://api.groq.com/openai/v1" })) - }) + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(GroqHandler) + expect(handler.getModel().id).toBe(mockOptions.apiModelId) + }) - it("should use the provided API key", () => { - const groqApiKey = "test-groq-api-key" - new GroqHandler({ groqApiKey }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: groqApiKey })) + it("should use default model ID if not provided", () => { + const handlerWithoutModel = new GroqHandler({ + ...mockOptions, + apiModelId: undefined, + }) + expect(handlerWithoutModel.getModel().id).toBe(groqDefaultModelId) + }) }) - it("should return default model when no model is specified", () => { - const model = handler.getModel() - expect(model.id).toBe(groqDefaultModelId) - expect(model.info).toEqual(groqModels[groqDefaultModelId]) - }) + describe("getModel", () => { + it("should return default model when no model is specified", () => { + const handlerWithoutModel = new GroqHandler({ + groqApiKey: "test-groq-api-key", + }) + const model = handlerWithoutModel.getModel() + expect(model.id).toBe(groqDefaultModelId) + expect(model.info).toEqual(groqModels[groqDefaultModelId]) + }) - it("should return specified model when valid model is provided", () => { - const testModelId: GroqModelId = "llama-3.3-70b-versatile" - const handlerWithModel = new GroqHandler({ apiModelId: testModelId, groqApiKey: "test-groq-api-key" }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual(groqModels[testModelId]) - }) + it("should return specified model when valid model is provided", () => { + const testModelId: GroqModelId = "llama-3.3-70b-versatile" + const handlerWithModel = new GroqHandler({ + apiModelId: testModelId, + groqApiKey: "test-groq-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual(groqModels[testModelId]) + }) - it("completePrompt method should return text from Groq API", async () => { - const expectedResponse = "This is a test response from Groq" - mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] }) - const result = await handler.completePrompt("test prompt") - expect(result).toBe(expectedResponse) - }) + it("should return model info for llama-3.1-8b-instant", () => { + const handlerWithLlama = new GroqHandler({ + ...mockOptions, + apiModelId: "llama-3.1-8b-instant", + }) + const model = handlerWithLlama.getModel() + expect(model.id).toBe("llama-3.1-8b-instant") + expect(model.info).toBeDefined() + expect(model.info.maxTokens).toBe(8192) + expect(model.info.contextWindow).toBe(131072) + expect(model.info.supportsImages).toBe(false) + expect(model.info.supportsPromptCache).toBe(false) + }) + + it("should return model info for kimi-k2 which supports prompt cache", () => { + const handlerWithKimi = new GroqHandler({ + ...mockOptions, + apiModelId: "moonshotai/kimi-k2-instruct-0905", + }) + const model = handlerWithKimi.getModel() + expect(model.id).toBe("moonshotai/kimi-k2-instruct-0905") + expect(model.info).toBeDefined() + expect(model.info.maxTokens).toBe(16384) + expect(model.info.contextWindow).toBe(262144) + expect(model.info.supportsPromptCache).toBe(true) + }) - it("should handle errors in completePrompt", async () => { - const errorMessage = "Groq API error" - mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow(`Groq completion error: ${errorMessage}`) + it("should return provided model ID with default model info if model does not exist", () => { + const handlerWithInvalidModel = new GroqHandler({ + ...mockOptions, + apiModelId: "invalid-model", + }) + const model = handlerWithInvalidModel.getModel() + expect(model.id).toBe("invalid-model") + expect(model.info).toBeDefined() + // Should use default model info + expect(model.info).toBe(groqModels[groqDefaultModelId]) + }) + + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") + }) }) - it("createMessage should yield text content from stream", async () => { - const testContent = "This is test content from Groq stream" - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vitest - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: testContent } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text" as const, + text: "Hello!", + }, + ], + }, + ] + + it("should handle streaming responses", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response from Groq" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } + + expect(chunks.length).toBeGreaterThan(0) + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response from Groq") }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "text", text: testContent }) - }) + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 20, + }) - it("createMessage should yield usage data from stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vitest - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } }, - }) - .mockResolvedValueOnce({ done: true }), - }), + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(20) }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + it("should handle cached tokens in usage data from providerMetadata", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + }) - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toMatchObject({ - type: "usage", - inputTokens: 10, - outputTokens: 20, + // Groq provides cache metrics via providerMetadata for supported models + const mockProviderMetadata = Promise.resolve({ + groq: { + promptCacheHitTokens: 30, + promptCacheMissTokens: 70, + }, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(100) + expect(usageChunks[0].outputTokens).toBe(50) + expect(usageChunks[0].cacheReadTokens).toBe(30) + expect(usageChunks[0].cacheWriteTokens).toBe(70) + }) + + it("should handle usage with details.cachedInputTokens when providerMetadata is not available", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + details: { + cachedInputTokens: 25, + }, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].cacheReadTokens).toBe(25) + expect(usageChunks[0].cacheWriteTokens).toBeUndefined() + }) + + it("should pass correct temperature (0.5 default) to streamText", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const handlerWithDefaultTemp = new GroqHandler({ + groqApiKey: "test-key", + apiModelId: "llama-3.1-8b-instant", + }) + + const stream = handlerWithDefaultTemp.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume stream + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.5, + }), + ) }) - // cacheWriteTokens and cacheReadTokens will be undefined when 0 - expect(firstChunk.value.cacheWriteTokens).toBeUndefined() - expect(firstChunk.value.cacheReadTokens).toBeUndefined() - // Check that totalCost is a number (we don't need to test the exact value as that's tested in cost.spec.ts) - expect(typeof firstChunk.value.totalCost).toBe("number") }) - it("createMessage should handle cached tokens in usage data", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vitest - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [{ delta: {} }], - usage: { - prompt_tokens: 100, - completion_tokens: 50, - prompt_tokens_details: { - cached_tokens: 30, - }, - }, - }, - }) - .mockResolvedValueOnce({ done: true }), + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion from Groq", + }) + + const result = await handler.completePrompt("Test prompt") + + expect(result).toBe("Test completion from Groq") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) + }) + + it("should use default temperature in completePrompt", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion", + }) + + await handler.completePrompt("Test prompt") + + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.5, }), + ) + }) + }) + + describe("processUsageMetrics", () => { + it("should correctly process usage metrics including cache information from providerMetadata", () => { + class TestGroqHandler extends GroqHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestGroqHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + } + + const providerMetadata = { + groq: { + promptCacheHitTokens: 20, + promptCacheMissTokens: 80, + }, } + + const result = testHandler.testProcessUsageMetrics(usage, providerMetadata) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBe(80) + expect(result.cacheReadTokens).toBe(20) }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + it("should handle missing cache metrics gracefully", () => { + class TestGroqHandler extends GroqHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestGroqHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + } + + const result = testHandler.testProcessUsageMetrics(usage) - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 30, + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBeUndefined() + expect(result.cacheReadTokens).toBeUndefined() + }) + + it("should include reasoning tokens when provided", () => { + class TestGroqHandler extends GroqHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestGroqHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + details: { + reasoningTokens: 30, + }, + } + + const result = testHandler.testProcessUsageMetrics(usage) + + expect(result.reasoningTokens).toBe(30) }) - // cacheWriteTokens will be undefined when 0 - expect(firstChunk.value.cacheWriteTokens).toBeUndefined() - expect(typeof firstChunk.value.totalCost).toBe("number") }) - it("createMessage should pass correct parameters to Groq client", async () => { - const modelId: GroqModelId = "llama-3.1-8b-instant" - const modelInfo = groqModels[modelId] - const handlerWithModel = new GroqHandler({ apiModelId: modelId, groqApiKey: "test-groq-api-key" }) + describe("tool handling", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] + + it("should handle tool calls in streaming", async () => { + async function* mockFullStream() { + yield { + type: "tool-input-start", + id: "tool-call-1", + toolName: "read_file", + } + yield { + type: "tool-input-delta", + id: "tool-call-1", + delta: '{"path":"test.ts"}', + } + yield { + type: "tool-input-end", + id: "tool-call-1", + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, }, - }), + ], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } + + const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start") + const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta") + const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end") + + expect(toolCallStartChunks.length).toBe(1) + expect(toolCallStartChunks[0].id).toBe("tool-call-1") + expect(toolCallStartChunks[0].name).toBe("read_file") + + expect(toolCallDeltaChunks.length).toBe(1) + expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}') + + expect(toolCallEndChunks.length).toBe(1) + expect(toolCallEndChunks[0].id).toBe("tool-call-1") }) - const systemPrompt = "Test system prompt for Groq" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for Groq" }] + it("should ignore tool-call events to prevent duplicate tools in UI", async () => { + async function* mockFullStream() { + yield { + type: "tool-call", + toolCallId: "tool-call-1", + toolName: "read_file", + input: { path: "test.ts" }, + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + const mockProviderMetadata = Promise.resolve({}) - const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages) - await messageGenerator.next() + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + providerMetadata: mockProviderMetadata, + }) - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: modelId, - max_tokens: modelInfo.maxTokens, - temperature: 0.5, - messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), - stream: true, - stream_options: { include_usage: true }, - }), - undefined, - ) + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + ], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // tool-call events are ignored, so no tool_call chunks should be emitted + const toolCallChunks = chunks.filter((c) => c.type === "tool_call") + expect(toolCallChunks.length).toBe(0) + }) + }) + + describe("getMaxOutputTokens", () => { + it("should return maxTokens from model info", () => { + class TestGroqHandler extends GroqHandler { + public testGetMaxOutputTokens() { + return this.getMaxOutputTokens() + } + } + + const testHandler = new TestGroqHandler({ + ...mockOptions, + apiModelId: "llama-3.1-8b-instant", + }) + const result = testHandler.testGetMaxOutputTokens() + + // llama-3.1-8b-instant has maxTokens of 8192 + expect(result).toBe(8192) + }) + + it("should use modelMaxTokens when provided", () => { + class TestGroqHandler extends GroqHandler { + public testGetMaxOutputTokens() { + return this.getMaxOutputTokens() + } + } + + const customMaxTokens = 5000 + const testHandler = new TestGroqHandler({ + ...mockOptions, + modelMaxTokens: customMaxTokens, + }) + + const result = testHandler.testGetMaxOutputTokens() + expect(result).toBe(customMaxTokens) + }) }) }) diff --git a/src/api/providers/__tests__/moonshot.spec.ts b/src/api/providers/__tests__/moonshot.spec.ts index 9040ed23ca..1bfd482fd9 100644 --- a/src/api/providers/__tests__/moonshot.spec.ts +++ b/src/api/providers/__tests__/moonshot.spec.ts @@ -419,7 +419,10 @@ describe("MoonshotHandler", () => { expect(toolCallEndChunks[0].id).toBe("tool-call-1") }) - it("should handle complete tool calls", async () => { + it("should ignore tool-call events to prevent duplicate tools in UI", async () => { + // tool-call events are intentionally ignored because tool-input-start/delta/end + // already provide complete tool call information. Emitting tool-call would cause + // duplicate tools in the UI for AI SDK providers (e.g., DeepSeek, Moonshot). async function* mockFullStream() { yield { type: "tool-call", @@ -464,11 +467,9 @@ describe("MoonshotHandler", () => { chunks.push(chunk) } + // tool-call events are ignored, so no tool_call chunks should be emitted const toolCallChunks = chunks.filter((c) => c.type === "tool_call") - expect(toolCallChunks.length).toBe(1) - expect(toolCallChunks[0].id).toBe("tool-call-1") - expect(toolCallChunks[0].name).toBe("read_file") - expect(toolCallChunks[0].arguments).toBe('{"path":"test.ts"}') + expect(toolCallChunks.length).toBe(0) }) }) }) diff --git a/src/api/providers/cerebras.ts b/src/api/providers/cerebras.ts index ef67cb9775..20e74f5de8 100644 --- a/src/api/providers/cerebras.ts +++ b/src/api/providers/cerebras.ts @@ -1,363 +1,159 @@ import { Anthropic } from "@anthropic-ai/sdk" +import { createCerebras } from "@ai-sdk/cerebras" +import { streamText, generateText, ToolSet } from "ai" -import { type CerebrasModelId, cerebrasDefaultModelId, cerebrasModels } from "@roo-code/types" +import { cerebrasModels, cerebrasDefaultModelId, type CerebrasModelId, type ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" -import { calculateApiCostOpenAI } from "../../shared/cost" -import { ApiStream } from "../transform/stream" -import { convertToOpenAiMessages } from "../transform/openai-format" -import { TagMatcher } from "../../utils/tag-matcher" -import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from "../index" -import { BaseProvider } from "./base-provider" -import { DEFAULT_HEADERS } from "./constants" -import { t } from "../../i18n" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" +import { getModelParams } from "../transform/model-params" -const CEREBRAS_BASE_URL = "https://api.cerebras.ai/v1" -const CEREBRAS_DEFAULT_TEMPERATURE = 0 +import { DEFAULT_HEADERS } from "./constants" +import { BaseProvider } from "./base-provider" +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" const CEREBRAS_INTEGRATION_HEADER = "X-Cerebras-3rd-Party-Integration" const CEREBRAS_INTEGRATION_NAME = "roocode" +const CEREBRAS_DEFAULT_TEMPERATURE = 0 +/** + * Cerebras provider using the dedicated @ai-sdk/cerebras package. + * Provides high-speed inference powered by Wafer-Scale Engines. + */ export class CerebrasHandler extends BaseProvider implements SingleCompletionHandler { - private apiKey: string - private providerModels: typeof cerebrasModels - private defaultProviderModelId: CerebrasModelId - private options: ApiHandlerOptions - private lastUsage: { inputTokens: number; outputTokens: number } = { inputTokens: 0, outputTokens: 0 } + protected options: ApiHandlerOptions + protected provider: ReturnType constructor(options: ApiHandlerOptions) { super() this.options = options - this.apiKey = options.cerebrasApiKey || "" - this.providerModels = cerebrasModels - this.defaultProviderModelId = cerebrasDefaultModelId - if (!this.apiKey) { - throw new Error("Cerebras API key is required") - } + // Create the Cerebras provider using AI SDK + this.provider = createCerebras({ + apiKey: options.cerebrasApiKey ?? "not-provided", + headers: { + ...DEFAULT_HEADERS, + [CEREBRAS_INTEGRATION_HEADER]: CEREBRAS_INTEGRATION_NAME, + }, + }) } - getModel(): { id: CerebrasModelId; info: (typeof cerebrasModels)[CerebrasModelId] } { - const modelId = this.options.apiModelId as CerebrasModelId - const validModelId = modelId && this.providerModels[modelId] ? modelId : this.defaultProviderModelId - - return { - id: validModelId, - info: this.providerModels[validModelId], - } + override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } { + const id = (this.options.apiModelId ?? cerebrasDefaultModelId) as CerebrasModelId + const info = cerebrasModels[id as keyof typeof cerebrasModels] || cerebrasModels[cerebrasDefaultModelId] + const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options }) + return { id, info, ...params } } /** - * Override convertToolSchemaForOpenAI to remove unsupported schema fields for Cerebras. - * Cerebras doesn't support minItems/maxItems in array schemas with strict mode. + * Get the language model for the configured model ID. */ - protected override convertToolSchemaForOpenAI(schema: any): any { - const converted = super.convertToolSchemaForOpenAI(schema) - return this.stripUnsupportedSchemaFields(converted) + protected getLanguageModel() { + const { id } = this.getModel() + return this.provider(id) } /** - * Recursively strips unsupported schema fields for Cerebras. - * Cerebras strict mode doesn't support minItems, maxItems on arrays. + * Process usage metrics from the AI SDK response. */ - private stripUnsupportedSchemaFields(schema: any): any { - if (!schema || typeof schema !== "object") { - return schema + protected processUsageMetrics(usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number } - - const result = { ...schema } - - // Remove unsupported array constraints - if (result.type === "array" || (Array.isArray(result.type) && result.type.includes("array"))) { - delete result.minItems - delete result.maxItems - } - - // Recursively process properties - if (result.properties) { - const newProps = { ...result.properties } - for (const key of Object.keys(newProps)) { - newProps[key] = this.stripUnsupportedSchemaFields(newProps[key]) - } - result.properties = newProps - } - - // Recursively process array items - if (result.items) { - result.items = this.stripUnsupportedSchemaFields(result.items) + }): ApiStreamUsageChunk { + return { + type: "usage", + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheReadTokens: usage.details?.cachedInputTokens, + reasoningTokens: usage.details?.reasoningTokens, } - - return result } /** - * Override convertToolsForOpenAI to ensure all tools have consistent strict values. - * Cerebras API requires all tools to have the same strict mode setting. - * We use strict: false for all tools since MCP tools cannot use strict mode - * (they have optional parameters from the MCP server schema). + * Get the max tokens parameter to include in the request. */ - protected override convertToolsForOpenAI(tools: any[] | undefined): any[] | undefined { - if (!tools) { - return undefined - } - - return tools.map((tool) => { - if (tool.type !== "function") { - return tool - } - - return { - ...tool, - function: { - ...tool.function, - strict: false, - parameters: this.convertToolSchemaForOpenAI(tool.function.parameters), - }, - } - }) + protected getMaxOutputTokens(): number | undefined { + const { info } = this.getModel() + return this.options.modelMaxTokens || info.maxTokens || undefined } - async *createMessage( + /** + * Create a message stream using the AI SDK. + */ + override async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const { id: model, info: modelInfo } = this.getModel() - const max_tokens = modelInfo.maxTokens - const temperature = this.options.modelTemperature ?? CEREBRAS_DEFAULT_TEMPERATURE - - // Convert Anthropic messages to OpenAI format (Cerebras is OpenAI-compatible) - const openaiMessages = convertToOpenAiMessages(messages) - - // Prepare request body following Cerebras API specification exactly - const requestBody: Record = { - model, - messages: [{ role: "system", content: systemPrompt }, ...openaiMessages], - stream: true, - // Use max_completion_tokens (Cerebras-specific parameter) - ...(max_tokens && max_tokens > 0 && max_tokens <= 32768 ? { max_completion_tokens: max_tokens } : {}), - // Clamp temperature to Cerebras range (0 to 1.5) - ...(temperature !== undefined && temperature !== CEREBRAS_DEFAULT_TEMPERATURE - ? { - temperature: Math.max(0, Math.min(1.5, temperature)), - } - : {}), - // Native tool calling support - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, + const { temperature } = this.getModel() + const languageModel = this.getLanguageModel() + + // Convert messages to AI SDK format + const aiSdkMessages = convertToAiSdkMessages(messages) + + // Convert tools to OpenAI format first, then to AI SDK format + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + // Build the request options + const requestOptions: Parameters[0] = { + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + temperature: this.options.modelTemperature ?? temperature ?? CEREBRAS_DEFAULT_TEMPERATURE, + maxOutputTokens: this.getMaxOutputTokens(), + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), } - try { - const response = await fetch(`${CEREBRAS_BASE_URL}/chat/completions`, { - method: "POST", - headers: { - ...DEFAULT_HEADERS, - "Content-Type": "application/json", - Authorization: `Bearer ${this.apiKey}`, - [CEREBRAS_INTEGRATION_HEADER]: CEREBRAS_INTEGRATION_NAME, - }, - body: JSON.stringify(requestBody), - }) - - if (!response.ok) { - const errorText = await response.text() - - let errorMessage = "Unknown error" - try { - const errorJson = JSON.parse(errorText) - errorMessage = errorJson.error?.message || errorJson.message || JSON.stringify(errorJson, null, 2) - } catch { - errorMessage = errorText || `HTTP ${response.status}` - } - - // Provide more actionable error messages - if (response.status === 401) { - throw new Error(t("common:errors.cerebras.authenticationFailed")) - } else if (response.status === 403) { - throw new Error(t("common:errors.cerebras.accessForbidden")) - } else if (response.status === 429) { - throw new Error(t("common:errors.cerebras.rateLimitExceeded")) - } else if (response.status >= 500) { - throw new Error(t("common:errors.cerebras.serverError", { status: response.status })) - } else { - throw new Error( - t("common:errors.cerebras.genericError", { status: response.status, message: errorMessage }), - ) - } - } - - if (!response.body) { - throw new Error(t("common:errors.cerebras.noResponseBody")) - } - - // Initialize TagMatcher to parse ... tags - const matcher = new TagMatcher( - "think", - (chunk) => - ({ - type: chunk.matched ? "reasoning" : "text", - text: chunk.data, - }) as const, - ) - - const reader = response.body.getReader() - const decoder = new TextDecoder() - let buffer = "" - let inputTokens = 0 - let outputTokens = 0 - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split("\n") - buffer = lines.pop() || "" // Keep the last incomplete line in the buffer - - for (const line of lines) { - if (line.trim() === "") continue - - try { - if (line.startsWith("data: ")) { - const jsonStr = line.slice(6).trim() - if (jsonStr === "[DONE]") { - continue - } - - const parsed = JSON.parse(jsonStr) - - const delta = parsed.choices?.[0]?.delta + // Use streamText for streaming responses + const result = streamText(requestOptions) - // Handle text content - parse for thinking tokens - if (delta?.content) { - const content = delta.content - - // Use TagMatcher to parse ... tags - for (const chunk of matcher.update(content)) { - yield chunk - } - } - - // Handle tool calls in stream - emit partial chunks for NativeToolCallParser - if (delta?.tool_calls) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - - // Handle usage information if available - if (parsed.usage) { - inputTokens = parsed.usage.prompt_tokens || 0 - outputTokens = parsed.usage.completion_tokens || 0 - } - } - } catch (error) { - // Silently ignore malformed streaming data lines - } - } + try { + // Process the full stream to get all events including reasoning + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk } - } finally { - reader.releaseLock() } - // Process any remaining content in the matcher - for (const chunk of matcher.final()) { - yield chunk - } - - // Provide token usage estimate if not available from API - if (inputTokens === 0 || outputTokens === 0) { - const inputText = - systemPrompt + - openaiMessages - .map((m: any) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content))) - .join("") - inputTokens = inputTokens || Math.ceil(inputText.length / 4) // Rough estimate: 4 chars per token - outputTokens = outputTokens || Math.ceil((max_tokens || 1000) / 10) // Rough estimate - } - - // Store usage for cost calculation - this.lastUsage = { inputTokens, outputTokens } - - yield { - type: "usage", - inputTokens, - outputTokens, + // Yield usage metrics at the end + const usage = await result.usage + if (usage) { + yield this.processUsageMetrics(usage) } } catch (error) { - if (error instanceof Error) { - throw new Error(t("common:errors.cerebras.completionError", { error: error.message })) - } - throw error + // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) + throw handleAiSdkError(error, "Cerebras") } } - + /** + * Complete a prompt using the AI SDK generateText. + */ async completePrompt(prompt: string, systemPrompt?: string, metadata?: any): Promise { - const { id: model } = this.getModel() - - // Prepare request body for non-streaming completion - const requestBody = { - model, - messages: [{ role: "user", content: prompt }], - stream: false, - } - - try { - const response = await fetch(`${CEREBRAS_BASE_URL}/chat/completions`, { - method: "POST", - headers: { - ...DEFAULT_HEADERS, - "Content-Type": "application/json", - Authorization: `Bearer ${this.apiKey}`, - [CEREBRAS_INTEGRATION_HEADER]: CEREBRAS_INTEGRATION_NAME, - }, - body: JSON.stringify(requestBody), - signal: metadata?.signal, - }) - - if (!response.ok) { - const errorText = await response.text() - - // Provide consistent error handling with createMessage - if (response.status === 401) { - throw new Error(t("common:errors.cerebras.authenticationFailed")) - } else if (response.status === 403) { - throw new Error(t("common:errors.cerebras.accessForbidden")) - } else if (response.status === 429) { - throw new Error(t("common:errors.cerebras.rateLimitExceeded")) - } else if (response.status >= 500) { - throw new Error(t("common:errors.cerebras.serverError", { status: response.status })) - } else { - throw new Error( - t("common:errors.cerebras.genericError", { status: response.status, message: errorText }), - ) - } - } - - const result = await response.json() - return result.choices?.[0]?.message?.content || "" - } catch (error) { - if (error instanceof Error) { - throw new Error(t("common:errors.cerebras.completionError", { error: error.message })) - } - throw error - } - } + const { temperature } = this.getModel() + const languageModel = this.getLanguageModel() + + const { text } = await generateText({ + model: languageModel, + prompt: systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt, + maxOutputTokens: this.getMaxOutputTokens(), + temperature: this.options.modelTemperature ?? temperature ?? CEREBRAS_DEFAULT_TEMPERATURE, + abortSignal: metadata?.signal, + }) - getApiCost(metadata: ApiHandlerCreateMessageMetadata): number { - const { info } = this.getModel() - // Use actual token usage from the last request - const { inputTokens, outputTokens } = this.lastUsage - const { totalCost } = calculateApiCostOpenAI(info, inputTokens, outputTokens) - return totalCost + return text } } diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 17ce6e0db7..ba9c9d47e3 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -1,150 +1,169 @@ import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import { createDeepSeek } from "@ai-sdk/deepseek" +import { streamText, generateText, ToolSet } from "ai" -import { - deepSeekModels, - deepSeekDefaultModelId, - DEEP_SEEK_DEFAULT_TEMPERATURE, - OPENAI_AZURE_AI_INFERENCE_PATH, -} from "@roo-code/types" +import { deepSeekModels, deepSeekDefaultModelId, DEEP_SEEK_DEFAULT_TEMPERATURE, type ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" -import { convertToR1Format } from "../transform/r1-format" -import { OpenAiHandler } from "./openai" -import type { ApiHandlerCreateMessageMetadata } from "../index" +import { DEFAULT_HEADERS } from "./constants" +import { BaseProvider } from "./base-provider" +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -// Custom interface for DeepSeek params to support thinking mode -type DeepSeekChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParamsStreaming & { - thinking?: { type: "enabled" | "disabled" } -} +/** + * DeepSeek provider using the dedicated @ai-sdk/deepseek package. + * Provides native support for reasoning (deepseek-reasoner) and prompt caching. + */ +export class DeepSeekHandler extends BaseProvider implements SingleCompletionHandler { + protected options: ApiHandlerOptions + protected provider: ReturnType -export class DeepSeekHandler extends OpenAiHandler { constructor(options: ApiHandlerOptions) { - super({ - ...options, - openAiApiKey: options.deepSeekApiKey ?? "not-provided", - openAiModelId: options.apiModelId ?? deepSeekDefaultModelId, - openAiBaseUrl: options.deepSeekBaseUrl ?? "https://api.deepseek.com", - openAiStreamingEnabled: true, - includeMaxTokens: true, + super() + this.options = options + + // Create the DeepSeek provider using AI SDK + this.provider = createDeepSeek({ + baseURL: options.deepSeekBaseUrl ?? "https://api.deepseek.com/v1", + apiKey: options.deepSeekApiKey ?? "not-provided", + headers: DEFAULT_HEADERS, }) } - override getModel() { + override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } { const id = this.options.apiModelId ?? deepSeekDefaultModelId const info = deepSeekModels[id as keyof typeof deepSeekModels] || deepSeekModels[deepSeekDefaultModelId] const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options }) return { id, info, ...params } } + /** + * Get the language model for the configured model ID. + */ + protected getLanguageModel() { + const { id } = this.getModel() + return this.provider(id) + } + + /** + * Process usage metrics from the AI SDK response, including DeepSeek's cache metrics. + * DeepSeek provides cache hit/miss info via providerMetadata. + */ + protected processUsageMetrics( + usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number + } + }, + providerMetadata?: { + deepseek?: { + promptCacheHitTokens?: number + promptCacheMissTokens?: number + } + }, + ): ApiStreamUsageChunk { + // Extract cache metrics from DeepSeek's providerMetadata + const cacheReadTokens = providerMetadata?.deepseek?.promptCacheHitTokens ?? usage.details?.cachedInputTokens + const cacheWriteTokens = providerMetadata?.deepseek?.promptCacheMissTokens + + return { + type: "usage", + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheReadTokens, + cacheWriteTokens, + reasoningTokens: usage.details?.reasoningTokens, + } + } + + /** + * Get the max tokens parameter to include in the request. + */ + protected getMaxOutputTokens(): number | undefined { + const { info } = this.getModel() + return this.options.modelMaxTokens || info.maxTokens || undefined + } + + /** + * Create a message stream using the AI SDK. + * The AI SDK automatically handles reasoning for deepseek-reasoner model. + */ override async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const modelId = this.options.apiModelId ?? deepSeekDefaultModelId - const { info: modelInfo } = this.getModel() - - // Check if this is a thinking-enabled model (deepseek-reasoner) - const isThinkingModel = modelId.includes("deepseek-reasoner") - - // Convert messages to R1 format (merges consecutive same-role messages) - // This is required for DeepSeek which does not support successive messages with the same role - // For thinking models (deepseek-reasoner), enable mergeToolResultText to preserve reasoning_content - // during tool call sequences. Without this, environment_details text after tool_results would - // create user messages that cause DeepSeek to drop all previous reasoning_content. - // See: https://api-docs.deepseek.com/guides/thinking_mode - const convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages], { - mergeToolResultText: isThinkingModel, - }) - - const requestOptions: DeepSeekChatCompletionParams = { - model: modelId, - temperature: this.options.modelTemperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE, - messages: convertedMessages, - stream: true as const, - stream_options: { include_usage: true }, - // Enable thinking mode for deepseek-reasoner or when tools are used with thinking model - ...(isThinkingModel && { thinking: { type: "enabled" } }), - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, + const { temperature } = this.getModel() + const languageModel = this.getLanguageModel() + + // Convert messages to AI SDK format + const aiSdkMessages = convertToAiSdkMessages(messages) + + // Convert tools to OpenAI format first, then to AI SDK format + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + // Build the request options + const requestOptions: Parameters[0] = { + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + temperature: this.options.modelTemperature ?? temperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE, + maxOutputTokens: this.getMaxOutputTokens(), + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), } - // Add max_tokens if needed - this.addMaxTokensIfNeeded(requestOptions, modelInfo) + // Use streamText for streaming responses + const result = streamText(requestOptions) - // Check if base URL is Azure AI Inference (for DeepSeek via Azure) - const isAzureAiInference = this._isAzureAiInference(this.options.deepSeekBaseUrl) - - let stream try { - stream = await this.client.chat.completions.create( - requestOptions, - isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, - ) - } catch (error) { - const { handleOpenAIError } = await import("./utils/openai-error-handler") - throw handleOpenAIError(error, "DeepSeek") - } - - let lastUsage - - for await (const chunk of stream) { - const delta = chunk.choices?.[0]?.delta ?? {} - - // Handle regular text content - if (delta.content) { - yield { - type: "text", - text: delta.content, - } - } - - // Handle reasoning_content from DeepSeek's interleaved thinking - // This is the proper way DeepSeek sends thinking content in streaming - if ("reasoning_content" in delta && delta.reasoning_content) { - yield { - type: "reasoning", - text: (delta.reasoning_content as string) || "", - } - } - - // Handle tool calls - if (delta.tool_calls) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } + // Process the full stream to get all events including reasoning + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk } } - if (chunk.usage) { - lastUsage = chunk.usage + // Yield usage metrics at the end, including cache metrics from providerMetadata + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics(usage, providerMetadata as any) } - } - - if (lastUsage) { - yield this.processUsageMetrics(lastUsage, modelInfo) + } catch (error) { + // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) + throw handleAiSdkError(error, "DeepSeek") } } - // Override to handle DeepSeek's usage metrics, including caching. - protected override processUsageMetrics(usage: any, _modelInfo?: any): ApiStreamUsageChunk { - return { - type: "usage", - inputTokens: usage?.prompt_tokens || 0, - outputTokens: usage?.completion_tokens || 0, - cacheWriteTokens: usage?.prompt_tokens_details?.cache_miss_tokens, - cacheReadTokens: usage?.prompt_tokens_details?.cached_tokens, - } + /** + * Complete a prompt using the AI SDK generateText. + */ + async completePrompt(prompt: string): Promise { + const { temperature } = this.getModel() + const languageModel = this.getLanguageModel() + + const { text } = await generateText({ + model: languageModel, + prompt, + maxOutputTokens: this.getMaxOutputTokens(), + temperature: this.options.modelTemperature ?? temperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE, + }) + + return text } } diff --git a/src/api/providers/fireworks.ts b/src/api/providers/fireworks.ts index db29e7bf3f..52bf431bb6 100644 --- a/src/api/providers/fireworks.ts +++ b/src/api/providers/fireworks.ts @@ -1,19 +1,175 @@ -import { type FireworksModelId, fireworksDefaultModelId, fireworksModels } from "@roo-code/types" +import { Anthropic } from "@anthropic-ai/sdk" +import { createFireworks } from "@ai-sdk/fireworks" +import { streamText, generateText, ToolSet } from "ai" + +import { fireworksModels, fireworksDefaultModelId, type ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" -import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" +import { getModelParams } from "../transform/model-params" + +import { DEFAULT_HEADERS } from "./constants" +import { BaseProvider } from "./base-provider" +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" + +const FIREWORKS_DEFAULT_TEMPERATURE = 0.5 + +/** + * Fireworks provider using the dedicated @ai-sdk/fireworks package. + * Provides native support for various models including reasoning models. + */ +export class FireworksHandler extends BaseProvider implements SingleCompletionHandler { + protected options: ApiHandlerOptions + protected provider: ReturnType -export class FireworksHandler extends BaseOpenAiCompatibleProvider { constructor(options: ApiHandlerOptions) { - super({ - ...options, - providerName: "Fireworks", + super() + this.options = options + + // Create the Fireworks provider using AI SDK + this.provider = createFireworks({ baseURL: "https://api.fireworks.ai/inference/v1", - apiKey: options.fireworksApiKey, - defaultProviderModelId: fireworksDefaultModelId, - providerModels: fireworksModels, - defaultTemperature: 0.5, + apiKey: options.fireworksApiKey ?? "not-provided", + headers: DEFAULT_HEADERS, + }) + } + + override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } { + const id = this.options.apiModelId ?? fireworksDefaultModelId + const info = fireworksModels[id as keyof typeof fireworksModels] || fireworksModels[fireworksDefaultModelId] + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: FIREWORKS_DEFAULT_TEMPERATURE, + }) + return { id, info, ...params } + } + + /** + * Get the language model for the configured model ID. + */ + protected getLanguageModel() { + const { id } = this.getModel() + return this.provider(id) + } + + /** + * Process usage metrics from the AI SDK response. + */ + protected processUsageMetrics( + usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number + } + }, + providerMetadata?: { + fireworks?: { + promptCacheHitTokens?: number + promptCacheMissTokens?: number + } + }, + ): ApiStreamUsageChunk { + // Extract cache metrics from Fireworks' providerMetadata if available + const cacheReadTokens = providerMetadata?.fireworks?.promptCacheHitTokens ?? usage.details?.cachedInputTokens + const cacheWriteTokens = providerMetadata?.fireworks?.promptCacheMissTokens + + return { + type: "usage", + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheReadTokens, + cacheWriteTokens, + reasoningTokens: usage.details?.reasoningTokens, + } + } + + /** + * Get the max tokens parameter to include in the request. + */ + protected getMaxOutputTokens(): number | undefined { + const { info } = this.getModel() + return this.options.modelMaxTokens || info.maxTokens || undefined + } + + /** + * Create a message stream using the AI SDK. + */ + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const { temperature } = this.getModel() + const languageModel = this.getLanguageModel() + + // Convert messages to AI SDK format + const aiSdkMessages = convertToAiSdkMessages(messages) + + // Convert tools to OpenAI format first, then to AI SDK format + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + // Build the request options + const requestOptions: Parameters[0] = { + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + temperature: this.options.modelTemperature ?? temperature ?? FIREWORKS_DEFAULT_TEMPERATURE, + maxOutputTokens: this.getMaxOutputTokens(), + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + } + + // Use streamText for streaming responses + const result = streamText(requestOptions) + + try { + // Process the full stream to get all events including reasoning + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } + + // Yield usage metrics at the end, including cache metrics from providerMetadata + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics(usage, providerMetadata as any) + } + } catch (error) { + // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) + throw handleAiSdkError(error, "Fireworks") + } + } + + /** + * Complete a prompt using the AI SDK generateText. + */ + async completePrompt(prompt: string): Promise { + const { temperature } = this.getModel() + const languageModel = this.getLanguageModel() + + const { text } = await generateText({ + model: languageModel, + prompt, + maxOutputTokens: this.getMaxOutputTokens(), + temperature: this.options.modelTemperature ?? temperature ?? FIREWORKS_DEFAULT_TEMPERATURE, }) + + return text } } diff --git a/src/api/providers/gemini-cli.ts b/src/api/providers/gemini-cli.ts index 0636c5a09f..88a5c30acc 100644 --- a/src/api/providers/gemini-cli.ts +++ b/src/api/providers/gemini-cli.ts @@ -292,10 +292,10 @@ export class GeminiCliHandler extends BaseProvider implements SingleCompletionHa parts: [{ text: systemInstruction + "\n\n" + getGeminiCliLiteToolGuide() }], }, ...contents, - { - role: "user", - parts: [{ text: getGeminiCliLiteToolGuide() }], - }, + // { + // role: "user", + // parts: [{ text: getGeminiCliLiteToolGuide() }], + // }, ], generationConfig: { temperature: this.options.modelTemperature ?? 0.7, diff --git a/src/api/providers/groq.ts b/src/api/providers/groq.ts index 7583edc51c..648679f92c 100644 --- a/src/api/providers/groq.ts +++ b/src/api/providers/groq.ts @@ -1,19 +1,177 @@ -import { type GroqModelId, groqDefaultModelId, groqModels } from "@roo-code/types" +import { Anthropic } from "@anthropic-ai/sdk" +import { createGroq } from "@ai-sdk/groq" +import { streamText, generateText, ToolSet } from "ai" + +import { groqModels, groqDefaultModelId, type ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" -import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" +import { getModelParams } from "../transform/model-params" + +import { DEFAULT_HEADERS } from "./constants" +import { BaseProvider } from "./base-provider" +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" + +const GROQ_DEFAULT_TEMPERATURE = 0.5 + +/** + * Groq provider using the dedicated @ai-sdk/groq package. + * Provides native support for reasoning models and prompt caching. + */ +export class GroqHandler extends BaseProvider implements SingleCompletionHandler { + protected options: ApiHandlerOptions + protected provider: ReturnType -export class GroqHandler extends BaseOpenAiCompatibleProvider { constructor(options: ApiHandlerOptions) { - super({ - ...options, - providerName: "Groq", + super() + this.options = options + + // Create the Groq provider using AI SDK + this.provider = createGroq({ baseURL: "https://api.groq.com/openai/v1", - apiKey: options.groqApiKey, - defaultProviderModelId: groqDefaultModelId, - providerModels: groqModels, - defaultTemperature: 0.5, + apiKey: options.groqApiKey ?? "not-provided", + headers: DEFAULT_HEADERS, + }) + } + + override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } { + const id = this.options.apiModelId ?? groqDefaultModelId + const info = groqModels[id as keyof typeof groqModels] || groqModels[groqDefaultModelId] + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: GROQ_DEFAULT_TEMPERATURE, + }) + return { id, info, ...params } + } + + /** + * Get the language model for the configured model ID. + */ + protected getLanguageModel() { + const { id } = this.getModel() + return this.provider(id) + } + + /** + * Process usage metrics from the AI SDK response, including Groq's cache metrics. + * Groq provides cache hit/miss info via providerMetadata for supported models. + */ + protected processUsageMetrics( + usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number + } + }, + providerMetadata?: { + groq?: { + promptCacheHitTokens?: number + promptCacheMissTokens?: number + } + }, + ): ApiStreamUsageChunk { + // Extract cache metrics from Groq's providerMetadata + const cacheReadTokens = providerMetadata?.groq?.promptCacheHitTokens ?? usage.details?.cachedInputTokens + const cacheWriteTokens = providerMetadata?.groq?.promptCacheMissTokens + + return { + type: "usage", + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheReadTokens, + cacheWriteTokens, + reasoningTokens: usage.details?.reasoningTokens, + } + } + + /** + * Get the max tokens parameter to include in the request. + */ + protected getMaxOutputTokens(): number | undefined { + const { info } = this.getModel() + return this.options.modelMaxTokens || info.maxTokens || undefined + } + + /** + * Create a message stream using the AI SDK. + * Groq supports reasoning for models like qwen/qwen3-32b via reasoningFormat: 'parsed'. + */ + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const { temperature } = this.getModel() + const languageModel = this.getLanguageModel() + + // Convert messages to AI SDK format + const aiSdkMessages = convertToAiSdkMessages(messages) + + // Convert tools to OpenAI format first, then to AI SDK format + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + // Build the request options + const requestOptions: Parameters[0] = { + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + temperature: this.options.modelTemperature ?? temperature ?? GROQ_DEFAULT_TEMPERATURE, + maxOutputTokens: this.getMaxOutputTokens(), + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + } + + // Use streamText for streaming responses + const result = streamText(requestOptions) + + try { + // Process the full stream to get all events including reasoning + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } + + // Yield usage metrics at the end, including cache metrics from providerMetadata + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics(usage, providerMetadata as any) + } + } catch (error) { + // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) + throw handleAiSdkError(error, "Groq") + } + } + + /** + * Complete a prompt using the AI SDK generateText. + */ + async completePrompt(prompt: string): Promise { + const { temperature } = this.getModel() + const languageModel = this.getLanguageModel() + + const { text } = await generateText({ + model: languageModel, + prompt, + maxOutputTokens: this.getMaxOutputTokens(), + temperature: this.options.modelTemperature ?? temperature ?? GROQ_DEFAULT_TEMPERATURE, }) + + return text } } diff --git a/src/api/providers/openai-compatible.ts b/src/api/providers/openai-compatible.ts index d129e72452..240de747be 100644 --- a/src/api/providers/openai-compatible.ts +++ b/src/api/providers/openai-compatible.ts @@ -12,7 +12,13 @@ import type { ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" -import { convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart } from "../transform/ai-sdk" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { DEFAULT_HEADERS } from "./constants" @@ -103,40 +109,6 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si } } - /** - * Map OpenAI tool_choice to AI SDK toolChoice format. - */ - protected mapToolChoice( - toolChoice: OpenAI.Chat.ChatCompletionCreateParams["tool_choice"], - ): "auto" | "none" | "required" | { type: "tool"; toolName: string } | undefined { - if (!toolChoice) { - return undefined - } - - // Handle string values - if (typeof toolChoice === "string") { - switch (toolChoice) { - case "auto": - return "auto" - case "none": - return "none" - case "required": - return "required" - default: - return "auto" - } - } - - // Handle object values (OpenAI ChatCompletionNamedToolChoice format) - if (typeof toolChoice === "object" && "type" in toolChoice) { - if (toolChoice.type === "function" && "function" in toolChoice && toolChoice.function?.name) { - return { type: "tool", toolName: toolChoice.function.name } - } - } - - return undefined - } - /** * Get the max tokens parameter to include in the request. */ @@ -173,24 +145,29 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si temperature: model.temperature ?? this.config.temperature ?? 0, maxOutputTokens: this.getMaxOutputTokens(), tools: aiSdkTools, - toolChoice: this.mapToolChoice(metadata?.tool_choice), + toolChoice: mapToolChoice(metadata?.tool_choice), } // Use streamText for streaming responses const result = streamText(requestOptions) - // Process the full stream to get all events - for await (const part of result.fullStream) { - // Use the processAiSdkStreamPart utility to convert stream parts - for (const chunk of processAiSdkStreamPart(part)) { - yield chunk + try { + // Process the full stream to get all events + for await (const part of result.fullStream) { + // Use the processAiSdkStreamPart utility to convert stream parts + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } } - } - // Yield usage metrics at the end - const usage = await result.usage - if (usage) { - yield this.processUsageMetrics(usage) + // Yield usage metrics at the end + const usage = await result.usage + if (usage) { + yield this.processUsageMetrics(usage) + } + } catch (error) { + // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) + throw handleAiSdkError(error, this.config.providerName) } } diff --git a/src/api/transform/__tests__/ai-sdk.spec.ts b/src/api/transform/__tests__/ai-sdk.spec.ts index 4a82ecac4e..bd87fd8eeb 100644 --- a/src/api/transform/__tests__/ai-sdk.spec.ts +++ b/src/api/transform/__tests__/ai-sdk.spec.ts @@ -1,6 +1,13 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart } from "../ai-sdk" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + extractAiSdkErrorMessage, + handleAiSdkError, +} from "../ai-sdk" vitest.mock("ai", () => ({ tool: vitest.fn((t) => t), @@ -419,7 +426,10 @@ describe("AI SDK conversion utilities", () => { expect(chunks[0]).toEqual({ type: "tool_call_end", id: "call_1" }) }) - it("processes complete tool-call chunks", () => { + it("ignores tool-call chunks to prevent duplicate tools in UI", () => { + // tool-call is intentionally ignored because tool-input-start/delta/end already + // provide complete tool call information. Emitting tool-call would cause duplicate + // tools in the UI for AI SDK providers (e.g., DeepSeek, Moonshot). const part = { type: "tool-call" as const, toolCallId: "call_1", @@ -428,13 +438,7 @@ describe("AI SDK conversion utilities", () => { } const chunks = [...processAiSdkStreamPart(part)] - expect(chunks).toHaveLength(1) - expect(chunks[0]).toEqual({ - type: "tool_call", - id: "call_1", - name: "read_file", - arguments: '{"path":"test.ts"}', - }) + expect(chunks).toHaveLength(0) }) it("processes source chunks with URL", () => { @@ -489,4 +493,155 @@ describe("AI SDK conversion utilities", () => { } }) }) + + describe("mapToolChoice", () => { + it("should return undefined for null or undefined", () => { + expect(mapToolChoice(null)).toBeUndefined() + expect(mapToolChoice(undefined)).toBeUndefined() + }) + + it("should handle string tool choices", () => { + expect(mapToolChoice("auto")).toBe("auto") + expect(mapToolChoice("none")).toBe("none") + expect(mapToolChoice("required")).toBe("required") + }) + + it("should return auto for unknown string values", () => { + expect(mapToolChoice("unknown")).toBe("auto") + expect(mapToolChoice("invalid")).toBe("auto") + }) + + it("should handle object tool choice with function name", () => { + const result = mapToolChoice({ + type: "function", + function: { name: "my_tool" }, + }) + + expect(result).toEqual({ type: "tool", toolName: "my_tool" }) + }) + + it("should return undefined for object without function name", () => { + const result = mapToolChoice({ + type: "function", + function: {}, + }) + + expect(result).toBeUndefined() + }) + + it("should return undefined for object with non-function type", () => { + const result = mapToolChoice({ + type: "other", + function: { name: "my_tool" }, + }) + + expect(result).toBeUndefined() + }) + }) + + describe("extractAiSdkErrorMessage", () => { + it("should return 'Unknown error' for null/undefined", () => { + expect(extractAiSdkErrorMessage(null)).toBe("Unknown error") + expect(extractAiSdkErrorMessage(undefined)).toBe("Unknown error") + }) + + it("should extract message from AI_RetryError", () => { + const retryError = { + name: "AI_RetryError", + message: "Failed after 3 attempts", + errors: [new Error("Error 1"), new Error("Error 2"), new Error("Too Many Requests")], + lastError: { message: "Too Many Requests", status: 429 }, + } + + const result = extractAiSdkErrorMessage(retryError) + expect(result).toBe("Failed after 3 attempts (429): Too Many Requests") + }) + + it("should handle AI_RetryError without status", () => { + const retryError = { + name: "AI_RetryError", + message: "Failed after 2 attempts", + errors: [new Error("Error 1"), new Error("Connection failed")], + lastError: { message: "Connection failed" }, + } + + const result = extractAiSdkErrorMessage(retryError) + expect(result).toBe("Failed after 2 attempts: Connection failed") + }) + + it("should extract message from AI_APICallError", () => { + const apiError = { + name: "AI_APICallError", + message: "Rate limit exceeded", + status: 429, + } + + const result = extractAiSdkErrorMessage(apiError) + expect(result).toBe("API Error (429): Rate limit exceeded") + }) + + it("should handle AI_APICallError without status", () => { + const apiError = { + name: "AI_APICallError", + message: "Connection timeout", + } + + const result = extractAiSdkErrorMessage(apiError) + expect(result).toBe("Connection timeout") + }) + + it("should extract message from standard Error", () => { + const error = new Error("Something went wrong") + expect(extractAiSdkErrorMessage(error)).toBe("Something went wrong") + }) + + it("should convert non-Error to string", () => { + expect(extractAiSdkErrorMessage("string error")).toBe("string error") + expect(extractAiSdkErrorMessage({ custom: "object" })).toBe("[object Object]") + }) + }) + + describe("handleAiSdkError", () => { + it("should wrap error with provider name", () => { + const error = new Error("API Error") + const result = handleAiSdkError(error, "Fireworks") + + expect(result.message).toBe("Fireworks: API Error") + }) + + it("should preserve status code from AI_RetryError", () => { + const retryError = { + name: "AI_RetryError", + errors: [new Error("Too Many Requests")], + lastError: { message: "Too Many Requests", status: 429 }, + } + + const result = handleAiSdkError(retryError, "Groq") + + expect(result.message).toContain("Groq:") + expect(result.message).toContain("429") + expect((result as any).status).toBe(429) + }) + + it("should preserve status code from AI_APICallError", () => { + const apiError = { + name: "AI_APICallError", + message: "Unauthorized", + status: 401, + } + + const result = handleAiSdkError(apiError, "DeepSeek") + + expect(result.message).toContain("DeepSeek:") + expect(result.message).toContain("401") + expect((result as any).status).toBe(401) + }) + + it("should preserve original error as cause", () => { + const originalError = new Error("Original error") + const result = handleAiSdkError(originalError, "Cerebras") + + expect((result as any).cause).toBe(originalError) + }) + }) }) diff --git a/src/api/transform/ai-sdk.ts b/src/api/transform/ai-sdk.ts index 535b932aba..ebbf1a8661 100644 --- a/src/api/transform/ai-sdk.ts +++ b/src/api/transform/ai-sdk.ts @@ -228,16 +228,6 @@ export function* processAiSdkStreamPart(part: ExtendedStreamPart): Generator { - const entry: FileEntry = { path: file.path } - if (file.line_ranges && Array.isArray(file.line_ranges)) { - entry.lineRanges = file.line_ranges - ?.map((range: any) => { + private static convertFileEntries(files: unknown[] = []): FileEntry[] { + return files?.map((file: unknown) => { + const f = file as Record + const entry: FileEntry = { path: f.path as string } + if (f.line_ranges && Array.isArray(f.line_ranges)) { + entry.lineRanges = (f.line_ranges as unknown[]) + .map((range: unknown) => { // Handle tuple format: [start, end] if (Array.isArray(range) && range.length >= 2) { return { start: Number(range[0]), end: Number(range[1]) } } // Handle object format: { start: number, end: number } if (typeof range === "object" && range !== null && "start" in range && "end" in range) { - return { start: Number(range.start), end: Number(range.end) } + const r = range as { start: unknown; end: unknown } + return { start: Number(r.start), end: Number(r.end) } } // Handle legacy string format: "1-50" if (typeof range === "string") { @@ -390,7 +405,7 @@ export class NativeToolCallParser { } return null }) - .filter(Boolean) + .filter((r): r is { start: number; end: number } => r !== null) } return entry }) @@ -422,6 +437,9 @@ export class NativeToolCallParser { // Build partial nativeArgs based on what we have so far let nativeArgs: any = undefined + // Track if legacy format was used (for telemetry) + let usedLegacyFormat = false + switch (name) { case "fake_tool_call": { // fake_tool_call is a virtual tool for compatibility with models that don't support native function calls @@ -430,8 +448,55 @@ export class NativeToolCallParser { break } case "read_file": - if (partialArgs.files && Array.isArray(partialArgs.files)) { - nativeArgs = { files: this.convertFileEntries(partialArgs.files) } + // Check for legacy format first: { files: [...] } + // Handle both array and stringified array (some models double-stringify) + if (partialArgs.files !== undefined) { + let filesArray: unknown[] | null = null + + if (Array.isArray(partialArgs.files)) { + filesArray = partialArgs.files + } else if (typeof partialArgs.files === "string") { + // Handle double-stringified case: files is a string containing JSON array + try { + const parsed = JSON.parse(partialArgs.files) + if (Array.isArray(parsed)) { + filesArray = parsed + } + } catch { + // Not valid JSON, ignore + } + } + + if (filesArray && filesArray.length > 0) { + usedLegacyFormat = true + nativeArgs = { + files: this.convertFileEntries(filesArray), + _legacyFormat: true as const, + } + } + } + // New format: { path: "...", mode: "..." } + if (!nativeArgs && partialArgs.path !== undefined) { + nativeArgs = { + path: partialArgs.path, + mode: partialArgs.mode, + offset: this.coerceOptionalNumber(partialArgs.offset), + limit: this.coerceOptionalNumber(partialArgs.limit), + indentation: + partialArgs.indentation && typeof partialArgs.indentation === "object" + ? { + anchor_line: this.coerceOptionalNumber(partialArgs.indentation.anchor_line), + max_levels: this.coerceOptionalNumber(partialArgs.indentation.max_levels), + max_lines: this.coerceOptionalNumber(partialArgs.indentation.max_lines), + include_siblings: this.coerceOptionalBoolean( + partialArgs.indentation.include_siblings, + ), + include_header: this.coerceOptionalBoolean( + partialArgs.indentation.include_header, + ), + } + : undefined, + } } break @@ -508,14 +573,6 @@ export class NativeToolCallParser { } break - case "fetch_instructions": - if (partialArgs.task !== undefined) { - nativeArgs = { - task: partialArgs.task, - } - } - break - case "generate_image": if (partialArgs.prompt !== undefined || partialArgs.path !== undefined) { nativeArgs = { @@ -535,6 +592,15 @@ export class NativeToolCallParser { } break + case "skill": + if (partialArgs.skill !== undefined) { + nativeArgs = { + skill: partialArgs.skill, + args: partialArgs.args, + } + } + break + case "search_files": if (partialArgs.path !== undefined || partialArgs.regex !== undefined) { nativeArgs = { @@ -655,6 +721,11 @@ export class NativeToolCallParser { result.originalName = originalName } + // Track legacy format usage for telemetry + if (usedLegacyFormat) { + result.usedLegacyFormat = true + } + return result } @@ -731,14 +802,7 @@ export class NativeToolCallParser { // Tool execution MUST use nativeArgs (typed) and does not support legacy fallbacks. const params: Partial> = {} - for (const [key, value] of Object.entries(normalizedArgs)) { - // 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 (resolvedName === "read_file" && key === "files") { - continue - } - + for (const [key, value] of Object.entries(args)) { // Validate parameter name if (!toolParamNames.includes(key as ToolParamName) && !customToolRegistry.has(resolvedName)) { console.warn(`Unknown parameter '${key}' for tool '${resolvedName}'`) @@ -756,10 +820,58 @@ export class NativeToolCallParser { // nativeArgs object. If validation fails, we treat the tool call as invalid and fail fast. let nativeArgs: NativeArgsFor | undefined = undefined + // Track if legacy format was used (for telemetry) + let usedLegacyFormat = false + switch (resolvedName) { case "read_file": - if (normalizedArgs.files && Array.isArray(normalizedArgs.files)) { - nativeArgs = { files: this.convertFileEntries(normalizedArgs.files) } as NativeArgsFor + // Check for legacy format first: { files: [...] } + // Handle both array and stringified array (some models double-stringify) + if (args.files !== undefined) { + let filesArray: unknown[] | null = null + + if (Array.isArray(args.files)) { + filesArray = args.files + } else if (typeof args.files === "string") { + // Handle double-stringified case: files is a string containing JSON array + try { + const parsed = JSON.parse(args.files) + if (Array.isArray(parsed)) { + filesArray = parsed + } + } catch { + // Not valid JSON, ignore + } + } + + if (filesArray && filesArray.length > 0) { + usedLegacyFormat = true + nativeArgs = { + files: this.convertFileEntries(filesArray), + _legacyFormat: true as const, + } as NativeArgsFor + } + } + // New format: { path: "...", mode: "..." } + if (!nativeArgs && args.path !== undefined) { + nativeArgs = { + path: args.path, + mode: args.mode, + offset: this.coerceOptionalNumber(args.offset), + limit: this.coerceOptionalNumber(args.limit), + indentation: + args.indentation && typeof args.indentation === "object" + ? { + anchor_line: this.coerceOptionalNumber(args.indentation.anchor_line), + max_levels: this.coerceOptionalNumber(args.indentation.max_levels), + max_lines: this.coerceOptionalNumber(args.indentation.max_lines), + include_siblings: this.coerceOptionalBoolean( + args.indentation.include_siblings, + ), + include_header: this.coerceOptionalBoolean(args.indentation.include_header), + } + : undefined, + } as NativeArgsFor } break @@ -844,14 +956,6 @@ export class NativeToolCallParser { } break - case "fetch_instructions": - if (normalizedArgs.task !== undefined) { - nativeArgs = { - task: normalizedArgs.task, - } as NativeArgsFor - } - break - case "generate_image": if (normalizedArgs.prompt !== undefined && normalizedArgs.path !== undefined) { nativeArgs = { @@ -871,6 +975,15 @@ export class NativeToolCallParser { } break + case "skill": + if (args.skill !== undefined) { + nativeArgs = { + skill: args.skill, + args: args.args, + } as NativeArgsFor + } + break + case "search_files": if (normalizedArgs.path !== undefined && normalizedArgs.regex !== undefined) { nativeArgs = { @@ -1025,6 +1138,11 @@ export class NativeToolCallParser { result.originalName = toolCall.name } + // Track legacy format usage for telemetry + if (usedLegacyFormat) { + result.usedLegacyFormat = true + } + return result } catch (error) { console.error( diff --git a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts index 0e81671cc1..db0dc00de4 100644 --- a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts +++ b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts @@ -8,20 +8,12 @@ describe("NativeToolCallParser", () => { describe("parseToolCall", () => { describe("read_file tool", () => { - it("should handle line_ranges as tuples (new format)", () => { + it("should parse minimal single-file read_file args", () => { const toolCall = { id: "toolu_123", name: "read_file" as const, arguments: JSON.stringify({ - files: [ - { - path: "src/core/task/Task.ts", - line_ranges: [ - [1920, 1990], - [2060, 2120], - ], - }, - ], + path: "src/core/task/Task.ts", }), } @@ -31,29 +23,20 @@ describe("NativeToolCallParser", () => { expect(result?.type).toBe("tool_use") if (result?.type === "tool_use") { expect(result.nativeArgs).toBeDefined() - const nativeArgs = result.nativeArgs as { - files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }> - } - expect(nativeArgs.files).toHaveLength(1) - expect(nativeArgs.files[0].path).toBe("src/core/task/Task.ts") - expect(nativeArgs.files[0].lineRanges).toEqual([ - { start: 1920, end: 1990 }, - { start: 2060, end: 2120 }, - ]) + const nativeArgs = result.nativeArgs as { path: string } + expect(nativeArgs.path).toBe("src/core/task/Task.ts") } }) - it("should handle line_ranges as strings (legacy format)", () => { + it("should parse slice-mode params", () => { const toolCall = { id: "toolu_123", name: "read_file" as const, arguments: JSON.stringify({ - files: [ - { - path: "src/core/task/Task.ts", - line_ranges: ["1920-1990", "2060-2120"], - }, - ], + path: "src/core/task/Task.ts", + mode: "slice", + offset: 10, + limit: 20, }), } @@ -62,29 +45,32 @@ describe("NativeToolCallParser", () => { expect(result).not.toBeNull() expect(result?.type).toBe("tool_use") if (result?.type === "tool_use") { - expect(result.nativeArgs).toBeDefined() const nativeArgs = result.nativeArgs as { - files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }> - } - expect(nativeArgs.files).toHaveLength(1) - expect(nativeArgs.files[0].path).toBe("src/core/task/Task.ts") - expect(nativeArgs.files[0].lineRanges).toEqual([ - { start: 1920, end: 1990 }, - { start: 2060, end: 2120 }, - ]) + path: string + mode?: string + offset?: number + limit?: number + } + expect(nativeArgs.path).toBe("src/core/task/Task.ts") + expect(nativeArgs.mode).toBe("slice") + expect(nativeArgs.offset).toBe(10) + expect(nativeArgs.limit).toBe(20) } }) - it("should handle files without line_ranges", () => { + it("should parse indentation-mode params", () => { const toolCall = { id: "toolu_123", name: "read_file" as const, arguments: JSON.stringify({ - files: [ - { - path: "src/utils.ts", - }, - ], + path: "src/utils.ts", + mode: "indentation", + indentation: { + anchor_line: 123, + max_levels: 2, + include_siblings: true, + include_header: false, + }, }), } @@ -94,120 +80,242 @@ describe("NativeToolCallParser", () => { expect(result?.type).toBe("tool_use") if (result?.type === "tool_use") { const nativeArgs = result.nativeArgs as { - files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }> + path: string + mode?: string + indentation?: { + anchor_line?: number + max_levels?: number + include_siblings?: boolean + include_header?: boolean + } } - expect(nativeArgs.files).toHaveLength(1) - expect(nativeArgs.files[0].path).toBe("src/utils.ts") - expect(nativeArgs.files[0].lineRanges).toBeUndefined() + expect(nativeArgs.path).toBe("src/utils.ts") + expect(nativeArgs.mode).toBe("indentation") + expect(nativeArgs.indentation?.anchor_line).toBe(123) + expect(nativeArgs.indentation?.include_siblings).toBe(true) + expect(nativeArgs.indentation?.include_header).toBe(false) } }) - it("should handle multiple files with different line_ranges", () => { - const toolCall = { - id: "toolu_123", - name: "read_file" as const, - arguments: JSON.stringify({ - files: [ - { - path: "file1.ts", - line_ranges: ["1-50"], - }, - { - path: "file2.ts", - line_ranges: ["100-150", "200-250"], - }, - { - path: "file3.ts", - }, - ], - }), - } + // Legacy format backward compatibility tests + describe("legacy format backward compatibility", () => { + it("should parse legacy files array format with single file", () => { + const toolCall = { + id: "toolu_legacy_1", + name: "read_file" as const, + arguments: JSON.stringify({ + files: [{ path: "src/legacy/file.ts" }], + }), + } - const result = NativeToolCallParser.parseToolCall(toolCall) + const result = NativeToolCallParser.parseToolCall(toolCall) - expect(result).not.toBeNull() - expect(result?.type).toBe("tool_use") - if (result?.type === "tool_use") { - const nativeArgs = result.nativeArgs as { - files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }> - } - expect(nativeArgs.files).toHaveLength(3) - expect(nativeArgs.files[0].lineRanges).toEqual([{ start: 1, end: 50 }]) - expect(nativeArgs.files[1].lineRanges).toEqual([ - { start: 100, end: 150 }, - { start: 200, end: 250 }, - ]) - expect(nativeArgs.files[2].lineRanges).toBeUndefined() - } - }) + expect(result).not.toBeNull() + expect(result?.type).toBe("tool_use") + if (result?.type === "tool_use") { + expect(result.usedLegacyFormat).toBe(true) + const nativeArgs = result.nativeArgs as { files: Array<{ path: string }>; _legacyFormat: true } + expect(nativeArgs._legacyFormat).toBe(true) + expect(nativeArgs.files).toHaveLength(1) + expect(nativeArgs.files[0].path).toBe("src/legacy/file.ts") + } + }) - it("should filter out invalid line_range strings", () => { - const toolCall = { - id: "toolu_123", - name: "read_file" as const, - arguments: JSON.stringify({ - files: [ - { - path: "file.ts", - line_ranges: ["1-50", "invalid", "100-200", "abc-def"], - }, - ], - }), - } + it("should parse legacy files array format with multiple files", () => { + const toolCall = { + id: "toolu_legacy_2", + name: "read_file" as const, + arguments: JSON.stringify({ + files: [{ path: "src/file1.ts" }, { path: "src/file2.ts" }, { path: "src/file3.ts" }], + }), + } - const result = NativeToolCallParser.parseToolCall(toolCall) + const result = NativeToolCallParser.parseToolCall(toolCall) - expect(result).not.toBeNull() - expect(result?.type).toBe("tool_use") - if (result?.type === "tool_use") { - const nativeArgs = result.nativeArgs as { - files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }> + expect(result).not.toBeNull() + expect(result?.type).toBe("tool_use") + if (result?.type === "tool_use") { + expect(result.usedLegacyFormat).toBe(true) + const nativeArgs = result.nativeArgs as { files: Array<{ path: string }>; _legacyFormat: true } + expect(nativeArgs.files).toHaveLength(3) + expect(nativeArgs.files[0].path).toBe("src/file1.ts") + expect(nativeArgs.files[1].path).toBe("src/file2.ts") + expect(nativeArgs.files[2].path).toBe("src/file3.ts") } - expect(nativeArgs.files[0].lineRanges).toEqual([ - { start: 1, end: 50 }, - { start: 100, end: 200 }, - ]) - } + }) + + it("should parse legacy line_ranges as tuples", () => { + const toolCall = { + id: "toolu_legacy_3", + name: "read_file" as const, + arguments: JSON.stringify({ + files: [ + { + path: "src/task.ts", + line_ranges: [ + [1, 50], + [100, 150], + ], + }, + ], + }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + + expect(result).not.toBeNull() + expect(result?.type).toBe("tool_use") + if (result?.type === "tool_use") { + expect(result.usedLegacyFormat).toBe(true) + const nativeArgs = result.nativeArgs as { + files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }> + _legacyFormat: true + } + expect(nativeArgs.files[0].lineRanges).toHaveLength(2) + expect(nativeArgs.files[0].lineRanges?.[0]).toEqual({ start: 1, end: 50 }) + expect(nativeArgs.files[0].lineRanges?.[1]).toEqual({ start: 100, end: 150 }) + } + }) + + it("should parse legacy line_ranges as objects", () => { + const toolCall = { + id: "toolu_legacy_4", + name: "read_file" as const, + arguments: JSON.stringify({ + files: [ + { + path: "src/task.ts", + line_ranges: [ + { start: 10, end: 20 }, + { start: 30, end: 40 }, + ], + }, + ], + }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + + expect(result).not.toBeNull() + expect(result?.type).toBe("tool_use") + if (result?.type === "tool_use") { + expect(result.usedLegacyFormat).toBe(true) + const nativeArgs = result.nativeArgs as { + files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }> + } + expect(nativeArgs.files[0].lineRanges).toHaveLength(2) + expect(nativeArgs.files[0].lineRanges?.[0]).toEqual({ start: 10, end: 20 }) + expect(nativeArgs.files[0].lineRanges?.[1]).toEqual({ start: 30, end: 40 }) + } + }) + + it("should parse legacy line_ranges as strings", () => { + const toolCall = { + id: "toolu_legacy_5", + name: "read_file" as const, + arguments: JSON.stringify({ + files: [ + { + path: "src/task.ts", + line_ranges: ["1-50", "100-150"], + }, + ], + }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + + expect(result).not.toBeNull() + expect(result?.type).toBe("tool_use") + if (result?.type === "tool_use") { + expect(result.usedLegacyFormat).toBe(true) + const nativeArgs = result.nativeArgs as { + files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }> + } + expect(nativeArgs.files[0].lineRanges).toHaveLength(2) + expect(nativeArgs.files[0].lineRanges?.[0]).toEqual({ start: 1, end: 50 }) + expect(nativeArgs.files[0].lineRanges?.[1]).toEqual({ start: 100, end: 150 }) + } + }) + + it("should parse double-stringified files array (model quirk)", () => { + // This tests the real-world case where some models double-stringify the files array + // e.g., { files: "[{\"path\": \"...\"}]" } instead of { files: [{path: "..."}] } + const toolCall = { + id: "toolu_double_stringify", + name: "read_file" as const, + arguments: JSON.stringify({ + files: JSON.stringify([ + { path: "src/services/browser/browserDiscovery.ts" }, + { path: "src/services/mcp/McpServerManager.ts" }, + ]), + }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + + expect(result).not.toBeNull() + expect(result?.type).toBe("tool_use") + if (result?.type === "tool_use") { + expect(result.usedLegacyFormat).toBe(true) + const nativeArgs = result.nativeArgs as { + files: Array<{ path: string }> + _legacyFormat: true + } + expect(nativeArgs._legacyFormat).toBe(true) + expect(nativeArgs.files).toHaveLength(2) + expect(nativeArgs.files[0].path).toBe("src/services/browser/browserDiscovery.ts") + expect(nativeArgs.files[1].path).toBe("src/services/mcp/McpServerManager.ts") + } + }) + + it("should NOT set usedLegacyFormat for new format", () => { + const toolCall = { + id: "toolu_new", + name: "read_file" as const, + arguments: JSON.stringify({ + path: "src/new/format.ts", + mode: "slice", + offset: 1, + limit: 100, + }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + + expect(result).not.toBeNull() + expect(result?.type).toBe("tool_use") + if (result?.type === "tool_use") { + expect(result.usedLegacyFormat).toBeUndefined() + } + }) }) }) }) describe("processStreamingChunk", () => { describe("read_file tool", () => { - it("should convert line_ranges strings to lineRanges objects during streaming", () => { + it("should emit a partial ToolUse with nativeArgs.path during streaming", () => { const id = "toolu_streaming_123" NativeToolCallParser.startStreamingToolCall(id, "read_file") // Simulate streaming chunks - const fullArgs = JSON.stringify({ - files: [ - { - path: "src/test.ts", - line_ranges: ["10-20", "30-40"], - }, - ], - }) + const fullArgs = JSON.stringify({ path: "src/test.ts" }) // Process the complete args as a single chunk for simplicity const result = NativeToolCallParser.processStreamingChunk(id, fullArgs) expect(result).not.toBeNull() expect(result?.nativeArgs).toBeDefined() - const nativeArgs = result?.nativeArgs as { - files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }> - } - expect(nativeArgs.files).toHaveLength(1) - expect(nativeArgs.files[0].lineRanges).toEqual([ - { start: 10, end: 20 }, - { start: 30, end: 40 }, - ]) + const nativeArgs = result?.nativeArgs as { path: string } + expect(nativeArgs.path).toBe("src/test.ts") }) }) }) describe("finalizeStreamingToolCall", () => { describe("read_file tool", () => { - it("should convert line_ranges strings to lineRanges objects on finalize", () => { + it("should parse read_file args on finalize", () => { const id = "toolu_finalize_123" NativeToolCallParser.startStreamingToolCall(id, "read_file") @@ -215,12 +323,10 @@ describe("NativeToolCallParser", () => { NativeToolCallParser.processStreamingChunk( id, JSON.stringify({ - files: [ - { - path: "finalized.ts", - line_ranges: ["500-600"], - }, - ], + path: "finalized.ts", + mode: "slice", + offset: 1, + limit: 10, }), ) @@ -229,11 +335,10 @@ describe("NativeToolCallParser", () => { expect(result).not.toBeNull() expect(result?.type).toBe("tool_use") if (result?.type === "tool_use") { - const nativeArgs = result.nativeArgs as { - files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }> - } - expect(nativeArgs.files[0].path).toBe("finalized.ts") - expect(nativeArgs.files[0].lineRanges).toEqual([{ start: 500, end: 600 }]) + const nativeArgs = result.nativeArgs as { path: string; offset?: number; limit?: number } + expect(nativeArgs.path).toBe("finalized.ts") + expect(nativeArgs.offset).toBe(1) + expect(nativeArgs.limit).toBe(10) } }) }) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 1bf3da38df..2f7ede8af3 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -2,7 +2,7 @@ import { serializeError } from "serialize-error" import { Anthropic } from "@anthropic-ai/sdk" import type { ToolName, ClineAsk, ToolProgressStatus } from "@roo-code/types" -import { ConsecutiveMistakeError } from "@roo-code/types" +import { ConsecutiveMistakeError, TelemetryEventName } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { customToolRegistry } from "@roo-code/core" @@ -14,7 +14,6 @@ import type { ToolResponse, ToolUse, McpToolUse } from "../../shared/tools" import { AskIgnoredError } from "../task/AskIgnoredError" import { Task } from "../task/Task" -import { fetchInstructionsTool } from "../tools/FetchInstructionsTool" import { listFilesTool } from "../tools/ListFilesTool" import { readFileTool } from "../tools/ReadFileTool" import { readCommandOutputTool } from "../tools/ReadCommandOutputTool" @@ -35,6 +34,7 @@ import { attemptCompletionTool, AttemptCompletionCallbacks } from "../tools/Atte import { newTaskTool } from "../tools/NewTaskTool" import { updateTodoListTool } from "../tools/UpdateTodoListTool" import { runSlashCommandTool } from "../tools/RunSlashCommandTool" +import { skillTool } from "../tools/SkillTool" import { generateImageTool } from "../tools/GenerateImageTool" import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool" import { isValidToolName, validateToolUse } from "../tools/validateToolUse" @@ -47,6 +47,7 @@ import { fixBrowserLaunchAction } from "../../utils/fixbrowserLaunchAction" import { fixNativeToolname } from "../../utils/fixNativeToolname" // import { isNativeProtocol } from "@roo-code/types" // import { resolveToolProtocol } from "../../utils/resolveToolProtocol" +import { sanitizeToolUseId } from "../../utils/tool-id" /** * Processes and presents assistant message content to the user interface. @@ -125,7 +126,7 @@ export async function presentAssistantMessage(cline: Task) { if (toolCallId) { cline.pushToolResultToUserContent({ type: "tool_result", - tool_use_id: toolCallId, + tool_use_id: sanitizeToolUseId(toolCallId), content: errorMessage, is_error: true, }) @@ -183,7 +184,7 @@ export async function presentAssistantMessage(cline: Task) { if (toolCallId) { cline.pushToolResultToUserContent({ type: "tool_result", - tool_use_id: toolCallId, + tool_use_id: sanitizeToolUseId(toolCallId), content: resultContent, }) @@ -364,8 +365,6 @@ export async function presentAssistantMessage(cline: Task) { return readFileTool.getReadFileToolDescription(block.name, block.nativeArgs) } return readFileTool.getReadFileToolDescription(block.name, block.params) - case "fetch_instructions": - return `[${block.name} for '${block.params.task}']` case "write_to_file": return `[${block.name} for '${block.params.path}']` case "apply_diff": @@ -413,6 +412,8 @@ export async function presentAssistantMessage(cline: Task) { } case "run_slash_command": return `[${block.name} for '${block.params.command}'${block.params.args ? ` with args: ${block.params.args}` : ""}]` + case "skill": + return `[${block.name} for '${block.params.skill}'${block.params.args ? ` with args: ${block.params.args}` : ""}]` case "generate_image": return `[${block.name} for '${block.params.path}']` default: @@ -429,7 +430,7 @@ export async function presentAssistantMessage(cline: Task) { cline.pushToolResultToUserContent({ type: "tool_result", - tool_use_id: toolCallId, + tool_use_id: sanitizeToolUseId(toolCallId), content: errorMessage, is_error: true, }) @@ -466,7 +467,7 @@ export async function presentAssistantMessage(cline: Task) { // continue gracefully. cline.pushToolResultToUserContent({ type: "tool_result", - tool_use_id: toolCallId, + tool_use_id: sanitizeToolUseId(toolCallId), content: formatResponse.toolError(errorMessage), is_error: true, }) @@ -523,7 +524,7 @@ export async function presentAssistantMessage(cline: Task) { cline.pushToolResultToUserContent({ type: "tool_result", - tool_use_id: toolCallId, + tool_use_id: sanitizeToolUseId(toolCallId), content: resultContent, }) @@ -632,6 +633,15 @@ export async function presentAssistantMessage(cline: Task) { const recordName = isCustomTool ? "custom_tool" : block.name cline.recordToolUsage(recordName) TelemetryService.instance.captureToolUsage(cline.taskId, recordName) + + // Track legacy format usage for read_file tool (for migration monitoring) + if (block.name === "read_file" && block.usedLegacyFormat) { + const modelInfo = cline.api.getModel() + TelemetryService.instance.captureEvent(TelemetryEventName.READ_FILE_LEGACY_FORMAT_USED, { + taskId: cline.taskId, + model: modelInfo?.id, + }) + } } // Validate tool use before execution - ONLY for complete (non-partial) blocks. @@ -667,7 +677,7 @@ export async function presentAssistantMessage(cline: Task) { // Push tool_result directly without setting didAlreadyUseTool cline.pushToolResultToUserContent({ type: "tool_result", - tool_use_id: toolCallId, + tool_use_id: sanitizeToolUseId(toolCallId), content: typeof errorContent === "string" ? errorContent : "(validation error)", is_error: true, }) @@ -792,13 +802,6 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult, }) break - case "fetch_instructions": - await fetchInstructionsTool.handle(cline, block as ToolUse<"fetch_instructions">, { - askApproval, - handleError, - pushToolResult, - }) - break case "list_files": await listFilesTool.handle(cline, block as ToolUse<"list_files">, { askApproval, @@ -909,6 +912,13 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult, }) break + case "skill": + await skillTool.handle(cline, block as ToolUse<"skill">, { + askApproval, + handleError, + pushToolResult, + }) + break case "generate_image": await checkpointSaveAndMark(cline) await generateImageTool.handle(cline, block as ToolUse<"generate_image">, { @@ -977,7 +987,7 @@ export async function presentAssistantMessage(cline: Task) { // This prevents the stream from being interrupted with "Response interrupted by tool use result" cline.pushToolResultToUserContent({ type: "tool_result", - tool_use_id: toolCallId, + tool_use_id: sanitizeToolUseId(toolCallId), content: formatResponse.toolError(errorMessage), is_error: true, }) @@ -1089,7 +1099,6 @@ function containsXmlToolMarkup(text: string): boolean { "codebase_search", "edit_file", "execute_command", - "fetch_instructions", "generate_image", "list_files", "new_task", diff --git a/src/core/auto-approval/index.ts b/src/core/auto-approval/index.ts index 7e1b6200b4..e83c70a5c4 100644 --- a/src/core/auto-approval/index.ts +++ b/src/core/auto-approval/index.ts @@ -153,14 +153,11 @@ export async function checkAutoApproval({ return { decision: "approve" } } - if (tool?.tool === "fetchInstructions") { - if (tool.content === "create_mode") { - return state.alwaysAllowModeSwitch === true ? { decision: "approve" } : { decision: "ask" } - } - - if (tool.content === "create_mcp_server") { - return state.alwaysAllowMcp === true ? { decision: "approve" } : { decision: "ask" } - } + // The skill tool only loads pre-defined instructions from built-in, global, or project skills. + // It does not read arbitrary files - skills must be explicitly installed/defined by the user. + // Auto-approval is intentional to provide a seamless experience when loading task instructions. + if (tool.tool === "skill") { + return { decision: "approve" } } if (tool?.tool === "switchMode") { diff --git a/src/core/costrict/codebase-index/packageInfoApi.ts b/src/core/costrict/codebase-index/packageInfoApi.ts index 07792cb77b..993192aa8e 100644 --- a/src/core/costrict/codebase-index/packageInfoApi.ts +++ b/src/core/costrict/codebase-index/packageInfoApi.ts @@ -28,7 +28,7 @@ export class PackageInfoApi { }) if (!response.ok) { - const errorData = await await response.text() + const errorData = await response.text() throw new Error(`Failed to get package information (${url}): ${errorData}`) } diff --git a/src/core/mentions/__tests__/processUserContentMentions.spec.ts b/src/core/mentions/__tests__/processUserContentMentions.spec.ts index 656ee5f772..7732cf279b 100644 --- a/src/core/mentions/__tests__/processUserContentMentions.spec.ts +++ b/src/core/mentions/__tests__/processUserContentMentions.spec.ts @@ -26,103 +26,10 @@ describe("processUserContentMentions", () => { vi.mocked(parseMentions).mockImplementation(async (text) => ({ text: `parsed: ${text}`, mode: undefined, + contentBlocks: [], })) }) - describe("maxReadFileLine parameter", () => { - it("should pass maxReadFileLine to parseMentions when provided", async () => { - const userContent = [ - { - type: "text" as const, - text: "Read file with limit", - }, - ] - - await processUserContentMentions({ - userContent, - cwd: "/test", - urlContentFetcher: mockUrlContentFetcher, - fileContextTracker: mockFileContextTracker, - rooIgnoreController: mockRooIgnoreController, - maxReadFileLine: 100, - }) - - expect(parseMentions).toHaveBeenCalledWith( - "Read file with limit", - "/test", - mockUrlContentFetcher, - mockFileContextTracker, - mockRooIgnoreController, - false, - true, // includeDiagnosticMessages - 50, // maxDiagnosticMessages - 100, - undefined, - ) - }) - - it("should pass undefined maxReadFileLine when not provided", async () => { - const userContent = [ - { - type: "text" as const, - text: "Read file without limit", - }, - ] - - await processUserContentMentions({ - userContent, - cwd: "/test", - urlContentFetcher: mockUrlContentFetcher, - fileContextTracker: mockFileContextTracker, - rooIgnoreController: mockRooIgnoreController, - }) - - expect(parseMentions).toHaveBeenCalledWith( - "Read file without limit", - "/test", - mockUrlContentFetcher, - mockFileContextTracker, - mockRooIgnoreController, - false, - true, // includeDiagnosticMessages - 50, // maxDiagnosticMessages - undefined, - undefined, - ) - }) - - it("should handle UNLIMITED_LINES constant correctly", async () => { - const userContent = [ - { - type: "text" as const, - text: "Read unlimited lines", - }, - ] - - await processUserContentMentions({ - userContent, - cwd: "/test", - urlContentFetcher: mockUrlContentFetcher, - fileContextTracker: mockFileContextTracker, - rooIgnoreController: mockRooIgnoreController, - maxReadFileLine: -1, - }) - - expect(parseMentions).toHaveBeenCalledWith( - "Read unlimited lines", - "/test", - mockUrlContentFetcher, - mockFileContextTracker, - mockRooIgnoreController, - false, - true, // includeDiagnosticMessages - 50, // maxDiagnosticMessages - -1, - undefined, - ) - }) - }) - describe("content processing", () => { it("should process text blocks with tags", async () => { const userContent = [ @@ -184,10 +91,16 @@ describe("processUserContentMentions", () => { }) expect(parseMentions).toHaveBeenCalled() + // String content is now converted to array format to support content blocks expect(result.content[0]).toEqual({ type: "tool_result", tool_use_id: "123", - content: "parsed: Tool feedback", + content: [ + { + type: "text", + text: "parsed: Tool feedback", + }, + ], }) expect(result.mode).toBeUndefined() }) @@ -261,7 +174,6 @@ describe("processUserContentMentions", () => { cwd: "/test", urlContentFetcher: mockUrlContentFetcher, fileContextTracker: mockFileContextTracker, - maxReadFileLine: 50, }) expect(parseMentions).toHaveBeenCalledTimes(2) @@ -271,10 +183,16 @@ describe("processUserContentMentions", () => { text: "parsed: First task", }) expect(result.content[1]).toEqual(userContent[1]) // Image block unchanged + // String content is now converted to array format to support content blocks expect(result.content[2]).toEqual({ type: "tool_result", tool_use_id: "456", - content: "parsed: Feedback", + content: [ + { + type: "text", + text: "parsed: Feedback", + }, + ], }) expect(result.mode).toBeUndefined() }) @@ -305,8 +223,6 @@ describe("processUserContentMentions", () => { false, // showRooIgnoredFiles should default to false true, // includeDiagnosticMessages 50, // maxDiagnosticMessages - undefined, - undefined, ) }) @@ -335,8 +251,6 @@ describe("processUserContentMentions", () => { false, true, // includeDiagnosticMessages 50, // maxDiagnosticMessages - undefined, - undefined, ) }) }) @@ -347,6 +261,7 @@ describe("processUserContentMentions", () => { text: "parsed text", slashCommandHelp: "command help", mode: undefined, + contentBlocks: [], }) const userContent = [ @@ -379,6 +294,7 @@ describe("processUserContentMentions", () => { text: "parsed tool output", slashCommandHelp: "command help", mode: undefined, + contentBlocks: [], }) const userContent = [ @@ -418,6 +334,7 @@ describe("processUserContentMentions", () => { text: "parsed array item", slashCommandHelp: "command help", mode: undefined, + contentBlocks: [], }) const userContent = [ diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts index 728317665e..0272d5aae9 100644 --- a/src/core/mentions/index.ts +++ b/src/core/mentions/index.ts @@ -9,8 +9,9 @@ import { mentionRegexGlobal, commandRegexGlobal, unescapeSpaces } from "../../sh import { getCommitInfo, getWorkingState } from "../../utils/git" import { openFile } from "../../integrations/misc/open-file" -import { extractTextFromFile } from "../../integrations/misc/extract-text" +import { extractTextFromFileWithMetadata, type ExtractTextResult } from "../../integrations/misc/extract-text" import { diagnosticsToProblemsString } from "../../integrations/diagnostics" +import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file" import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher" @@ -20,7 +21,18 @@ import { RooIgnoreController } from "../ignore/RooIgnoreController" import { getCommand, type Command } from "../../services/command/commands" import { t } from "../../i18n" -import { Task } from "../task/Task" + +/** + * Maximum number of files to read from a folder mention. + * This prevents context window explosion when mentioning large directories. + */ +export const MAX_FOLDER_FILES_TO_READ = 10 + +/** + * Maximum total content size (in characters) to read from a folder mention. + * This is approximately 100KB which should be safe for most context windows. + */ +export const MAX_FOLDER_CONTENT_SIZE = 128_000 function getUrlErrorMessage(error: unknown): string { const errorMessage = error instanceof Error ? error.message : String(error) @@ -72,12 +84,59 @@ export async function openMention(cwd: string, mention?: string): Promise } } +/** + * Represents a content block generated from an @ mention. + * These are returned separately from the user's text to enable + * proper formatting as distinct message blocks. + */ +export interface MentionContentBlock { + type: "file" | "folder" | "url" | "diagnostics" | "git_changes" | "git_commit" | "terminal" | "command" + /** Path for file/folder mentions */ + path?: string + /** The content to display */ + content: string + /** Metadata about truncation (for files) */ + metadata?: { + totalLines: number + returnedLines: number + wasTruncated: boolean + linesShown?: [number, number] + } +} + export interface ParseMentionsResult { + /** User's text with @ mentions replaced by clean path references */ text: string + /** Separate content blocks for each mention (file content, URLs, etc.) */ + contentBlocks: MentionContentBlock[] slashCommandHelp?: string mode?: string // Mode from the first slash command that has one } +/** + * Formats file content to look like a read_file tool result. + * Includes Gemini-style truncation warning when content is truncated. + */ +function formatFileReadResult(filePath: string, result: ExtractTextResult): string { + const header = `[read_file for '${filePath}']` + + if (result.wasTruncated && result.linesShown) { + const [start, end] = result.linesShown + const nextOffset = end + 1 + return `${header} +IMPORTANT: File content truncated. +Status: Showing lines ${start}-${end} of ${result.totalLines} total lines. +To read more: Use the read_file tool with offset=${nextOffset} and limit=${DEFAULT_LINE_LIMIT}. + +File: ${filePath} +${result.content}` + } + + return `${header} +File: ${filePath} +${result.content}` +} + export async function parseMentions( text: string, cwd: string, @@ -87,11 +146,10 @@ export async function parseMentions( showRooIgnoredFiles: boolean = false, includeDiagnosticMessages: boolean = true, maxDiagnosticMessages: number = 50, - maxReadFileLine?: number, - maxReadCharacterLimit?: number, ): Promise { const mentions: Set = new Set() const validCommands: Map = new Map() + const contentBlocks: MentionContentBlock[] = [] let commandMode: string | undefined // Track mode from the first slash command that has one // First pass: check which command mentions exist and cache the results @@ -121,7 +179,7 @@ export async function parseMentions( } } - // Only replace text for commands that actually exist + // Only replace text for commands that actually exist (keep "see below" for commands) let parsedText = text for (const [match, commandName] of commandMatches) { if (validCommands.has(commandName)) { @@ -129,16 +187,17 @@ export async function parseMentions( } } - // Second pass: handle regular mentions + // Second pass: handle regular mentions - replace with clean references + // Content will be provided as separate blocks that look like read_file results parsedText = parsedText.replace(mentionRegexGlobal, (match, mention) => { mentions.add(mention) if (mention.startsWith("http")) { + // Keep old style for URLs (still XML-based) return `'${mention}' (see below for site content)` } else if (mention.startsWith("/")) { + // Clean path reference - no "see below" since we format like tool results const mentionPath = mention.slice(1) - return mentionPath.endsWith("/") - ? `'${mentionPath}' (see below for folder content)` - : `'${mentionPath}' (see below for file content)` + return mentionPath.endsWith("/") ? `'${mentionPath}'` : `'${mentionPath}'` } else if (mention === "problems") { return `Workspace Problems (see below for diagnostics)` } else if (mention === "git-changes") { @@ -191,32 +250,26 @@ export async function parseMentions( result = `Error fetching content: ${rawErrorMessage}` } } + // URLs still use XML format (appended to text for backwards compat) parsedText += `\n\n\n${result}\n` } else if (mention.startsWith("/")) { const mentionPath = mention.slice(1) try { - const content = await getFileOrFolderContent( + const fileResult = await getFileOrFolderContentWithMetadata( mentionPath, cwd, rooIgnoreController, showRooIgnoredFiles, - maxReadFileLine, - maxReadCharacterLimit, + fileContextTracker, ) - if (mention.endsWith("/")) { - parsedText += `\n\n\n${content}\n` - } else { - parsedText += `\n\n\n${content}\n` - if (fileContextTracker) { - await fileContextTracker.trackFileContext(mentionPath, "file_mentioned") - } - } + contentBlocks.push(fileResult) } catch (error) { - if (mention.endsWith("/")) { - parsedText += `\n\n\nError fetching content: ${error.message}\n` - } else { - parsedText += `\n\n\nError fetching content: ${error.message}\n` - } + const errorMsg = error instanceof Error ? error.message : String(error) + contentBlocks.push({ + type: mention.endsWith("/") ? "folder" : "file", + path: mentionPath, + content: `[read_file for '${mentionPath}']\nError: ${errorMsg}`, + }) } } else if (mention === "problems") { try { @@ -272,19 +325,28 @@ export async function parseMentions( } } - return { text: parsedText, mode: commandMode, slashCommandHelp: slashCommandHelp.trim() || undefined } + return { + text: parsedText, + contentBlocks, + mode: commandMode, + slashCommandHelp: slashCommandHelp.trim() || undefined, + } } -async function getFileOrFolderContent( +/** + * Gets file or folder content and returns it as a MentionContentBlock + * formatted to look like a read_file tool result. + */ +async function getFileOrFolderContentWithMetadata( mentionPath: string, cwd: string, rooIgnoreController?: any, showRooIgnoredFiles: boolean = false, - maxReadFileLine?: number, - maxReadCharacterLimit?: number, -): Promise { + fileContextTracker?: FileContextTracker, +): Promise { const unescapedPath = unescapeSpaces(mentionPath) const absPath = path.resolve(cwd, unescapedPath) + const isFolder = mentionPath.endsWith("/") try { const stats = await fs.stat(absPath) @@ -294,25 +356,56 @@ async function getFileOrFolderContent( // Image mentions are handled separately via image attachment flow. const isBinary = await isBinaryFileWithEncodingDetection(absPath).catch(() => false) if (isBinary) { - return `(Binary file ${mentionPath} omitted)` + return { + type: "file", + path: mentionPath, + content: `[read_file for '${mentionPath}']\nNote: Binary file omitted from context.`, + } } if (rooIgnoreController && !rooIgnoreController.validateAccess(unescapedPath)) { - return `(File ${mentionPath} is ignored by .rooignore)` + return { + type: "file", + path: mentionPath, + content: `[read_file for '${mentionPath}']\nNote: File is ignored by .rooignore.`, + } } try { - const content = await extractTextFromFile(absPath, maxReadFileLine, maxReadCharacterLimit) - return content + const result = await extractTextFromFileWithMetadata(absPath) + + // Track file context + if (fileContextTracker) { + await fileContextTracker.trackFileContext(mentionPath, "file_mentioned") + } + + return { + type: "file", + path: mentionPath, + content: formatFileReadResult(mentionPath, result), + metadata: { + totalLines: result.totalLines, + returnedLines: result.returnedLines, + wasTruncated: result.wasTruncated, + linesShown: result.linesShown, + }, + } } catch (error) { - return `(Failed to read contents of ${mentionPath}): ${error.message}` + const errorMsg = error instanceof Error ? error.message : String(error) + return { + type: "file", + path: mentionPath, + content: `[read_file for '${mentionPath}']\nError: ${errorMsg}`, + } } } else if (stats.isDirectory()) { const entries = await fs.readdir(absPath, { withFileTypes: true }) - let folderContent = "" - const fileContentPromises: Array< - (maxReadFileLine?: number, maxReadCharacterLimit?: number) => Promise - > = [] + let folderListing = "" + const fileReadResults: string[] = [] const LOCK_SYMBOL = "🔒" - + // Track limits to prevent context window explosion + let filesRead = 0 + let totalContentSize = 0 + let limitReached: "files" | "size" | null = null + let skippedFilesCount = 0 for (let index = 0; index < entries.length; index++) { const entry = entries[index] const isLast = index === entries.length - 1 @@ -331,51 +424,87 @@ async function getFileOrFolderContent( const displayName = isIgnored ? `${LOCK_SYMBOL} ${entry.name}` : entry.name if (entry.isFile()) { - folderContent += `${linePrefix}${displayName}\n` + folderListing += `${linePrefix}${displayName}\n` if (!isIgnored) { + // Check if we've hit the file limit + if (filesRead >= MAX_FOLDER_FILES_TO_READ) { + if (!limitReached) { + limitReached = "files" + } + skippedFilesCount++ + continue + } + + // Check if we've hit the content size limit + if (totalContentSize >= MAX_FOLDER_CONTENT_SIZE) { + if (!limitReached) { + limitReached = "size" + } + skippedFilesCount++ + continue + } const filePath = path.join(mentionPath, entry.name) const absoluteFilePath = path.resolve(absPath, entry.name) - fileContentPromises.push(async (maxReadFileLine?: number, maxReadCharacterLimit?: number) => { - try { - const isBinary = await isBinaryFileWithEncodingDetection(absoluteFilePath) - if (isBinary) { - return undefined + try { + const isBinary = await isBinaryFileWithEncodingDetection(absoluteFilePath).catch( + () => false, + ) + if (!isBinary) { + const result = await extractTextFromFileWithMetadata(absoluteFilePath) + const fileContent = formatFileReadResult(filePath.toPosix(), result) + + // Check if adding this file would exceed the size limit + if (totalContentSize + fileContent.length > MAX_FOLDER_CONTENT_SIZE) { + if (!limitReached) { + limitReached = "size" + } + skippedFilesCount++ + continue } - const content = await extractTextFromFile( - absoluteFilePath, - maxReadFileLine, - maxReadCharacterLimit, - ) - return `\n${content}\n` - } catch (error) { - return undefined + + fileReadResults.push(fileContent) + filesRead++ + totalContentSize += fileContent.length } - }) + } catch (error) { + // Skip files that can't be read + } } } else if (entry.isDirectory()) { - folderContent += `${linePrefix}${displayName}/\n` + folderListing += `${linePrefix}${displayName}/\n` } else { - folderContent += `${linePrefix}${displayName}\n` + folderListing += `${linePrefix}${displayName}\n` } } - const fileContentPromisesCount = fileContentPromises.length - const [_maxReadFileLine, _maxReadCharacterLimit] = [ - maxReadFileLine != null && maxReadFileLine > 0 && fileContentPromisesCount > 0 - ? Math.max(250, Math.ceil(maxReadFileLine / fileContentPromisesCount)) - : maxReadFileLine, - maxReadCharacterLimit != null && maxReadCharacterLimit > 0 && fileContentPromisesCount > 0 - ? Math.max(20_000, Math.ceil(maxReadCharacterLimit / fileContentPromisesCount)) - : maxReadCharacterLimit, - ] - const fileContents = ( - await Promise.all(fileContentPromises.map((cb) => cb(_maxReadFileLine, _maxReadCharacterLimit))) - ).filter((content) => content) - return `${folderContent}\n${fileContents.join("\n\n")}`.trim() + + // Format folder content similar to read_file output + let content = `[read_file for folder '${mentionPath}']\nFolder listing:\n${folderListing}` + if (fileReadResults.length > 0) { + content += `\n\n--- File Contents ---\n\n${fileReadResults.join("\n\n")}` + } + // Add truncation notice if limits were hit + if (limitReached) { + const limitMessage = + limitReached === "files" + ? `\n\n--- Content Truncated ---\nNote: Only ${MAX_FOLDER_FILES_TO_READ} files were read to prevent context window overflow. ${skippedFilesCount} additional file(s) were skipped.\nTo read specific files, use individual @file mentions instead of @folder.` + : `\n\n--- Content Truncated ---\nNote: Content was limited to approximately ${Math.round(MAX_FOLDER_CONTENT_SIZE / 1000)}KB to prevent context window overflow. ${skippedFilesCount} additional file(s) were skipped.\nTo read specific files, use individual @file mentions instead of @folder.` + content += limitMessage + } + return { + type: "folder", + path: mentionPath, + content, + } } else { - return `(Failed to read contents of ${mentionPath})` + return { + type: isFolder ? "folder" : "file", + path: mentionPath, + content: `[read_file for '${mentionPath}']\nError: Unable to read (not a file or directory)`, + } } } catch (error) { - throw new Error(`Failed to access path "${mentionPath}": ${error.message}`) + const errorMsg = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to access path "${mentionPath}": ${errorMsg}`) } } diff --git a/src/core/mentions/processUserContentMentions.ts b/src/core/mentions/processUserContentMentions.ts index 7c3e3022f4..47459b312f 100644 --- a/src/core/mentions/processUserContentMentions.ts +++ b/src/core/mentions/processUserContentMentions.ts @@ -1,5 +1,5 @@ import { Anthropic } from "@anthropic-ai/sdk" -import { parseMentions, ParseMentionsResult } from "./index" +import { parseMentions, ParseMentionsResult, MentionContentBlock } from "./index" import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher" import { FileContextTracker } from "../context-tracking/FileContextTracker" import { Task } from "../task/Task" @@ -10,7 +10,23 @@ export interface ProcessUserContentMentionsResult { } /** - * Process mentions in user content, specifically within task and feedback tags + * Converts MentionContentBlocks to Anthropic text blocks. + * Each file/folder mention becomes a separate text block formatted + * to look like a read_file tool result. + */ +function contentBlocksToAnthropicBlocks(contentBlocks: MentionContentBlock[]): Anthropic.Messages.TextBlockParam[] { + return contentBlocks.map((block) => ({ + type: "text" as const, + text: block.content, + })) +} + +/** + * Process mentions in user content, specifically within task and feedback tags. + * + * File/folder @ mentions are now returned as separate text blocks that + * look like read_file tool results, making it clear to the model that + * the file has already been read. */ export async function processUserContentMentions({ userContent, @@ -22,8 +38,6 @@ export async function processUserContentMentions({ showRooIgnoredFiles = false, includeDiagnosticMessages = true, maxDiagnosticMessages = 50, - maxReadFileLine, - maxReadCharacterLimit, }: { userContent: Anthropic.Messages.ContentBlockParam[] cwd: string @@ -34,8 +48,6 @@ export async function processUserContentMentions({ cline?: Task includeDiagnosticMessages?: boolean maxDiagnosticMessages?: number - maxReadFileLine?: number - maxReadCharacterLimit?: number }): Promise { // Track the first mode found from slash commands let commandMode: string | undefined @@ -63,19 +75,28 @@ export async function processUserContentMentions({ showRooIgnoredFiles, includeDiagnosticMessages, maxDiagnosticMessages, - maxReadFileLine, - maxReadCharacterLimit, ) // Capture the first mode found if (!commandMode && result.mode) { commandMode = result.mode } + + // Build the blocks array: + // 1. User's text (with @ mentions replaced by clean paths) + // 2. File/folder content blocks (formatted like read_file results) + // 3. Slash command help (if any) const blocks: Anthropic.Messages.ContentBlockParam[] = [ { ...block, text: result.text, }, ] + + // Add file/folder content as separate blocks + if (result.contentBlocks.length > 0) { + blocks.push(...contentBlocksToAnthropicBlocks(result.contentBlocks)) + } + if (result.slashCommandHelp) { blocks.push({ type: "text" as const, @@ -98,30 +119,38 @@ export async function processUserContentMentions({ showRooIgnoredFiles, includeDiagnosticMessages, maxDiagnosticMessages, - maxReadFileLine, ) // Capture the first mode found if (!commandMode && result.mode) { commandMode = result.mode } + + // Build content array with file blocks included + const contentParts: Array<{ type: "text"; text: string }> = [ + { + type: "text" as const, + text: result.text, + }, + ] + + // Add file/folder content blocks + for (const contentBlock of result.contentBlocks) { + contentParts.push({ + type: "text" as const, + text: contentBlock.content, + }) + } + if (result.slashCommandHelp) { - return { - ...block, - content: [ - { - type: "text" as const, - text: result.text, - }, - { - type: "text" as const, - text: result.slashCommandHelp, - }, - ], - } + contentParts.push({ + type: "text" as const, + text: result.slashCommandHelp, + }) } + return { ...block, - content: result.text, + content: contentParts, } } @@ -140,18 +169,28 @@ export async function processUserContentMentions({ showRooIgnoredFiles, includeDiagnosticMessages, maxDiagnosticMessages, - maxReadFileLine, ) // Capture the first mode found if (!commandMode && result.mode) { commandMode = result.mode } - const blocks = [ + + // Build blocks array with file content + const blocks: Array<{ type: "text"; text: string }> = [ { ...contentBlock, text: result.text, }, ] + + // Add file/folder content blocks + for (const cb of result.contentBlocks) { + blocks.push({ + type: "text" as const, + text: cb.content, + }) + } + if (result.slashCommandHelp) { blocks.push({ type: "text" as const, 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 41c41cf56a..bf77af8dde 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 @@ -14,18 +14,19 @@ You have access to a set of tools that are executed upon the user's approval. Us # Tool Use Guidelines -1. Assess what information you have and what you need to proceed -2. Choose the most appropriate tool based on the task -3. Use tools as needed - you may use multiple tools in one message or iteratively across messages -4. Each tool use should be informed by previous results - do not assume outcomes +1. Assess what information you already have and what information you need to proceed with the task. +2. Choose the most appropriate tool based on the task and the tool descriptions provided. Assess if you need additional information to proceed, and which of the available tools would be most effective for gathering this information. For example using the list_files tool is more effective than running a command like `ls` in the terminal. It's critical that you think about each available tool and use the one that best fits the current step in the task. +3. If multiple actions are needed, you may use multiple tools in a single message when appropriate, or use tools iteratively across messages. Each tool use should be informed by the results of previous tool uses. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result. + +By carefully considering the user's response after tool executions, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work. ==== CAPABILITIES -- Execute CLI commands, list/read/write files, regex search, and ask follow-up questions -- Workspace directory: '/test/path' - file structure provided in environment_details -- Commands run in VSCode terminal, can be interactive or long-running +- You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search, 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 ('/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 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. ==== @@ -74,12 +75,13 @@ The Current Workspace Directory is the active VS Code project directory, and is OBJECTIVE -Work through tasks iteratively and methodically: +You accomplish a given task iteratively, breaking it down into clear steps and working through them methodically. -1. Analyze the task and set clear, prioritized goals -2. Use tools sequentially to accomplish each goal -3. Use attempt_completion to present final results -4. Incorporate feedback if provided, but avoid pointless back-and-forth +1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. +2. Work through these goals sequentially, utilizing available tools one at a time as necessary. Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go. +3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. +4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. +5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance. ==== @@ -89,7 +91,7 @@ USER'S CUSTOM INSTRUCTIONS The following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines. Language Preference: -You should always speak and think in the "en" language. +You should always speak and think in the "zh-CN" language. Mode-specific Instructions: 1. Do some information gathering (using provided tools) to get more context about the task. 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 33254efc44..4c0f0a3e0b 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 @@ -14,18 +14,19 @@ You have access to a set of tools that are executed upon the user's approval. Us # Tool Use Guidelines -1. Assess what information you have and what you need to proceed -2. Choose the most appropriate tool based on the task -3. Use tools as needed - you may use multiple tools in one message or iteratively across messages -4. Each tool use should be informed by previous results - do not assume outcomes +1. Assess what information you already have and what information you need to proceed with the task. +2. Choose the most appropriate tool based on the task and the tool descriptions provided. Assess if you need additional information to proceed, and which of the available tools would be most effective for gathering this information. For example using the list_files tool is more effective than running a command like `ls` in the terminal. It's critical that you think about each available tool and use the one that best fits the current step in the task. +3. If multiple actions are needed, you may use multiple tools in a single message when appropriate, or use tools iteratively across messages. Each tool use should be informed by the results of previous tool uses. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result. + +By carefully considering the user's response after tool executions, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work. ==== CAPABILITIES -- Execute CLI commands, list/read/write files, regex search, and ask follow-up questions -- Workspace directory: '/test/path' - file structure provided in environment_details -- Commands run in VSCode terminal, can be interactive or long-running +- You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search, 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 ('/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 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. ==== @@ -74,12 +75,13 @@ The Current Workspace Directory is the active VS Code project directory, and is OBJECTIVE -Work through tasks iteratively and methodically: +You accomplish a given task iteratively, breaking it down into clear steps and working through them methodically. -1. Analyze the task and set clear, prioritized goals -2. Use tools sequentially to accomplish each goal -3. Use attempt_completion to present final results -4. Incorporate feedback if provided, but avoid pointless back-and-forth +1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. +2. Work through these goals sequentially, utilizing available tools one at a time as necessary. Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go. +3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. +4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. +5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance. ==== @@ -89,7 +91,7 @@ USER'S CUSTOM INSTRUCTIONS The following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines. Language Preference: -You should always speak and think in the "en" language. +You should always speak and think in the "zh-CN" language. Mode-specific Instructions: You can analyze code, explain concepts, and access external resources. Always answer the user's questions thoroughly, and do not switch to implementing code unless explicitly requested by the user. Include Mermaid diagrams when they clarify your response. 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 0e47c0a84b..18b20485c2 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 @@ -14,18 +14,19 @@ You have access to a set of tools that are executed upon the user's approval. Us # Tool Use Guidelines -1. Assess what information you have and what you need to proceed -2. Choose the most appropriate tool based on the task -3. Use tools as needed - you may use multiple tools in one message or iteratively across messages -4. Each tool use should be informed by previous results - do not assume outcomes +1. Assess what information you already have and what information you need to proceed with the task. +2. Choose the most appropriate tool based on the task and the tool descriptions provided. Assess if you need additional information to proceed, and which of the available tools would be most effective for gathering this information. For example using the list_files tool is more effective than running a command like `ls` in the terminal. It's critical that you think about each available tool and use the one that best fits the current step in the task. +3. If multiple actions are needed, you may use multiple tools in a single message when appropriate, or use tools iteratively across messages. Each tool use should be informed by the results of previous tool uses. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result. + +By carefully considering the user's response after tool executions, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work. ==== CAPABILITIES -- Execute CLI commands, list/read/write files, regex search, and ask follow-up questions -- Workspace directory: '/test/path' - file structure provided in environment_details -- Commands run in VSCode terminal, can be interactive or long-running +- You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search, 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 ('/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 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. ==== @@ -74,12 +75,13 @@ The Current Workspace Directory is the active VS Code project directory, and is OBJECTIVE -Work through tasks iteratively and methodically: +You accomplish a given task iteratively, breaking it down into clear steps and working through them methodically. -1. Analyze the task and set clear, prioritized goals -2. Use tools sequentially to accomplish each goal -3. Use attempt_completion to present final results -4. Incorporate feedback if provided, but avoid pointless back-and-forth +1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. +2. Work through these goals sequentially, utilizing available tools one at a time as necessary. Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go. +3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. +4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. +5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance. ==== @@ -89,7 +91,7 @@ USER'S CUSTOM INSTRUCTIONS The following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines. Language Preference: -You should always speak and think in the "en" language. +You should always speak and think in the "zh-CN" language. Rules: # Rules from .clinerules-code: 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 deleted file mode 100644 index 0e47c0a84b..0000000000 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/partial-reads-enabled.snap +++ /dev/null @@ -1,98 +0,0 @@ -You are CoStrict, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices. - -==== - -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 attempt_completion - -==== - -TOOL USE - -You have access to a set of tools that are executed upon the user's approval. Use the provider-native tool-calling mechanism. Do not include XML markup or examples. You must call at least one tool per assistant response. Prefer calling as many tools as are reasonably needed in a single response to reduce back-and-forth and complete tasks faster. - -# Tool Use Guidelines - -1. Assess what information you have and what you need to proceed -2. Choose the most appropriate tool based on the task -3. Use tools as needed - you may use multiple tools in one message or iteratively across messages -4. Each tool use should be informed by previous results - do not assume outcomes - -==== - -CAPABILITIES - -- Execute CLI commands, list/read/write files, regex search, and ask follow-up questions -- Workspace directory: '/test/path' - file structure provided in environment_details -- Commands run in VSCode terminal, can be interactive or long-running - -==== - -MODES - -- Test modes section - -==== - -RULES - -- The project base directory is: /test/path -- All file paths must be relative to this directory. However, commands may change directories in terminals, so respect working directory specified by the response to execute_command. -- 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)`. -- 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$" -- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices. -- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again. -- You are only allowed to ask the user questions using the ask_followup_question tool. Use this tool only when you need additional details to complete a task, and be sure to use a clear and concise question that will help you move forward with the task. When you ask a question, provide the user with 2-4 suggested answers based on your question so they don't need to do so much typing. The suggestions should be specific, actionable, and directly related to the completed task. They should be ordered by priority or logical sequence. However if you can use the available tools to avoid having to ask the user questions, you should do so. For example, if the user mentions a file that may be in an outside directory like the Desktop, you should use the list_files tool to list the files in the Desktop and check if the file they are talking about is there, rather than asking the user to provide the file path themselves. -- 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. -- NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user. -- You are STRICTLY FORBIDDEN from starting your messages with "Great", "Certainly", "Okay", "Sure". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say "Great, I've updated the CSS" but instead something like "I've updated the CSS". It is important you be clear and technical in your messages. -- When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task. -- At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the project structure and environment. While this information can be valuable for understanding the project context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details. -- 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. - -==== - -SYSTEM INFORMATION - -Operating System: Linux -Default Shell: /bin/zsh -Home Directory: /home/user -Current Workspace Directory: /test/path - -The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. New terminals will be created in the current workspace directory, however if you change directories in a terminal it will then have a different working directory; changing directories in a terminal does not modify the workspace directory, because you do not have access to change the workspace directory. 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. - -==== - -OBJECTIVE - -Work through tasks iteratively and methodically: - -1. Analyze the task and set clear, prioritized goals -2. Use tools sequentially to accomplish each goal -3. Use attempt_completion to present final results -4. Incorporate feedback if provided, but avoid pointless back-and-forth - - -==== - -USER'S CUSTOM INSTRUCTIONS - -The following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines. - -Language Preference: -You should always speak and think in the "en" language. - -Rules: -# Rules from .clinerules-code: -Mock mode-specific rules -# Rules from .clinerules: -Mock generic rules \ No newline at end of file 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 c755bd8eab..8148d8448a 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 @@ -14,18 +14,19 @@ You have access to a set of tools that are executed upon the user's approval. Us # Tool Use Guidelines -1. Assess what information you have and what you need to proceed -2. Choose the most appropriate tool based on the task -3. Use tools as needed - you may use multiple tools in one message or iteratively across messages -4. Each tool use should be informed by previous results - do not assume outcomes +1. Assess what information you already have and what information you need to proceed with the task. +2. Choose the most appropriate tool based on the task and the tool descriptions provided. Assess if you need additional information to proceed, and which of the available tools would be most effective for gathering this information. For example using the list_files tool is more effective than running a command like `ls` in the terminal. It's critical that you think about each available tool and use the one that best fits the current step in the task. +3. If multiple actions are needed, you may use multiple tools in a single message when appropriate, or use tools iteratively across messages. Each tool use should be informed by the results of previous tool uses. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result. + +By carefully considering the user's response after tool executions, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work. ==== CAPABILITIES -- Execute CLI commands, list/read/write files, regex search, and ask follow-up questions -- Workspace directory: '/test/path' - file structure provided in environment_details -- Commands run in VSCode terminal, can be interactive or long-running +- You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search, 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 ('/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 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. ==== @@ -74,12 +75,13 @@ The Current Workspace Directory is the active VS Code project directory, and is OBJECTIVE -Work through tasks iteratively and methodically: +You accomplish a given task iteratively, breaking it down into clear steps and working through them methodically. -1. Analyze the task and set clear, prioritized goals -2. Use tools sequentially to accomplish each goal -3. Use attempt_completion to present final results -4. Incorporate feedback if provided, but avoid pointless back-and-forth +1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. +2. Work through these goals sequentially, utilizing available tools one at a time as necessary. Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go. +3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. +4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. +5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance. ==== @@ -89,7 +91,7 @@ USER'S CUSTOM INSTRUCTIONS The following additional instructions are provided by the user, and should be followed to the best of your ability. Language Preference: -You should always speak and think in the "en" language. +You should always speak and think in the "zh-CN" language. Rules: # Rules from .clinerules-code: 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 c755bd8eab..8148d8448a 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 @@ -14,18 +14,19 @@ You have access to a set of tools that are executed upon the user's approval. Us # Tool Use Guidelines -1. Assess what information you have and what you need to proceed -2. Choose the most appropriate tool based on the task -3. Use tools as needed - you may use multiple tools in one message or iteratively across messages -4. Each tool use should be informed by previous results - do not assume outcomes +1. Assess what information you already have and what information you need to proceed with the task. +2. Choose the most appropriate tool based on the task and the tool descriptions provided. Assess if you need additional information to proceed, and which of the available tools would be most effective for gathering this information. For example using the list_files tool is more effective than running a command like `ls` in the terminal. It's critical that you think about each available tool and use the one that best fits the current step in the task. +3. If multiple actions are needed, you may use multiple tools in a single message when appropriate, or use tools iteratively across messages. Each tool use should be informed by the results of previous tool uses. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result. + +By carefully considering the user's response after tool executions, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work. ==== CAPABILITIES -- Execute CLI commands, list/read/write files, regex search, and ask follow-up questions -- Workspace directory: '/test/path' - file structure provided in environment_details -- Commands run in VSCode terminal, can be interactive or long-running +- You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search, 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 ('/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 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. ==== @@ -74,12 +75,13 @@ The Current Workspace Directory is the active VS Code project directory, and is OBJECTIVE -Work through tasks iteratively and methodically: +You accomplish a given task iteratively, breaking it down into clear steps and working through them methodically. -1. Analyze the task and set clear, prioritized goals -2. Use tools sequentially to accomplish each goal -3. Use attempt_completion to present final results -4. Incorporate feedback if provided, but avoid pointless back-and-forth +1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. +2. Work through these goals sequentially, utilizing available tools one at a time as necessary. Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go. +3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. +4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. +5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance. ==== @@ -89,7 +91,7 @@ USER'S CUSTOM INSTRUCTIONS The following additional instructions are provided by the user, and should be followed to the best of your ability. Language Preference: -You should always speak and think in the "en" language. +You should always speak and think in the "zh-CN" language. Rules: # Rules from .clinerules-code: 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 c755bd8eab..8148d8448a 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 @@ -14,18 +14,19 @@ You have access to a set of tools that are executed upon the user's approval. Us # Tool Use Guidelines -1. Assess what information you have and what you need to proceed -2. Choose the most appropriate tool based on the task -3. Use tools as needed - you may use multiple tools in one message or iteratively across messages -4. Each tool use should be informed by previous results - do not assume outcomes +1. Assess what information you already have and what information you need to proceed with the task. +2. Choose the most appropriate tool based on the task and the tool descriptions provided. Assess if you need additional information to proceed, and which of the available tools would be most effective for gathering this information. For example using the list_files tool is more effective than running a command like `ls` in the terminal. It's critical that you think about each available tool and use the one that best fits the current step in the task. +3. If multiple actions are needed, you may use multiple tools in a single message when appropriate, or use tools iteratively across messages. Each tool use should be informed by the results of previous tool uses. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result. + +By carefully considering the user's response after tool executions, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work. ==== CAPABILITIES -- Execute CLI commands, list/read/write files, regex search, and ask follow-up questions -- Workspace directory: '/test/path' - file structure provided in environment_details -- Commands run in VSCode terminal, can be interactive or long-running +- You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search, 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 ('/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 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. ==== @@ -74,12 +75,13 @@ The Current Workspace Directory is the active VS Code project directory, and is OBJECTIVE -Work through tasks iteratively and methodically: +You accomplish a given task iteratively, breaking it down into clear steps and working through them methodically. -1. Analyze the task and set clear, prioritized goals -2. Use tools sequentially to accomplish each goal -3. Use attempt_completion to present final results -4. Incorporate feedback if provided, but avoid pointless back-and-forth +1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. +2. Work through these goals sequentially, utilizing available tools one at a time as necessary. Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go. +3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. +4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. +5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance. ==== @@ -89,7 +91,7 @@ USER'S CUSTOM INSTRUCTIONS The following additional instructions are provided by the user, and should be followed to the best of your ability. Language Preference: -You should always speak and think in the "en" language. +You should always speak and think in the "zh-CN" language. Rules: # Rules from .clinerules-code: 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 98c27af56b..9df85f44b2 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 @@ -14,19 +14,21 @@ You have access to a set of tools that are executed upon the user's approval. Us # Tool Use Guidelines -1. Assess what information you have and what you need to proceed -2. Choose the most appropriate tool based on the task -3. Use tools as needed - you may use multiple tools in one message or iteratively across messages -4. Each tool use should be informed by previous results - do not assume outcomes +1. Assess what information you already have and what information you need to proceed with the task. +2. Choose the most appropriate tool based on the task and the tool descriptions provided. Assess if you need additional information to proceed, and which of the available tools would be most effective for gathering this information. For example using the list_files tool is more effective than running a command like `ls` in the terminal. It's critical that you think about each available tool and use the one that best fits the current step in the task. +3. If multiple actions are needed, you may use multiple tools in a single message when appropriate, or use tools iteratively across messages. Each tool use should be informed by the results of previous tool uses. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result. + +By carefully considering the user's response after tool executions, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work. ==== CAPABILITIES -- Execute CLI commands, list/read/write files, regex search, and ask follow-up questions -- Workspace directory: '/test/path' - file structure provided in environment_details -- Commands run in VSCode terminal, can be interactive or long-running -- Access to MCP servers for additional tools and resources +- You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search, 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 ('/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 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. + ==== @@ -75,12 +77,13 @@ The Current Workspace Directory is the active VS Code project directory, and is OBJECTIVE -Work through tasks iteratively and methodically: +You accomplish a given task iteratively, breaking it down into clear steps and working through them methodically. -1. Analyze the task and set clear, prioritized goals -2. Use tools sequentially to accomplish each goal -3. Use attempt_completion to present final results -4. Incorporate feedback if provided, but avoid pointless back-and-forth +1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. +2. Work through these goals sequentially, utilizing available tools one at a time as necessary. Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go. +3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. +4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. +5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance. ==== @@ -90,7 +93,7 @@ USER'S CUSTOM INSTRUCTIONS The following additional instructions are provided by the user, and should be followed to the best of your ability. Language Preference: -You should always speak and think in the "en" language. +You should always speak and think in the "zh-CN" language. Rules: # Rules from .clinerules-code: 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 c755bd8eab..8148d8448a 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 @@ -14,18 +14,19 @@ You have access to a set of tools that are executed upon the user's approval. Us # Tool Use Guidelines -1. Assess what information you have and what you need to proceed -2. Choose the most appropriate tool based on the task -3. Use tools as needed - you may use multiple tools in one message or iteratively across messages -4. Each tool use should be informed by previous results - do not assume outcomes +1. Assess what information you already have and what information you need to proceed with the task. +2. Choose the most appropriate tool based on the task and the tool descriptions provided. Assess if you need additional information to proceed, and which of the available tools would be most effective for gathering this information. For example using the list_files tool is more effective than running a command like `ls` in the terminal. It's critical that you think about each available tool and use the one that best fits the current step in the task. +3. If multiple actions are needed, you may use multiple tools in a single message when appropriate, or use tools iteratively across messages. Each tool use should be informed by the results of previous tool uses. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result. + +By carefully considering the user's response after tool executions, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work. ==== CAPABILITIES -- Execute CLI commands, list/read/write files, regex search, and ask follow-up questions -- Workspace directory: '/test/path' - file structure provided in environment_details -- Commands run in VSCode terminal, can be interactive or long-running +- You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search, 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 ('/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 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. ==== @@ -74,12 +75,13 @@ The Current Workspace Directory is the active VS Code project directory, and is OBJECTIVE -Work through tasks iteratively and methodically: +You accomplish a given task iteratively, breaking it down into clear steps and working through them methodically. -1. Analyze the task and set clear, prioritized goals -2. Use tools sequentially to accomplish each goal -3. Use attempt_completion to present final results -4. Incorporate feedback if provided, but avoid pointless back-and-forth +1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. +2. Work through these goals sequentially, utilizing available tools one at a time as necessary. Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go. +3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. +4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. +5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance. ==== @@ -89,7 +91,7 @@ USER'S CUSTOM INSTRUCTIONS The following additional instructions are provided by the user, and should be followed to the best of your ability. Language Preference: -You should always speak and think in the "en" language. +You should always speak and think in the "zh-CN" language. Rules: # Rules from .clinerules-code: diff --git a/src/core/prompts/__tests__/add-custom-instructions.spec.ts b/src/core/prompts/__tests__/add-custom-instructions.spec.ts index f692adee3b..fb9ddc8cc2 100644 --- a/src/core/prompts/__tests__/add-custom-instructions.spec.ts +++ b/src/core/prompts/__tests__/add-custom-instructions.spec.ts @@ -245,8 +245,7 @@ describe("addCustomInstructions", () => { undefined, // customModes undefined, // globalCustomInstructions undefined, // experiments - true, // enableMcpServerCreation - "en", // language + undefined, // language undefined, // rooIgnoreInstructions undefined, // partialReadsEnabled ) @@ -267,8 +266,7 @@ describe("addCustomInstructions", () => { undefined, // customModes undefined, // globalCustomInstructions undefined, // experiments - true, // enableMcpServerCreation - "en", // language + undefined, // language undefined, // rooIgnoreInstructions undefined, // partialReadsEnabled ) @@ -291,8 +289,7 @@ describe("addCustomInstructions", () => { undefined, // customModes, undefined, // globalCustomInstructions undefined, // experiments - false, // enableMcpServerCreation - "en", // language + undefined, // language undefined, // rooIgnoreInstructions undefined, // partialReadsEnabled ) @@ -301,28 +298,6 @@ describe("addCustomInstructions", () => { expect(prompt).toMatchFileSnapshot("./__snapshots__/add-custom-instructions/mcp-server-creation-disabled.snap") }) - it("should include partial read instructions when partialReadsEnabled is true", async () => { - const prompt = await SYSTEM_PROMPT( - mockContext, - "/test/path", - false, // supportsImages - undefined, // mcpHub - undefined, // diffStrategy - undefined, // browserViewportSize - defaultModeSlug, // mode - undefined, // customModePrompts - undefined, // customModes, - undefined, // globalCustomInstructions - undefined, // experiments - true, // enableMcpServerCreation - "en", // language - undefined, // rooIgnoreInstructions - true, // partialReadsEnabled - ) - - expect(prompt).toMatchFileSnapshot("./__snapshots__/add-custom-instructions/partial-reads-enabled.snap") - }) - it("should prioritize mode-specific rules for code mode", async () => { const instructions = await addCustomInstructions("", "", "/test/path", defaultModeSlug) expect(instructions).toMatchFileSnapshot("./__snapshots__/add-custom-instructions/code-mode-rules.snap") diff --git a/src/core/prompts/__tests__/custom-system-prompt.spec.ts b/src/core/prompts/__tests__/custom-system-prompt.spec.ts index c1af8979a8..0d1c674cc8 100644 --- a/src/core/prompts/__tests__/custom-system-prompt.spec.ts +++ b/src/core/prompts/__tests__/custom-system-prompt.spec.ts @@ -138,7 +138,6 @@ describe("File-Based Custom System Prompt", () => { undefined, // customModes undefined, // globalCustomInstructions undefined, // experiments - true, // enableMcpServerCreation undefined, // language undefined, // rooIgnoreInstructions undefined, // partialReadsEnabled @@ -175,7 +174,6 @@ describe("File-Based Custom System Prompt", () => { undefined, // customModes undefined, // globalCustomInstructions undefined, // experiments - true, // enableMcpServerCreation undefined, // language undefined, // rooIgnoreInstructions undefined, // partialReadsEnabled @@ -220,7 +218,6 @@ describe("File-Based Custom System Prompt", () => { undefined, // customModes undefined, // globalCustomInstructions undefined, // experiments - true, // enableMcpServerCreation undefined, // language undefined, // rooIgnoreInstructions undefined, // partialReadsEnabled diff --git a/src/core/prompts/__tests__/sections.spec.ts b/src/core/prompts/__tests__/sections.spec.ts index 011b279698..dbfa7cf137 100644 --- a/src/core/prompts/__tests__/sections.spec.ts +++ b/src/core/prompts/__tests__/sections.spec.ts @@ -70,7 +70,6 @@ describe("getRulesSection", () => { it("includes vendor confidentiality section when isStealthModel is true", () => { const settings = { - maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true, newTaskRequireTodos: false, @@ -88,7 +87,6 @@ describe("getRulesSection", () => { it("excludes vendor confidentiality section when isStealthModel is false", () => { const settings = { - maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true, newTaskRequireTodos: false, @@ -103,7 +101,6 @@ describe("getRulesSection", () => { it("excludes vendor confidentiality section when isStealthModel is undefined", () => { const settings = { - maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true, newTaskRequireTodos: false, diff --git a/src/core/prompts/__tests__/system-prompt.spec.ts b/src/core/prompts/__tests__/system-prompt.spec.ts index 5f027fd88e..25c07ed9e5 100644 --- a/src/core/prompts/__tests__/system-prompt.spec.ts +++ b/src/core/prompts/__tests__/system-prompt.spec.ts @@ -261,10 +261,8 @@ describe("SYSTEM_PROMPT", () => { undefined, // customModes undefined, // globalCustomInstructions experiments, - true, // enableMcpServerCreation - "en", // language + undefined, // language undefined, // rooIgnoreInstructions - undefined, // partialReadsEnabled ) expect(prompt).toMatchFileSnapshot("./__snapshots__/system-prompt/consistent-system-prompt.snap") @@ -283,10 +281,8 @@ describe("SYSTEM_PROMPT", () => { undefined, // customModes, undefined, // globalCustomInstructions experiments, - true, // enableMcpServerCreation - "en", // language + undefined, // language undefined, // rooIgnoreInstructions - undefined, // partialReadsEnabled ) expect(prompt).toMatchFileSnapshot("./__snapshots__/system-prompt/with-computer-use-support.snap") @@ -307,10 +303,8 @@ describe("SYSTEM_PROMPT", () => { undefined, // customModes, undefined, // globalCustomInstructions experiments, - true, // enableMcpServerCreation - "en", // language + undefined, // language undefined, // rooIgnoreInstructions - undefined, // partialReadsEnabled ) expect(prompt).toMatchFileSnapshot("./__snapshots__/system-prompt/with-mcp-hub-provided.snap") @@ -329,10 +323,8 @@ describe("SYSTEM_PROMPT", () => { undefined, // customModes, undefined, // globalCustomInstructions experiments, - true, // enableMcpServerCreation - "en", // language + undefined, // language undefined, // rooIgnoreInstructions - undefined, // partialReadsEnabled ) expect(prompt).toMatchFileSnapshot("./__snapshots__/system-prompt/with-undefined-mcp-hub.snap") @@ -351,14 +343,13 @@ describe("SYSTEM_PROMPT", () => { undefined, // customModes, undefined, // globalCustomInstructions experiments, - true, // enableMcpServerCreation - "en", // language + undefined, // language undefined, // rooIgnoreInstructions - undefined, // partialReadsEnabled ) expect(prompt).toMatchFileSnapshot("./__snapshots__/system-prompt/with-different-viewport-size.snap") }) + it("should include vscode language in custom instructions", async () => { // Mock vscode.env.language resetLanguageCache() @@ -401,10 +392,8 @@ describe("SYSTEM_PROMPT", () => { undefined, // customModes undefined, // globalCustomInstructions undefined, // experiments - true, // enableMcpServerCreation undefined, // language undefined, // rooIgnoreInstructions - undefined, // partialReadsEnabled ) expect(prompt).toContain("Language Preference:") @@ -463,10 +452,8 @@ describe("SYSTEM_PROMPT", () => { customModes, // customModes "Global instructions", // globalCustomInstructions experiments, - true, // enableMcpServerCreation - "en", // language + undefined, // language undefined, // rooIgnoreInstructions - undefined, // partialReadsEnabled ) // Role definition should be at the top @@ -500,10 +487,8 @@ describe("SYSTEM_PROMPT", () => { undefined, // customModes undefined, // globalCustomInstructions undefined, // experiments - false, // enableMcpServerCreation - "en", // language + undefined, // language undefined, // rooIgnoreInstructions - undefined, // partialReadsEnabled ) // Role definition from promptComponent should be at the top @@ -532,10 +517,8 @@ describe("SYSTEM_PROMPT", () => { undefined, // customModes undefined, // globalCustomInstructions undefined, // experiments - false, // enableMcpServerCreation - "en", // language + undefined, // language undefined, // rooIgnoreInstructions - undefined, // partialReadsEnabled ) // Should use the default mode's role definition @@ -544,7 +527,6 @@ describe("SYSTEM_PROMPT", () => { it("should exclude update_todo_list tool when todoListEnabled is false", async () => { const settings = { - maxConcurrentFileReads: 5, todoListEnabled: false, useAgentRules: true, newTaskRequireTodos: false, @@ -562,10 +544,8 @@ describe("SYSTEM_PROMPT", () => { undefined, // customModes undefined, // globalCustomInstructions experiments, - true, // enableMcpServerCreation - "en", // language + undefined, // language undefined, // rooIgnoreInstructions - undefined, // partialReadsEnabled settings, // settings ) @@ -576,7 +556,6 @@ describe("SYSTEM_PROMPT", () => { it("should include update_todo_list tool when todoListEnabled is true", async () => { const settings = { - maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true, newTaskRequireTodos: false, @@ -594,10 +573,8 @@ describe("SYSTEM_PROMPT", () => { undefined, // customModes undefined, // globalCustomInstructions experiments, - true, // enableMcpServerCreation - "en", // language + undefined, // language undefined, // rooIgnoreInstructions - undefined, // partialReadsEnabled settings, // settings ) @@ -608,7 +585,6 @@ describe("SYSTEM_PROMPT", () => { it("should include update_todo_list tool when todoListEnabled is undefined", async () => { const settings = { - maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true, newTaskRequireTodos: false, @@ -626,10 +602,8 @@ describe("SYSTEM_PROMPT", () => { undefined, // customModes undefined, // globalCustomInstructions experiments, - true, // enableMcpServerCreation - "en", // language + undefined, // language undefined, // rooIgnoreInstructions - undefined, // partialReadsEnabled settings, // settings ) @@ -640,7 +614,6 @@ describe("SYSTEM_PROMPT", () => { it("should include native tool instructions", async () => { const settings = { - maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true, newTaskRequireTodos: false, @@ -658,10 +631,8 @@ describe("SYSTEM_PROMPT", () => { undefined, // customModes undefined, // globalCustomInstructions experiments, - true, // enableMcpServerCreation undefined, // language undefined, // rooIgnoreInstructions - undefined, // partialReadsEnabled settings, // settings ) @@ -693,6 +664,7 @@ describe("SYSTEM_PROMPT", () => { expect(prompt).toContain("SYSTEM INFORMATION") expect(prompt).toContain("OBJECTIVE") }) + afterAll(() => { vi.restoreAllMocks() }) diff --git a/src/core/prompts/instructions/create-mode.ts b/src/core/prompts/instructions/create-mode.ts deleted file mode 100644 index 035128efaf..0000000000 --- a/src/core/prompts/instructions/create-mode.ts +++ /dev/null @@ -1,62 +0,0 @@ -import * as path from "path" -import * as vscode from "vscode" - -import { GlobalFileNames } from "../../../shared/globalFileNames" -import { getSettingsDirectoryPath } from "../../../utils/storage" - -export async function createModeInstructions(context: vscode.ExtensionContext | undefined): Promise { - if (!context) throw new Error("Missing VSCode Extension Context") - - const settingsDir = await getSettingsDirectoryPath(context.globalStorageUri.fsPath) - const customModesPath = path.join(settingsDir, GlobalFileNames.customModes) - - return ` -Custom modes can be configured in two ways: - 1. Globally via '${customModesPath}' (created automatically on startup) - 2. Per-workspace via '.roomodes' in the workspace root directory - -When modes with the same slug exist in both files, the workspace-specific .roomodes version takes precedence. This allows projects to override global modes or define project-specific modes. - -If asked to create a project mode, create it in .roomodes in the workspace root. If asked to create a global mode, use the global custom modes file. - -- The following fields are required and must not be empty: - * slug: A valid slug (lowercase letters, numbers, and hyphens). Must be unique, and shorter is better. - * name: The display name for the mode - * roleDefinition: A detailed description of the mode's role and capabilities - * groups: Array of allowed tool groups (can be empty). Each group can be specified either as a string (e.g., "edit" to allow editing any file) or with file restrictions (e.g., ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }] to only allow editing markdown files) - -- The following fields are optional but highly recommended: - * description: A short, human-readable description of what this mode does (5 words) - * whenToUse: A clear description of when this mode should be selected and what types of tasks it's best suited for. This helps the Orchestrator mode make better decisions. - * customInstructions: Additional instructions for how the mode should operate - -- For multi-line text, include newline characters in the string like "This is the first line.\\nThis is the next line.\\n\\nThis is a double line break." - -Both files should follow this structure (in YAML format): - -customModes: - - slug: designer # Required: unique slug with lowercase letters, numbers, and hyphens - name: Designer # Required: mode display name - description: UI/UX design systems expert # Optional but recommended: short description (5 words) - roleDefinition: >- - You are CoStrict, a UI/UX expert specializing in design systems and frontend development. Your expertise includes: - - Creating and maintaining design systems - - Implementing responsive and accessible web interfaces - - Working with CSS, HTML, and modern frontend frameworks - - Ensuring consistent user experiences across platforms # Required: non-empty - whenToUse: >- - Use this mode when creating or modifying UI components, implementing design systems, - or ensuring responsive web interfaces. This mode is especially effective with CSS, - HTML, and modern frontend frameworks. # Optional but recommended - groups: # Required: array of tool groups (can be empty) - - read # Read files group (read_file, fetch_instructions, search_files, list_files) - - edit # Edit files group (apply_diff, write_to_file) - allows editing any file - # Or with file restrictions: - # - - edit - # - fileRegex: \\.md$ - # description: Markdown files only # Edit group that only allows editing markdown files - - browser # Browser group (browser_action) - - command # Command group (execute_command) - - mcp # MCP group (use_mcp_tool, access_mcp_resource) - customInstructions: Additional instructions for the Designer mode # Optional` -} diff --git a/src/core/prompts/instructions/instructions.ts b/src/core/prompts/instructions/instructions.ts deleted file mode 100644 index c1ff2a1899..0000000000 --- a/src/core/prompts/instructions/instructions.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { createMCPServerInstructions } from "./create-mcp-server" -import { createModeInstructions } from "./create-mode" -import { McpHub } from "../../../services/mcp/McpHub" -import { DiffStrategy } from "../../../shared/tools" -import * as vscode from "vscode" - -interface InstructionsDetail { - mcpHub?: McpHub - diffStrategy?: DiffStrategy - context?: vscode.ExtensionContext -} - -export async function fetchInstructions(text: string, detail: InstructionsDetail): Promise { - switch (text) { - case "create_mcp_server": { - return await createMCPServerInstructions(detail.mcpHub, detail.diffStrategy) - } - case "create_mode": { - return await createModeInstructions(detail.context) - } - default: { - return "" - } - } -} diff --git a/src/core/prompts/sections/__tests__/custom-instructions.spec.ts b/src/core/prompts/sections/__tests__/custom-instructions.spec.ts index 149556abb5..d4d195da64 100644 --- a/src/core/prompts/sections/__tests__/custom-instructions.spec.ts +++ b/src/core/prompts/sections/__tests__/custom-instructions.spec.ts @@ -543,7 +543,6 @@ describe("addCustomInstructions", () => { "test-mode", { settings: { - maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true, newTaskRequireTodos: false, @@ -575,7 +574,6 @@ describe("addCustomInstructions", () => { "test-mode", { settings: { - maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: false, newTaskRequireTodos: false, @@ -636,7 +634,6 @@ describe("addCustomInstructions", () => { "test-mode", { settings: { - maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true, newTaskRequireTodos: false, @@ -682,7 +679,6 @@ describe("addCustomInstructions", () => { "test-mode", { settings: { - maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true, newTaskRequireTodos: false, @@ -750,7 +746,6 @@ describe("addCustomInstructions", () => { "test-mode", { settings: { - maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true, newTaskRequireTodos: false, @@ -802,7 +797,6 @@ describe("addCustomInstructions", () => { "test-mode", { settings: { - maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true, newTaskRequireTodos: false, @@ -856,7 +850,6 @@ describe("addCustomInstructions", () => { "test-mode", { settings: { - maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true, newTaskRequireTodos: false, @@ -902,7 +895,6 @@ describe("addCustomInstructions", () => { "test-mode", { settings: { - maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true, newTaskRequireTodos: false, diff --git a/src/core/prompts/sections/modes.ts b/src/core/prompts/sections/modes.ts index 6e082afaff..dc3122696b 100644 --- a/src/core/prompts/sections/modes.ts +++ b/src/core/prompts/sections/modes.ts @@ -5,18 +5,14 @@ import type { ModeConfig } from "@roo-code/types" import { getAllModesWithPrompts } from "../../../shared/modes" import { ensureSettingsDirectoryExists } from "../../../utils/globalContext" -export async function getModesSection( - context: vscode.ExtensionContext, - skipXmlExamples: boolean = false, - zgsmCodeMode?: string, -): Promise { +export async function getModesSection(context: vscode.ExtensionContext, zgsmCodeMode?: string): Promise { // Make sure path gets created await ensureSettingsDirectoryExists(context) // Get all modes with their overrides from extension state const allModes = await getAllModesWithPrompts(context) - let modesContent = `==== + const modesContent = `==== MODES @@ -40,18 +36,5 @@ ${allModes }) .join("\n")}` - 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/skills.ts b/src/core/prompts/sections/skills.ts index 53ba8b95f1..39cfca405b 100644 --- a/src/core/prompts/sections/skills.ts +++ b/src/core/prompts/sections/skills.ts @@ -33,10 +33,11 @@ export async function getSkillsSection( .map((skill) => { const name = escapeXml(skill.name) const description = escapeXml(skill.description) - // Per the Agent Skills integration guidance for filesystem-based agents, - // location should be an absolute path to the SKILL.md file. - const location = escapeXml(skill.path) - return ` \n ${name}\n ${description}\n ${location}\n ` + // Only include location for file-based skills (not built-in) + // Built-in skills are loaded via the skill tool by name, not by path + const isFileBasedSkill = skill.source !== "built-in" && skill.path !== "built-in" + const locationLine = isFileBasedSkill ? `\n ${escapeXml(skill.path)}` : "" + return ` \n ${name}\n ${description}${locationLine}\n ` }) .join("\n") @@ -62,9 +63,9 @@ Step 2: Branching Decision - Select EXACTLY ONE skill. - Prefer the most specific skill when multiple skills match. -- Read the full SKILL.md file at the skill's . -- Load the SKILL.md contents fully into context BEFORE continuing. -- Follow the SKILL.md instructions precisely. +- Use the skill tool to load the skill by name. +- Load the skill's instructions fully into context BEFORE continuing. +- Follow the skill instructions precisely. - Do NOT respond outside the skill-defined flow. @@ -74,15 +75,15 @@ Step 2: Branching Decision CONSTRAINTS: -- Do NOT load every SKILL.md up front. -- Load SKILL.md ONLY after a skill is selected. +- Do NOT load every skill up front. +- Load skills ONLY after a skill is selected. - Do NOT skip this check. - FAILURE to perform this check is an error. -- When a SKILL.md is loaded, ONLY the contents of SKILL.md are present. -- Files linked from SKILL.md are NOT loaded automatically. +- When a skill is loaded, ONLY the skill instructions are present. +- Files linked from the skill are NOT loaded automatically. - The model MUST explicitly decide to read a linked file based on task relevance. - Do NOT assume the contents of linked files unless they have been explicitly read. - Prefer reading the minimum necessary linked file. diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 91f4bc203a..ebc011b3d0 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -62,8 +62,8 @@ async function generatePrompt(data: { enableMcpServerCreation?: boolean language?: string rooIgnoreInstructions?: string - partialReadsEnabled?: boolean - parallelToolCallsEnabled?: boolean + // partialReadsEnabled?: boolean + // parallelToolCallsEnabled?: boolean settings?: SystemPromptSettings todoList?: TodoItem[] modelId?: string @@ -86,8 +86,8 @@ async function generatePrompt(data: { enableMcpServerCreation, language, rooIgnoreInstructions, - partialReadsEnabled, - parallelToolCallsEnabled, + // partialReadsEnabled, + // parallelToolCallsEnabled, skillsManager, settings, todoList, @@ -115,7 +115,7 @@ async function generatePrompt(data: { const effectiveProtocol = "native" const [modesSection, skillsSection] = await Promise.all([ - getModesSection(context, undefined, zgsmCodeMode), + getModesSection(context, zgsmCodeMode), getSkillsSection(skillsManager, mode as string), ]) @@ -165,10 +165,8 @@ export const SYSTEM_PROMPT = async ( customModes?: ModeConfig[], globalCustomInstructions?: string, experiments?: Record, - enableMcpServerCreation?: boolean, language?: string, rooIgnoreInstructions?: string, - partialReadsEnabled?: boolean, settings?: SystemPromptSettings, todoList?: TodoItem[], modelId?: string, @@ -237,11 +235,8 @@ ${customInstructions}` customModeConfigs: customModes, globalCustomInstructions, experiments, - enableMcpServerCreation, language, rooIgnoreInstructions, - partialReadsEnabled, - parallelToolCallsEnabled, settings, todoList, modelId, diff --git a/src/core/prompts/tools/lite-descriptions.ts b/src/core/prompts/tools/lite-descriptions.ts index c34f7d1c0f..55411e7a3d 100644 --- a/src/core/prompts/tools/lite-descriptions.ts +++ b/src/core/prompts/tools/lite-descriptions.ts @@ -1,10 +1,19 @@ export function getLiteReadFileDescription(): string { return `## read_file -Read file contents with line numbers. -Params: files (REQUIRED) -- files: Array of file objects - - path (REQUIRED): File path relative to workspace - - line_ranges (optional): Array of [start, end] tuples for specific sections` +Read file contents with line numbers. Supports text extraction from PDF and DOCX files. +Two modes: +- slice (default): Read lines sequentially with offset/limit +- indentation: Extract semantic code blocks based on anchor_line +Params: +- path (REQUIRED): File path relative to workspace +- mode (optional): Reading mode - 'slice' or 'indentation' (default: 'slice') +- offset (optional): 1-based start line for slice mode (default: 1) +- limit (optional): Max lines for slice mode (default: 2000) +- indentation (optional): Object for indentation mode + - anchor_line (REQUIRED): 1-based line number to extract semantic block + - max_levels (optional): Indentation levels to include above anchor + - include_siblings (optional): Include sibling blocks (default: false) +Note: When anchor_line is known, prefer indentation mode for complete code blocks. Returns up to 2000 lines by default.` } getLiteReadFileDescription.toolname = "read_file" @@ -39,10 +48,10 @@ getLiteExecuteCommandDescription.toolname = "execute_command" export function getLiteAskFollowupQuestionDescription(): string { return `## ask_followup_question Ask user for clarification. -Params: question (required), follow_up (required) +Params: question (REQUIRED), follow_up (REQUIRED) - question: Clear, specific question addressing the information needed - follow_up: Array of 2-4 suggested responses - - text (required): Suggested answer the user can pick + - text (REQUIRED): Suggested answer the user can pick - mode (optional): Mode slug to switch to if chosen (e.g., code, architect)` } getLiteAskFollowupQuestionDescription.toolname = "ask_followup_question" @@ -78,18 +87,20 @@ getLiteNewTaskDescription.toolname = "new_task" export function getLiteUpdateTodoListDescription(): string { return `## update_todo_list Update TODO checklist. -Params: todos (required) +Params: todos (REQUIRED) - todos: Full markdown checklist in execution order Format: [ ] pending, [x] completed, [-] in progress` } getLiteUpdateTodoListDescription.toolname = "update_todo_list" -export function getLiteFetchInstructionsDescription(): string { - return `## fetch_instructions -Get task instructions. -Params: task (REQUIRED) - create_mcp_server or create_mode` +export function getLiteSkillDescription(): string { + return `## skill +Load and execute a skill by name. Skills provide specialized instructions for common tasks like creating MCP servers or custom modes. +Params: +- skill (REQUIRED): Name of the skill to load (e.g., create-mcp-server, create-mode) +- args (optional): Context or arguments to pass to the skill` } -getLiteFetchInstructionsDescription.toolname = "fetch_instructions" +getLiteSkillDescription.toolname = "skill" export function getLiteCodebaseSearchDescription(): string { return `## codebase_search @@ -157,7 +168,7 @@ getLiteEditFileDescription.toolname = "edit_file" export function getLiteAskMultipleChoiceDescription(): string { return `## ask_multiple_choice Ask the user to select one or more options from a list of choices. -Params: title (optional), questions (REQUIRED) +Params: title (REQUIRED), questions (REQUIRED) - questions: Array of question objects (at least 1) - id (REQUIRED): Unique identifier for the question - prompt (REQUIRED): Question text to display @@ -220,6 +231,8 @@ export const getGeminiCliLiteToolGuide = () => { # User Local Available Tools +──────────────────────────────────── + ${getLiteReadFileDescription()} ${getLiteWriteToFileDescription()} @@ -244,7 +257,7 @@ ${getLiteNewTaskDescription()} ${getLiteUpdateTodoListDescription()} -${getLiteFetchInstructionsDescription()} +${getLiteSkillDescription()} ${getLiteCodebaseSearchDescription()} @@ -266,6 +279,8 @@ ${getLiteSearchAndReplaceDescription()} ${getLiteSearchReplaceDescription()} +──────────────────────────────────── + ${xmlLiteToolGuide} ----------------------------------------- diff --git a/src/core/prompts/tools/native-tools/__tests__/converters.spec.ts b/src/core/prompts/tools/native-tools/__tests__/converters.spec.ts index 02032346b2..dfef164659 100644 --- a/src/core/prompts/tools/native-tools/__tests__/converters.spec.ts +++ b/src/core/prompts/tools/native-tools/__tests__/converters.spec.ts @@ -80,27 +80,27 @@ describe("converters", () => { const openAITool: OpenAI.Chat.ChatCompletionTool = { type: "function", function: { - name: "read_file", - description: "Read files", + name: "process_data", + description: "Process data with filters", parameters: { type: "object", properties: { - files: { + items: { type: "array", items: { type: "object", properties: { - path: { type: "string" }, - line_ranges: { + name: { type: "string" }, + tags: { type: ["array", "null"], - items: { type: "string", pattern: "^[0-9]+-[0-9]+$" }, + items: { type: "string" }, }, }, - required: ["path", "line_ranges"], + required: ["name"], }, }, }, - required: ["files"], + required: ["items"], additionalProperties: false, }, }, diff --git a/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts b/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts index 9561fe417d..dded7fba50 100644 --- a/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts +++ b/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts @@ -1,5 +1,5 @@ import type OpenAI from "openai" -import { createReadFileTool, type ReadFileToolOptions } from "../read_file" +import { createReadFileTool } from "../read_file" // Helper type to access function tools type FunctionTool = OpenAI.Chat.ChatCompletionTool & { type: "function" } @@ -8,91 +8,46 @@ type FunctionTool = OpenAI.Chat.ChatCompletionTool & { type: "function" } const getFunctionDef = (tool: OpenAI.Chat.ChatCompletionTool) => (tool as FunctionTool).function describe("createReadFileTool", () => { - describe("maxConcurrentFileReads documentation", () => { - it("should include default maxConcurrentFileReads limit (5) in description", () => { + describe("single-file-per-call documentation", () => { + it("should indicate single-file-per-call and suggest parallel tool calls", () => { const tool = createReadFileTool() const description = getFunctionDef(tool).description - expect(description).toContain("maximum of 5 files") - expect(description).toContain("If you need to read more files, use multiple sequential read_file requests") - }) - - it("should include custom maxConcurrentFileReads limit in description", () => { - const tool = createReadFileTool({ maxConcurrentFileReads: 3 }) - const description = getFunctionDef(tool).description - - expect(description).toContain("maximum of 3 files") - expect(description).toContain("within 3-file limit") - }) - - it("should indicate single file reads only when maxConcurrentFileReads is 1", () => { - const tool = createReadFileTool({ maxConcurrentFileReads: 1 }) - const description = getFunctionDef(tool).description - - expect(description).toContain("Multiple file reads are currently disabled") - expect(description).toContain("only read one file at a time") - expect(description).not.toContain("Example multiple files") - }) - - it("should use singular 'Read a file' in base description when maxConcurrentFileReads is 1", () => { - const tool = createReadFileTool({ maxConcurrentFileReads: 1 }) - const description = getFunctionDef(tool).description - - expect(description).toMatch(/^Read a file/) - expect(description).not.toContain("Read one or more files") - }) - - it("should use plural 'Read one or more files' in base description when maxConcurrentFileReads is > 1", () => { - const tool = createReadFileTool({ maxConcurrentFileReads: 5 }) - const description = getFunctionDef(tool).description - - expect(description).toMatch(/^Read one or more files/) - }) - - it("should not show multiple files example when maxConcurrentFileReads is 1", () => { - const tool = createReadFileTool({ maxConcurrentFileReads: 1, partialReadsEnabled: true }) - const description = getFunctionDef(tool).description - - expect(description).not.toContain("Example multiple files") - }) - - it("should show multiple files example when maxConcurrentFileReads is > 1", () => { - const tool = createReadFileTool({ maxConcurrentFileReads: 5, partialReadsEnabled: true }) - const description = getFunctionDef(tool).description - - expect(description).toContain("Example multiple files") + expect(description).toContain("exactly one file per call") + expect(description).toContain("multiple parallel read_file calls") }) }) - describe("partialReadsEnabled option", () => { - it("should include line_ranges in description when partialReadsEnabled is true", () => { - const tool = createReadFileTool({ partialReadsEnabled: true }) + describe("indentation mode", () => { + it("should always include indentation mode in description", () => { + const tool = createReadFileTool() const description = getFunctionDef(tool).description - expect(description).toContain("line_ranges") - expect(description).toContain("Example with line ranges") + expect(description).toContain("indentation") }) - it("should not include line_ranges in description when partialReadsEnabled is false", () => { - const tool = createReadFileTool({ partialReadsEnabled: false }) - const description = getFunctionDef(tool).description + it("should always include indentation parameter in schema", () => { + const tool = createReadFileTool() + const schema = getFunctionDef(tool).parameters as any - expect(description).not.toContain("line_ranges") - expect(description).not.toContain("Example with line ranges") + expect(schema.properties).toHaveProperty("indentation") }) - it("should include line_ranges parameter in schema when partialReadsEnabled is true", () => { - const tool = createReadFileTool({ partialReadsEnabled: true }) + it("should include mode parameter in schema", () => { + const tool = createReadFileTool() const schema = getFunctionDef(tool).parameters as any - expect(schema.properties.files.items.properties).toHaveProperty("line_ranges") + expect(schema.properties).toHaveProperty("mode") + expect(schema.properties.mode.enum).toContain("slice") + expect(schema.properties.mode.enum).toContain("indentation") }) - it("should not include line_ranges parameter in schema when partialReadsEnabled is false", () => { - const tool = createReadFileTool({ partialReadsEnabled: false }) + it("should include offset and limit parameters in schema", () => { + const tool = createReadFileTool() const schema = getFunctionDef(tool).parameters as any - expect(schema.properties.files.items.properties).not.toHaveProperty("line_ranges") + expect(schema.properties).toHaveProperty("offset") + expect(schema.properties).toHaveProperty("limit") }) }) @@ -138,75 +93,6 @@ describe("createReadFileTool", () => { }) }) - describe("combined options", () => { - it("should correctly combine low maxConcurrentFileReads with partialReadsEnabled", () => { - const tool = createReadFileTool({ - maxConcurrentFileReads: 2, - partialReadsEnabled: true, - }) - const description = getFunctionDef(tool).description - - expect(description).toContain("maximum of 2 files") - expect(description).toContain("line_ranges") - expect(description).toContain("within 2-file limit") - }) - - it("should correctly handle maxConcurrentFileReads of 1 with partialReadsEnabled false", () => { - const tool = createReadFileTool({ - maxConcurrentFileReads: 1, - partialReadsEnabled: false, - }) - const description = getFunctionDef(tool).description - - expect(description).toContain("only read one file at a time") - expect(description).not.toContain("line_ranges") - expect(description).not.toContain("Example multiple files") - }) - - it("should correctly combine partialReadsEnabled and supportsImages", () => { - const tool = createReadFileTool({ - partialReadsEnabled: true, - supportsImages: true, - }) - const description = getFunctionDef(tool).description - - // Should have both line_ranges and image support - expect(description).toContain("line_ranges") - expect(description).toContain( - "Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis", - ) - }) - - it("should work with partialReadsEnabled=false and supportsImages=true", () => { - const tool = createReadFileTool({ - partialReadsEnabled: false, - supportsImages: true, - }) - const description = getFunctionDef(tool).description - - // Should have image support but no line_ranges - expect(description).not.toContain("line_ranges") - expect(description).toContain( - "Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis", - ) - }) - - it("should correctly combine all three options", () => { - const tool = createReadFileTool({ - maxConcurrentFileReads: 3, - partialReadsEnabled: true, - supportsImages: true, - }) - const description = getFunctionDef(tool).description - - expect(description).toContain("maximum of 3 files") - expect(description).toContain("line_ranges") - expect(description).toContain( - "Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis", - ) - }) - }) - describe("tool structure", () => { it("should have correct tool name", () => { const tool = createReadFileTool() @@ -226,18 +112,11 @@ describe("createReadFileTool", () => { expect(getFunctionDef(tool).strict).toBe(true) }) - it("should require files parameter", () => { + it("should require path parameter", () => { const tool = createReadFileTool() const schema = getFunctionDef(tool).parameters as any - expect(schema.required).toContain("files") - }) - - it("should require path in file objects", () => { - const tool = createReadFileTool({ partialReadsEnabled: false }) - const schema = getFunctionDef(tool).parameters as any - - expect(schema.properties.files.items.required).toContain("path") + expect(schema.required).toContain("path") }) }) }) diff --git a/src/core/prompts/tools/native-tools/fetch_instructions.ts b/src/core/prompts/tools/native-tools/fetch_instructions.ts deleted file mode 100644 index 86ab184c58..0000000000 --- a/src/core/prompts/tools/native-tools/fetch_instructions.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type OpenAI from "openai" - -const FETCH_INSTRUCTIONS_DESCRIPTION = `Retrieve detailed instructions for performing a predefined task, such as creating an MCP server or creating a mode.` - -const TASK_PARAMETER_DESCRIPTION = `Task identifier to fetch instructions for` - -export default { - type: "function", - function: { - name: "fetch_instructions", - description: FETCH_INSTRUCTIONS_DESCRIPTION, - strict: true, - parameters: { - type: "object", - properties: { - task: { - type: "string", - description: TASK_PARAMETER_DESCRIPTION, - enum: ["create_mcp_server", "create_mode"], - }, - }, - required: ["task"], - 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 dd743faeba..1d3adda16e 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -6,15 +6,15 @@ import askFollowupQuestion from "./ask_followup_question" import askMultipleChoice from "./ask_multiple_choice" import attemptCompletion from "./attempt_completion" import browserAction from "./browser_action" -import codebaseSearch from "./codebase_search" +// import codebaseSearch from "./codebase_search" import executeCommand from "./execute_command" -import fetchInstructions from "./fetch_instructions" import generateImage from "./generate_image" import listFiles from "./list_files" import newTask from "./new_task" import readCommandOutput from "./read_command_output" import { createReadFileTool, type ReadFileToolOptions } from "./read_file" import runSlashCommand from "./run_slash_command" +import skill from "./skill" import searchAndReplace from "./search_and_replace" import searchReplace from "./search_replace" import edit_file from "./edit_file" @@ -41,8 +41,8 @@ import { getLiteSwitchModeDescription, getLiteNewTaskDescription, getLiteUpdateTodoListDescription, - getLiteFetchInstructionsDescription, - getLiteCodebaseSearchDescription, + getLiteSkillDescription, + // getLiteCodebaseSearchDescription, getLiteAccessMcpResourceDescription, getLiteGenerateImageDescription, getLiteRunSlashCommandDescription, @@ -57,10 +57,6 @@ import { * Options for customizing the native tools array. */ export interface NativeToolsOptions { - /** Whether to include line_ranges support in read_file tool (default: true) */ - partialReadsEnabled?: boolean - /** Maximum number of files that can be read in a single read_file request (default: 5) */ - maxConcurrentFileReads?: number /** Whether the model supports image processing (default: false) */ supportsImages?: boolean useLitePrompts?: boolean @@ -82,12 +78,12 @@ function getLiteDescription(tool: OpenAI.Chat.ChatCompletionFunctionTool): strin return getLiteAttemptCompletionDescription() case "browser_action": return getLiteBrowserActionDescription() - case "codebase_search": - return getLiteCodebaseSearchDescription() + // case "codebase_search": + // return getLiteCodebaseSearchDescription() case "execute_command": return getLiteExecuteCommandDescription() - case "fetch_instructions": - return getLiteFetchInstructionsDescription() + case "skill": + return getLiteSkillDescription() case "generate_image": return getLiteGenerateImageDescription() case "list_files": @@ -126,15 +122,9 @@ function getLiteDescription(tool: OpenAI.Chat.ChatCompletionFunctionTool): strin * @returns Array of native tool definitions */ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.ChatCompletionTool[] { - const { - partialReadsEnabled = true, - maxConcurrentFileReads = 5, - supportsImages = false, - useLitePrompts = false, - } = options + const { supportsImages = false, useLitePrompts = false } = options + const readFileOptions: ReadFileToolOptions = { - partialReadsEnabled, - maxConcurrentFileReads, supportsImages, } @@ -146,15 +136,15 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch askMultipleChoice, attemptCompletion, browserAction, - codebaseSearch, + // codebaseSearch, executeCommand, - fetchInstructions, generateImage, listFiles, newTask, readCommandOutput, createReadFileTool(readFileOptions), runSlashCommand, + skill, searchAndReplace, searchReplace, edit_file, diff --git a/src/core/prompts/tools/native-tools/read_file.ts b/src/core/prompts/tools/native-tools/read_file.ts index 7171be0f1d..af781556ef 100644 --- a/src/core/prompts/tools/native-tools/read_file.ts +++ b/src/core/prompts/tools/native-tools/read_file.ts @@ -1,5 +1,18 @@ import type OpenAI from "openai" +// ─── Constants ──────────────────────────────────────────────────────────────── + +/** Default maximum lines to return per file (Codex-inspired predictable limit) */ +export const DEFAULT_LINE_LIMIT = 2000 + +/** Maximum characters per line before truncation */ +export const MAX_LINE_LENGTH = 2000 + +/** Default indentation levels to include above anchor (0 = unlimited) */ +export const DEFAULT_MAX_LEVELS = 0 + +// ─── Helper Functions ───────────────────────────────────────────────────────── + /** * Generates the file support note, optionally including image format support. * @@ -13,86 +26,117 @@ function getReadFileSupportsNote(supportsImages: boolean): string { return `Supports text extraction from PDF and DOCX files, but may not handle other binary files properly.` } +// ─── Types ──────────────────────────────────────────────────────────────────── + /** * Options for creating the read_file tool definition. */ export interface ReadFileToolOptions { - /** Whether to include line_ranges parameter (default: true) */ - partialReadsEnabled?: boolean - /** Maximum number of files that can be read in a single request (default: 5) */ - maxConcurrentFileReads?: number /** Whether the model supports image processing (default: false) */ supportsImages?: boolean } +// ─── Schema Builder ─────────────────────────────────────────────────────────── + /** - * Creates the read_file tool definition, optionally including line_ranges support - * based on whether partial reads are enabled. + * Creates the read_file tool definition with Codex-inspired modes. + * + * Two reading modes are supported: + * + * 1. **Slice Mode** (default): Simple offset/limit reading + * - Reads contiguous lines starting from `offset` (1-based, default: 1) + * - Limited to `limit` lines (default: 2000) + * - Predictable and efficient for agent planning + * + * 2. **Indentation Mode**: Semantic code block extraction + * - Anchored on a specific line number (1-based) + * - Extracts the block containing that line plus context + * - Respects code structure based on indentation hierarchy + * - Useful for extracting functions, classes, or logical blocks * * @param options - Configuration options for the tool * @returns Native tool definition for read_file */ export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Chat.ChatCompletionTool { - const { partialReadsEnabled = true, maxConcurrentFileReads = 5, supportsImages = false } = options - const isMultipleReadsEnabled = maxConcurrentFileReads > 1 + const { supportsImages = false } = options - // Build description intro with concurrent reads limit message - const descriptionIntro = isMultipleReadsEnabled - ? `Read one or more files and return their contents with line numbers for diffing or discussion. IMPORTANT: You can read a maximum of ${maxConcurrentFileReads} files in a single request. If you need to read more files, use multiple sequential read_file requests. ` - : "Read a file and return its contents with line numbers for diffing or discussion. IMPORTANT: Multiple file reads are currently disabled. You can only read one file at a time. " + // Build description based on capabilities + const descriptionIntro = + "Read a file and return its contents with line numbers for diffing or discussion. IMPORTANT: This tool reads exactly one file per call. If you need multiple files, issue multiple parallel read_file calls." - const baseDescription = - descriptionIntro + - "Structure: { files: [{ path: 'relative/path.ts'" + - (partialReadsEnabled ? ", line_ranges: [[1, 50], [100, 150]]" : "") + - " }] }. " + - "The 'path' is required and relative to workspace. " - - const optionalRangesDescription = partialReadsEnabled - ? "The 'line_ranges' is optional for reading specific sections. Each range is a [start, end] tuple (1-based inclusive). " - : "" - - const examples = partialReadsEnabled - ? "Example single file: { files: [{ path: 'src/app.ts' }] }. " + - "Example with line ranges: { files: [{ path: 'src/app.ts', line_ranges: [[1, 50], [100, 150]] }] }. " + - (isMultipleReadsEnabled - ? `Example multiple files (within ${maxConcurrentFileReads}-file limit): { files: [{ path: 'file1.ts', line_ranges: [[1, 50]] }, { path: 'file2.ts' }] }` - : "") - : "Example single file: { files: [{ path: 'src/app.ts' }] }. " + - (isMultipleReadsEnabled - ? `Example multiple files (within ${maxConcurrentFileReads}-file limit): { files: [{ path: 'file1.ts' }, { path: 'file2.ts' }] }` - : "") + const modeDescription = + ` Supports two modes: 'slice' (default) reads lines sequentially with offset/limit; 'indentation' extracts complete semantic code blocks around an anchor line based on indentation hierarchy.` + + ` Slice mode is ideal for initial file exploration, understanding overall structure, reading configuration/data files, or when you need a specific line range. Use it when you don't have a target line number.` + + ` PREFER indentation mode when you have a specific line number from search results, error messages, or definition lookups - it guarantees complete, syntactically valid code blocks without mid-function truncation.` + + ` IMPORTANT: Indentation mode requires anchor_line to be useful. Without it, only header content (imports) is returned.` + + const limitNote = ` By default, returns up to ${DEFAULT_LINE_LIMIT} lines per file. Lines longer than ${MAX_LINE_LENGTH} characters are truncated.` const description = - baseDescription + optionalRangesDescription + getReadFileSupportsNote(supportsImages) + " " + examples + descriptionIntro + + modeDescription + + limitNote + + " " + + getReadFileSupportsNote(supportsImages) + + ` Example: { path: 'src/app.ts' }` + + ` Example (indentation mode): { path: 'src/app.ts', mode: 'indentation', indentation: { anchor_line: 42 } }` + + const indentationProperties: Record = { + anchor_line: { + type: "integer", + description: + "1-based line number to anchor the extraction. REQUIRED for meaningful indentation mode results. The extractor finds the semantic block (function, method, class) containing this line and returns it completely. Without anchor_line, indentation mode defaults to line 1 and returns only imports/header content. Obtain anchor_line from: search results, error stack traces, definition lookups, codebase_search results, or condensed file summaries (e.g., '14--28 | export class UserService' means anchor_line=14).", + }, + max_levels: { + type: "integer", + description: `Maximum indentation levels to include above the anchor (indentation mode, 0 = unlimited (default)). Higher values include more parent context.`, + }, + include_siblings: { + type: "boolean", + description: + "Include sibling blocks at the same indentation level as the anchor block (indentation mode, default: false). Useful for seeing related methods in a class.", + }, + include_header: { + type: "boolean", + description: + "Include file header content (imports, module-level comments) at the top of output (indentation mode, default: true).", + }, + max_lines: { + type: "integer", + description: + "Hard cap on lines returned for indentation mode. Acts as a separate limit from the top-level 'limit' parameter.", + }, + } - // Build the properties object conditionally - const fileProperties: Record = { + const properties: Record = { path: { type: "string", description: "Path to the file to read, relative to the workspace", }, - } - - // Only include line_ranges if partial reads are enabled - if (partialReadsEnabled) { - fileProperties.line_ranges = { - type: ["array", "null"], + mode: { + type: "string", + enum: ["slice", "indentation"], description: - "Optional line ranges to read. Each range is a [start, end] tuple with 1-based inclusive line numbers. Use multiple ranges for non-contiguous sections.", - items: { - type: "array", - items: { type: "integer" }, - minItems: 2, - maxItems: 2, - }, - } + "Reading mode. 'slice' (default): read lines sequentially with offset/limit - use for general file exploration or when you don't have a target line number (may truncate code mid-function). 'indentation': extract complete semantic code blocks containing anchor_line - PREFERRED when you have a line number because it guarantees complete, valid code blocks. WARNING: Do not use indentation mode without specifying indentation.anchor_line, or you will only get header content.", + }, + offset: { + type: "integer", + description: "1-based line offset to start reading from (slice mode, default: 1)", + }, + limit: { + type: "integer", + description: `Maximum number of lines to return (slice mode, default: ${DEFAULT_LINE_LIMIT})`, + }, + indentation: { + type: "object", + description: + "Indentation mode options. Only used when mode='indentation'. You MUST specify anchor_line for useful results - it determines which code block to extract.", + properties: indentationProperties, + required: [], + additionalProperties: false, + }, } - // When using strict mode, ALL properties must be in the required array - // Optional properties are handled by having type: ["...", "null"] - const fileRequiredProperties = partialReadsEnabled ? ["path", "line_ranges"] : ["path"] - return { type: "function", function: { @@ -101,24 +145,15 @@ export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Ch strict: true, parameters: { type: "object", - properties: { - files: { - type: "array", - description: "List of files to read; request related files together when allowed", - items: { - type: "object", - properties: fileProperties, - required: fileRequiredProperties, - additionalProperties: false, - }, - minItems: 1, - }, - }, - required: ["files"], + properties, + required: ["path"], additionalProperties: false, }, }, } satisfies OpenAI.Chat.ChatCompletionTool } -export const read_file = createReadFileTool({ partialReadsEnabled: false }) +/** + * Default read_file tool with all parameters + */ +export const read_file = createReadFileTool() diff --git a/src/core/prompts/tools/native-tools/skill.ts b/src/core/prompts/tools/native-tools/skill.ts new file mode 100644 index 0000000000..98a2d98cc8 --- /dev/null +++ b/src/core/prompts/tools/native-tools/skill.ts @@ -0,0 +1,33 @@ +import type OpenAI from "openai" + +const SKILL_DESCRIPTION = `Load and execute a skill by name. Skills provide specialized instructions for common tasks like creating MCP servers or custom modes. + +Use this tool when you need to follow specific procedures documented in a skill. Available skills are listed in the AVAILABLE SKILLS section of the system prompt.` + +const SKILL_PARAMETER_DESCRIPTION = `Name of the skill to load (e.g., create-mcp-server, create-mode). Must match a skill name from the available skills list.` + +const ARGS_PARAMETER_DESCRIPTION = `Optional context or arguments to pass to the skill` + +export default { + type: "function", + function: { + name: "skill", + description: SKILL_DESCRIPTION, + strict: true, + parameters: { + type: "object", + properties: { + skill: { + type: "string", + description: SKILL_PARAMETER_DESCRIPTION, + }, + args: { + type: ["string", "null"], + description: ARGS_PARAMETER_DESCRIPTION, + }, + }, + required: ["skill", "args"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/prompts/types.ts b/src/core/prompts/types.ts index 1507c651bc..18cb18bf41 100644 --- a/src/core/prompts/types.ts +++ b/src/core/prompts/types.ts @@ -2,8 +2,7 @@ * Settings passed to system prompt generation functions */ export interface SystemPromptSettings { - maxConcurrentFileReads: number - todoListEnabled?: boolean + todoListEnabled: boolean browserToolEnabled?: boolean useAgentRules: boolean /** When true, recursively discover and load .roo/rules from subdirectories */ diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index cb077e87ea..4c41b451be 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -16,7 +16,7 @@ import pWaitFor from "p-wait-for" import { serializeError } from "serialize-error" import { parseJSON } from "partial-json" import { Package } from "../../shared/package" -import { formatToolInvocation } from "../tools/helpers/toolResultFormatting" +// import { formatToolInvocation } from "../tools/helpers/toolResultFormatting" import { type TaskLike, @@ -425,6 +425,127 @@ export class Task extends EventEmitter implements TaskLike { this.userMessageContent.push(toolResult) return true } + + /** + * Handle a tool call streaming event (tool_call_start, tool_call_delta, or tool_call_end). + * This is used both for processing events from NativeToolCallParser (legacy providers) + * and for direct AI SDK events (DeepSeek, Moonshot, etc.). + * + * @param event - The tool call event to process + */ + private handleToolCallEvent( + event: + | { type: "tool_call_start"; id: string; name: string } + | { type: "tool_call_delta"; id: string; delta: string } + | { type: "tool_call_end"; id: string }, + ): void { + if (event.type === "tool_call_start") { + // Guard against duplicate tool_call_start events for the same tool ID. + // This can occur due to stream retry, reconnection, or API quirks. + // Without this check, duplicate tool_use blocks with the same ID would + // be added to assistantMessageContent, causing API 400 errors: + // "tool_use ids must be unique" + if (this.streamingToolCallIndices.has(event.id)) { + console.warn( + `[Task#${this.taskId}] Ignoring duplicate tool_call_start for ID: ${event.id} (tool: ${event.name})`, + ) + return + } + + // Initialize streaming in NativeToolCallParser + NativeToolCallParser.startStreamingToolCall(event.id, event.name as ToolName) + + // Before adding a new tool, finalize any preceding text block + // This prevents the text block from blocking tool presentation + const lastBlock = this.assistantMessageContent[this.assistantMessageContent.length - 1] + if (lastBlock?.type === "text" && lastBlock.partial) { + lastBlock.partial = false + } + + // Track the index where this tool will be stored + const toolUseIndex = this.assistantMessageContent.length + this.streamingToolCallIndices.set(event.id, toolUseIndex) + + // Create initial partial tool use + const partialToolUse: ToolUse = { + type: "tool_use", + name: event.name as ToolName, + params: {}, + partial: true, + } + + // Store the ID for native protocol + ;(partialToolUse as any).id = event.id + + // Add to content and present + this.assistantMessageContent.push(partialToolUse) + this.userMessageContentReady = false + presentAssistantMessage(this) + } else if (event.type === "tool_call_delta") { + // Process chunk using streaming JSON parser + const partialToolUse = NativeToolCallParser.processStreamingChunk(event.id, event.delta) + + if (partialToolUse) { + // Get the index for this tool call + const toolUseIndex = this.streamingToolCallIndices.get(event.id) + if (toolUseIndex !== undefined) { + // Store the ID for native protocol + ;(partialToolUse as any).id = event.id + + // Update the existing tool use with new partial data + this.assistantMessageContent[toolUseIndex] = partialToolUse + + // Present updated tool use + presentAssistantMessage(this) + } + } + } else if (event.type === "tool_call_end") { + // Finalize the streaming tool call + const finalToolUse = NativeToolCallParser.finalizeStreamingToolCall(event.id) + + // Get the index for this tool call + const toolUseIndex = this.streamingToolCallIndices.get(event.id) + + if (finalToolUse) { + // Store the tool call ID + ;(finalToolUse as any).id = event.id + + // Get the index and replace partial with final + if (toolUseIndex !== undefined) { + this.assistantMessageContent[toolUseIndex] = finalToolUse + } + + // Clean up tracking + this.streamingToolCallIndices.delete(event.id) + + // Mark that we have new content to process + this.userMessageContentReady = false + + // Present the finalized tool call + presentAssistantMessage(this) + } else if (toolUseIndex !== undefined) { + // finalizeStreamingToolCall returned null (malformed JSON or missing args) + // Mark the tool as non-partial so it's presented as complete, but execution + // will be short-circuited in presentAssistantMessage with a structured tool_result. + const existingToolUse = this.assistantMessageContent[toolUseIndex] + if (existingToolUse && existingToolUse.type === "tool_use") { + existingToolUse.partial = false + // Ensure it has the ID for native protocol + ;(existingToolUse as any).id = event.id + } + + // Clean up tracking + this.streamingToolCallIndices.delete(event.id) + + // Mark that we have new content to process + this.userMessageContentReady = false + + // Present the tool call - validation will handle missing params + presentAssistantMessage(this) + } + } + } + didRejectTool = false didAlreadyUseTool = false didToolFailInCurrentTurn = false @@ -1775,8 +1896,6 @@ export class Task extends EventEmitter implements TaskLike { customModes: state?.customModes, experiments: state?.experiments, apiConfiguration, - maxReadFileLine: state?.maxReadFileLine ?? -1, - maxConcurrentFileReads: state?.maxConcurrentFileReads ?? 5, browserToolEnabled: state?.browserToolEnabled ?? true, modelInfo, includeAllToolsWithRestrictions: false, @@ -2712,8 +2831,6 @@ export class Task extends EventEmitter implements TaskLike { showRooIgnoredFiles = false, includeDiagnosticMessages = true, maxDiagnosticMessages = 50, - maxReadFileLine = -1, - maxReadCharacterLimit = 20000, } = (await this.providerRef.deref()?.getState()) ?? {} const { content: parsedUserContent, mode: slashCommandMode } = await processUserContentMentions({ @@ -2725,8 +2842,6 @@ export class Task extends EventEmitter implements TaskLike { showRooIgnoredFiles, includeDiagnosticMessages, maxDiagnosticMessages, - maxReadFileLine, - maxReadCharacterLimit, }) // Switch mode if specified in a slash command's frontmatter @@ -2986,6 +3101,20 @@ export class Task extends EventEmitter implements TaskLike { pendingGroundingSources.push(...chunk.sources) } break + + case "fake_tool_call": { + // During streaming, only accumulate fake_tool_call content without executing tools + // This maintains streaming output continuity + assistantXmlToolMessage += chunk.text + + // Generate unique tool call ID if not already set + if (!assistantXmlToolCallId) { + assistantXmlToolCallId = uuidv7() + } + + // Don't call presentAssistantMessage() here, wait until stream ends + break + } case "tool_call_partial": { // Process raw tool call chunk through NativeToolCallParser // which handles tracking, buffering, and emits events @@ -2997,122 +3126,19 @@ export class Task extends EventEmitter implements TaskLike { }) for (const event of events) { - if (event.type === "tool_call_start") { - // Guard against duplicate tool_call_start events for the same tool ID. - // This can occur due to stream retry, reconnection, or API quirks. - // Without this check, duplicate tool_use blocks with the same ID would - // be added to assistantMessageContent, causing API 400 errors: - // "tool_use ids must be unique" - if (this.streamingToolCallIndices.has(event.id)) { - console.warn( - `[Task#${this.taskId}] Ignoring duplicate tool_call_start for ID: ${event.id} (tool: ${event.name})`, - ) - continue - } - - // Initialize streaming in NativeToolCallParser - NativeToolCallParser.startStreamingToolCall(event.id, event.name as ToolName) - - // Before adding a new tool, finalize any preceding text block - // This prevents the text block from blocking tool presentation - const lastBlock = - this.assistantMessageContent[this.assistantMessageContent.length - 1] - if (lastBlock?.type === "text" && lastBlock.partial) { - lastBlock.partial = false - } - - // Track the index where this tool will be stored - const toolUseIndex = this.assistantMessageContent.length - this.streamingToolCallIndices.set(event.id, toolUseIndex) - - // Create initial partial tool use - const partialToolUse: ToolUse = { - type: "tool_use", - name: fixNativeToolname(event.name as ToolName), - params: {}, - partial: true, - } - - // Store the ID for native protocol - ;(partialToolUse as any).id = event.id - - // Add to content and present - this.assistantMessageContent.push(partialToolUse) - this.userMessageContentReady = false - presentAssistantMessage(this) - } else if (event.type === "tool_call_delta") { - // Process chunk using streaming JSON parser - const partialToolUse = NativeToolCallParser.processStreamingChunk( - event.id, - event.delta, - ) - - if (partialToolUse) { - // Get the index for this tool call - const toolUseIndex = this.streamingToolCallIndices.get(event.id) - if (toolUseIndex !== undefined) { - // Store the ID for native protocol - ;(partialToolUse as any).id = event.id - - // Update the existing tool use with new partial data - this.assistantMessageContent[toolUseIndex] = partialToolUse - - // Present updated tool use - presentAssistantMessage(this) - } - } - } else if (event.type === "tool_call_end") { - // Finalize the streaming tool call - const finalToolUse = NativeToolCallParser.finalizeStreamingToolCall( - event.id, - this?.apiConfiguration?.apiProvider === "zgsm", - ) - - // Get the index for this tool call - const toolUseIndex = this.streamingToolCallIndices.get(event.id) - - if (finalToolUse) { - // Store the tool call ID - ;(finalToolUse as any).id = event.id - - // Get the index and replace partial with final - if (toolUseIndex !== undefined) { - this.assistantMessageContent[toolUseIndex] = finalToolUse - } - - // Clean up tracking - this.streamingToolCallIndices.delete(event.id) - - // Mark that we have new content to process - this.userMessageContentReady = false - - // Present the finalized tool call - presentAssistantMessage(this) - } else if (toolUseIndex !== undefined) { - // finalizeStreamingToolCall returned null (malformed JSON or missing args) - // Mark the tool as non-partial so it's presented as complete, but execution - // will be short-circuited in presentAssistantMessage with a structured tool_result. - const existingToolUse = this.assistantMessageContent[toolUseIndex] - if (existingToolUse && existingToolUse.type === "tool_use") { - existingToolUse.partial = false - // Ensure it has the ID for native protocol - ;(existingToolUse as any).id = event.id - } - - // Clean up tracking - this.streamingToolCallIndices.delete(event.id) - - // Mark that we have new content to process - this.userMessageContentReady = false - - // Present the tool call - validation will handle missing params - presentAssistantMessage(this) - } - } + this.handleToolCallEvent(event) } break } + // Direct handlers for AI SDK tool streaming events (DeepSeek, Moonshot, etc.) + // These providers emit tool_call_start/delta/end directly instead of tool_call_partial + case "tool_call_start": + case "tool_call_delta": + case "tool_call_end": + this.handleToolCallEvent(chunk) + break + case "tool_call": { // Legacy: Handle complete tool calls (for backward compatibility) // Convert native tool call to ToolUse format @@ -3167,39 +3193,18 @@ export class Task extends EventEmitter implements TaskLike { } break } - case "fake_tool_call": { - // During streaming, only accumulate fake_tool_call content without executing tools - // This maintains streaming output continuity - assistantXmlToolMessage += chunk.text - - // Generate unique tool call ID if not already set - if (!assistantXmlToolCallId) { - assistantXmlToolCallId = uuidv7() - } - - // Don't call presentAssistantMessage() here, wait until stream ends - break - } case "text": { assistantMessage += chunk.text - // // Native tool calling: text chunks are plain text. - // // Create or update a text content block directly + + // Native tool calling: text chunks are plain text. + // Create or update a text content block directly const lastBlock = this.assistantMessageContent[this.assistantMessageContent.length - 1] - // const _assistantMessage = assistantMessage - const _assistantMessage = - assistantMessage.includes("") && - this.apiConfiguration.apiProvider === "zgsm" && - !(this.apiConfiguration.zgsmModelId ?? "").toLowerCase().includes("qwen") - ? assistantMessage.split("")[0] - : assistantMessage if (lastBlock?.type === "text" && lastBlock.partial) { - lastBlock.content = _assistantMessage - } else if (lastBlock?.type === "text" && lastBlock.partial === false) { - console.log("assistantMessage", assistantMessage) + lastBlock.content = assistantMessage } else { this.assistantMessageContent.push({ type: "text", - content: _assistantMessage, + content: assistantMessage, partial: true, }) this.userMessageContentReady = false @@ -3530,7 +3535,7 @@ export class Task extends EventEmitter implements TaskLike { // Present the tool call to user - presentAssistantMessage will execute // tools sequentially and accumulate all results in userMessageContent - await presentAssistantMessage(this) + presentAssistantMessage(this) } } } catch (error) { @@ -3647,7 +3652,11 @@ export class Task extends EventEmitter implements TaskLike { // This ensures that when new_task triggers delegation and calls flushPendingToolResultsToHistory(), // the assistant message is already in history. Otherwise, tool_result blocks would appear // BEFORE their corresponding tool_use blocks, causing API errors. - + if (!assistantMessage?.length) { + await pWaitFor(() => assistantMessage.length > 0, { timeout: 1_000 }).catch(() => { + console.error("assistantMessage was empty after 3 seconds") + }) + } // Check if we have any content to process (text or tool uses) const hasTextContent = assistantMessage.length > 0 @@ -4002,15 +4011,6 @@ export class Task extends EventEmitter implements TaskLike { return false } - /** - * Get the names of all loaded custom tools. - * This is a synchronous method that returns the currently loaded custom tool names. - * If custom tools experiment is not enabled, the registry will be empty. - */ - private getCustomToolNames(): string[] { - return customToolRegistry.list() - } - private async getSystemPrompt(): Promise { const { mcpEnabled } = (await this.providerRef.deref()?.getState()) ?? {} let mcpHub: McpHub | undefined @@ -4045,11 +4045,8 @@ export class Task extends EventEmitter implements TaskLike { customModePrompts, customInstructions, experiments, - enableMcpServerCreation, browserToolEnabled, language, - maxConcurrentFileReads, - maxReadFileLine, apiConfiguration, terminalShellIntegrationDisabled, enableSubfolderRules, @@ -4086,13 +4083,10 @@ export class Task extends EventEmitter implements TaskLike { customModes, customInstructions, experiments, - enableMcpServerCreation, language, rooIgnoreInstructions, - maxReadFileLine !== -1, { terminalShellIntegrationDisabled, - maxConcurrentFileReads: maxConcurrentFileReads ?? 5, todoListEnabled: apiConfiguration?.todoListEnabled ?? true, browserToolEnabled: browserToolEnabled ?? true, useAgentRules: @@ -4157,8 +4151,6 @@ export class Task extends EventEmitter implements TaskLike { customModes: state?.customModes, experiments: state?.experiments, apiConfiguration, - maxReadFileLine: state?.maxReadFileLine ?? -1, - maxConcurrentFileReads: state?.maxConcurrentFileReads ?? 5, browserToolEnabled: state?.browserToolEnabled ?? true, modelInfo, includeAllToolsWithRestrictions: false, @@ -4377,8 +4369,6 @@ export class Task extends EventEmitter implements TaskLike { customModes: state?.customModes, experiments: state?.experiments, apiConfiguration, - maxReadFileLine: state?.maxReadFileLine ?? -1, - maxConcurrentFileReads: state?.maxConcurrentFileReads ?? 5, browserToolEnabled: state?.browserToolEnabled ?? true, modelInfo, includeAllToolsWithRestrictions: false, @@ -4542,8 +4532,6 @@ export class Task extends EventEmitter implements TaskLike { customModes: state?.customModes, experiments: state?.experiments, apiConfiguration, - maxReadFileLine: state?.maxReadFileLine ?? -1, - maxConcurrentFileReads: state?.maxConcurrentFileReads ?? 5, browserToolEnabled: state?.browserToolEnabled ?? true, modelInfo, useLitePrompts: experiments?.useLitePrompts ?? false, diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 80398d5266..f438f115a5 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -164,7 +164,7 @@ vi.mock("vscode", async (importOriginal) => { vi.mock("../../mentions", () => ({ parseMentions: vi.fn().mockImplementation((text) => { - return Promise.resolve({ text: `processed: ${text}`, mode: undefined }) + return Promise.resolve({ text: `processed: ${text}`, mode: undefined, contentBlocks: [] }) }), openMention: vi.fn(), getLatestTerminalOutput: vi.fn(), diff --git a/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts b/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts index 34980190a7..072512fbb0 100644 --- a/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts +++ b/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts @@ -128,7 +128,7 @@ vi.mock("vscode", async (importOriginal) => { vi.mock("../../mentions", () => ({ parseMentions: vi.fn().mockImplementation((text) => { - return Promise.resolve(`processed: ${text}`) + return Promise.resolve({ text: `processed: ${text}`, mode: undefined, contentBlocks: [] }) }), openMention: vi.fn(), getLatestTerminalOutput: vi.fn(), diff --git a/src/core/task/__tests__/grace-retry-errors.spec.ts b/src/core/task/__tests__/grace-retry-errors.spec.ts index a0cca191f2..dac3871e4b 100644 --- a/src/core/task/__tests__/grace-retry-errors.spec.ts +++ b/src/core/task/__tests__/grace-retry-errors.spec.ts @@ -132,7 +132,7 @@ vi.mock("vscode", async (importOriginal) => { vi.mock("../../mentions", () => ({ parseMentions: vi.fn().mockImplementation((text) => { - return Promise.resolve(`processed: ${text}`) + return Promise.resolve({ text: `processed: ${text}`, mode: undefined, contentBlocks: [] }) }), openMention: vi.fn(), getLatestTerminalOutput: vi.fn(), diff --git a/src/core/task/__tests__/grounding-sources.test.ts b/src/core/task/__tests__/grounding-sources.test.ts index 87dc015bf3..ae0647a950 100644 --- a/src/core/task/__tests__/grounding-sources.test.ts +++ b/src/core/task/__tests__/grounding-sources.test.ts @@ -128,7 +128,7 @@ vi.mock("fs/promises", () => ({ // Mock mentions vi.mock("../../mentions", () => ({ - parseMentions: vi.fn().mockImplementation((text) => Promise.resolve(text)), + parseMentions: vi.fn().mockImplementation((text) => Promise.resolve({ text, mode: undefined, contentBlocks: [] })), openMention: vi.fn(), getLatestTerminalOutput: vi.fn(), })) diff --git a/src/core/task/__tests__/reasoning-preservation.test.ts b/src/core/task/__tests__/reasoning-preservation.test.ts index 6438c083f7..f5f33e6c75 100644 --- a/src/core/task/__tests__/reasoning-preservation.test.ts +++ b/src/core/task/__tests__/reasoning-preservation.test.ts @@ -125,7 +125,7 @@ vi.mock("fs/promises", () => ({ // Mock mentions vi.mock("../../mentions", () => ({ - parseMentions: vi.fn().mockImplementation((text) => Promise.resolve(text)), + parseMentions: vi.fn().mockImplementation((text) => Promise.resolve({ text, mode: undefined, contentBlocks: [] })), openMention: vi.fn(), getLatestTerminalOutput: vi.fn(), })) diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index 8ef9c0a74c..d7d7459e17 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -22,8 +22,6 @@ interface BuildToolsOptions { customModes: ModeConfig[] | undefined experiments: Record | undefined apiConfiguration: ProviderSettings | undefined - maxReadFileLine: number - maxConcurrentFileReads: number browserToolEnabled: boolean modelInfo?: ModelInfo useLitePrompts?: boolean @@ -90,8 +88,6 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO customModes, experiments, apiConfiguration, - maxReadFileLine, - maxConcurrentFileReads, browserToolEnabled, modelInfo, useLitePrompts, @@ -111,16 +107,11 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO modelInfo, } - // Determine if partial reads are enabled based on maxReadFileLine setting. - const partialReadsEnabled = maxReadFileLine !== -1 - // Check if the model supports images for read_file tool description. const supportsImages = modelInfo?.supportsImages ?? false // Build native tools with dynamic read_file tool based on settings. const nativeTools = getNativeTools({ - partialReadsEnabled, - maxConcurrentFileReads, supportsImages, useLitePrompts, }) diff --git a/src/core/tools/FetchInstructionsTool.ts b/src/core/tools/FetchInstructionsTool.ts deleted file mode 100644 index f800e57fc4..0000000000 --- a/src/core/tools/FetchInstructionsTool.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { type ClineSayTool } from "@roo-code/types" - -import { Task } from "../task/Task" -import { fetchInstructions } from "../prompts/instructions/instructions" -import { formatResponse } from "../prompts/responses" -import type { ToolUse } from "../../shared/tools" - -import { BaseTool, ToolCallbacks } from "./BaseTool" - -interface FetchInstructionsParams { - task: string -} - -export class FetchInstructionsTool extends BaseTool<"fetch_instructions"> { - readonly name = "fetch_instructions" as const - - async execute(params: FetchInstructionsParams, task: Task, callbacks: ToolCallbacks): Promise { - const { handleError, pushToolResult, askApproval } = callbacks - const { task: taskParam } = params - - try { - if (!taskParam) { - task.consecutiveMistakeCount++ - task.recordToolError("fetch_instructions") - task.didToolFailInCurrentTurn = true - 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/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 8080056203..095f538bef 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -1,21 +1,30 @@ +/** + * ReadFileTool - Codex-inspired file reading with indentation mode support. + * + * Supports two modes: + * 1. Slice mode (default): Read contiguous lines with offset/limit + * 2. Indentation mode: Extract semantic code blocks based on indentation hierarchy + * + * Also supports legacy format for backward compatibility: + * - Legacy format: { files: [{ path: string, lineRanges?: [...] }] } + */ import path from "path" import * as fs from "fs/promises" +// import { isBinaryFile } from "isbinaryfile" +import { isBinaryFileWithEncodingDetection } from "../../utils/encoding" -import type { FileEntry, LineRange } from "@roo-code/types" -import { type ClineSayTool, ANTHROPIC_DEFAULT_MAX_TOKENS, DEFAULT_FILE_READ_CHARACTER_LIMIT } from "@roo-code/types" +import type { ReadFileParams, ReadFileMode, ReadFileToolParams, FileEntry, LineRange } from "@roo-code/types" +import { isLegacyReadFileParams, type ClineSayTool } from "@roo-code/types" import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" -import { getModelMaxOutputTokens } from "../../shared/api" -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 type { ToolUse } from "../../shared/tools" +import { readWithIndentation, readWithSlice } from "../../integrations/misc/indentation-reader" +import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file" +import type { ToolUse, PushToolResult } from "../../shared/tools" import { DEFAULT_MAX_IMAGE_FILE_SIZE_MB, @@ -25,11 +34,24 @@ import { processImageFile, ImageMemoryTracker, } from "./helpers/imageHelpers" -import { FILE_READ_BUDGET_PERCENT, readFileWithTokenBudget } from "./helpers/fileTokenBudget" -import { truncateDefinitionsToLineLimit } from "./helpers/truncateDefinitions" import { BaseTool, ToolCallbacks } from "./BaseTool" -import { isBinaryFileWithEncodingDetection } from "../../utils/encoding" -import { handleRooCommandsApprovalSkip } from "../costrict/wiki/utils/rooCommandsUtils" + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** + * Internal entry structure for tracking file read parameters. + */ +interface InternalFileEntry { + path: string + mode?: ReadFileMode + offset?: number + limit?: number + anchor_line?: number + max_levels?: number + include_siblings?: boolean + include_header?: boolean + max_lines?: number +} interface FileResult { path: string @@ -37,51 +59,79 @@ interface FileResult { content?: string error?: string notice?: string - lineRanges?: LineRange[] nativeContent?: string imageDataUrl?: string feedbackText?: string - feedbackImages?: any[] + feedbackImages?: string[] + // Store the original entry for mode processing + entry?: InternalFileEntry } +// ─── Tool Implementation ────────────────────────────────────────────────────── + export class ReadFileTool extends BaseTool<"read_file"> { readonly name = "read_file" as const - async execute(params: { files: FileEntry[] }, task: Task, callbacks: ToolCallbacks): Promise { - const { handleError, pushToolResult } = callbacks - const fileEntries = params.files + async execute(params: ReadFileToolParams, task: Task, callbacks: ToolCallbacks): Promise { + // Dispatch to legacy or new execution path based on format + if (isLegacyReadFileParams(params)) { + return this.executeLegacy(params.files, task, callbacks) + } + + return this.executeNew(params, task, callbacks) + } + + /** + * Execute new single-file format with slice/indentation mode support. + */ + private async executeNew(params: ReadFileParams, task: Task, callbacks: ToolCallbacks): Promise { + const { pushToolResult } = callbacks const modelInfo = task.api.getModel().info - const useNative = true + const filePath = params.path - if (!fileEntries || fileEntries.length === 0) { + // Validate input + if (!filePath) { task.consecutiveMistakeCount++ task.recordToolError("read_file") - const errorMsg = await task.sayAndCreateMissingParamError("read_file", "files") - const errorResult = `Error: ${errorMsg}` - pushToolResult(errorResult) + const errorMsg = await task.sayAndCreateMissingParamError("read_file", "path") + pushToolResult(`Error: ${errorMsg}`) return } - // Enforce maxConcurrentFileReads limit - const { maxConcurrentFileReads = 5 } = (await task.providerRef.deref()?.getState()) ?? {} - if (fileEntries.length > maxConcurrentFileReads) { - task.consecutiveMistakeCount++ - task.recordToolError("read_file") - const errorMsg = `Too many files requested. You attempted to read ${fileEntries.length} files, but the concurrent file reads limit is ${maxConcurrentFileReads}. Please read files in batches of ${maxConcurrentFileReads} or fewer.` - await task.say("error", errorMsg) - const errorResult = `Error: ${errorMsg}` - pushToolResult(errorResult) + const supportsImages = modelInfo.supportsImages ?? false + + // Initialize file results tracking + // Validate line number parameters (must be 1-indexed positive integers) + if (params.offset !== undefined && params.offset < 1) { + const errorMsg = `offset must be a 1-indexed line number (got ${params.offset}). Line numbers start at 1.` + pushToolResult(`Error: ${errorMsg}`) + return + } + if (params.indentation?.anchor_line !== undefined && params.indentation.anchor_line < 1) { + const errorMsg = `anchor_line must be a 1-indexed line number (got ${params.indentation.anchor_line}). Line numbers start at 1.` + pushToolResult(`Error: ${errorMsg}`) return } - const supportsImages = modelInfo.supportsImages ?? false + const fileEntry: InternalFileEntry = { + path: filePath, + mode: params.mode, + offset: params.offset, + limit: params.limit, + anchor_line: params.indentation?.anchor_line, + max_levels: params.indentation?.max_levels, + include_siblings: params.indentation?.include_siblings, + include_header: params.indentation?.include_header, + max_lines: params.indentation?.max_lines, + } - const fileResults: FileResult[] = - fileEntries?.map((entry) => ({ - path: entry.path, - status: "pending", - lineRanges: entry.lineRanges, - })) || [] + const fileResults: FileResult[] = [ + { + path: filePath, + status: "pending" as const, + entry: fileEntry, + }, + ] const updateFileResult = (filePath: string, updates: Partial) => { const index = fileResults.findIndex((result) => result.path === filePath) @@ -91,582 +141,510 @@ export class ReadFileTool extends BaseTool<"read_file"> { } try { + // Phase 1: Validate and filter files for approval 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, - 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, - nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`, - }) - await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) - hasRangeError = true - break - } - } - if (hasRangeError) continue - } - if (handleRooCommandsApprovalSkip(fileResult, relPath, task, updateFileResult)) { + // RooIgnore validation + 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, + nativeContent: `File: ${relPath}\nError: ${errorMsg}`, + }) 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, - nativeContent: `File: ${relPath}\nError: ${errorMsg}`, - }) - continue - } - filesToApprove.push(fileResult) - } + 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", - 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", - 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", - 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", - 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 }) - } - } + // Phase 2: Request user approval + await this.requestApproval(task, filesToApprove, updateFileResult) + // Phase 3: Process approved files const imageMemoryTracker = new ImageMemoryTracker() const state = await task.providerRef.deref()?.getState() const { - maxReadFileLine = -1, - maxReadCharacterLimit = DEFAULT_FILE_READ_CHARACTER_LIMIT, maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, } = state ?? {} - // Calculate per-file limits when reading multiple files - // Similar to mentions/index.ts strategy to prevent context window overflow - const approvedFilesCount = fileResults.filter((f) => f.status === "approved").length - const [perFileMaxLine, perFileMaxChar] = [ - maxReadFileLine > 0 && approvedFilesCount > 1 - ? Math.max(300, Math.ceil(maxReadFileLine / approvedFilesCount)) - : maxReadFileLine, - maxReadCharacterLimit > 0 && approvedFilesCount > 1 - ? Math.max(30_000, Math.ceil(maxReadCharacterLimit / approvedFilesCount)) - : maxReadCharacterLimit, - ] - for (const fileResult of fileResults) { if (fileResult.status !== "approved") continue const relPath = fileResult.path const fullPath = path.resolve(task.cwd, relPath) + const entry = fileResult.entry! try { - // Check if the path is a directory before attempting to read it + // Check if path is a directory const stats = await fs.stat(fullPath) if (stats.isDirectory()) { - const errorMsg = `Cannot read '${relPath}' because it is a directory. To view the contents of a directory, use the list_files tool instead.` + const errorMsg = `Cannot read '${relPath}' because it is a directory. Use list_files tool instead.` updateFileResult(relPath, { status: "error", error: errorMsg, - nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`, + nativeContent: `File: ${relPath}\nError: ${errorMsg}`, }) await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) continue } - const [totalLines, isBinary] = await Promise.all([ - countFileLines(fullPath), - isBinaryFileWithEncodingDetection(fullPath), - ]) + // Check for binary file + const isBinary = await isBinaryFileWithEncodingDetection(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, { - 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, { - 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}`, - 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)) { - // Use extractTextFromFile for supported binary formats (PDF, DOCX, etc.) - try { - const content = await extractTextFromFile(fullPath) - const numberedContent = addLineNumbers(content) - const lines = content.split("\n") - const lineCount = lines.length - - await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - - updateFileResult(relPath, { - nativeContent: - lineCount > 0 - ? `File: ${relPath}\nLines 1-${lineCount}:\n${numberedContent}` - : `File: ${relPath}\nNote: File is empty`, - }) - continue - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - updateFileResult(relPath, { - status: "error", - error: `Error extracting text: ${errorMsg}`, - nativeContent: `File: ${relPath}\nError: Error extracting text: ${errorMsg}`, - }) - await task.say("error", `Error extracting text from ${relPath}: ${errorMsg}`) - continue - } - } else { - const fileFormat = fileExtension.slice(1) || "bin" - updateFileResult(relPath, { - notice: `Binary file format: ${fileFormat}`, - nativeContent: `File: ${relPath}\nBinary file (${fileFormat}) - content not displayed`, - }) - continue - } - } - - if (fileResult.lineRanges && fileResult.lineRanges.length > 0) { - const nativeRangeResults: string[] = [] - - for (const range of fileResult.lineRanges) { - const content = addLineNumbers( - await readLines(fullPath, range.end - 1, range.start - 1), - range.start, - ) - nativeRangeResults.push(`Lines ${range.start}-${range.end}:\n${content}`) - } - - updateFileResult(relPath, { - 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, { - 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 (perFileMaxLine > 0 && totalLines > perFileMaxLine) { - const content = addLineNumbers(await readLines(fullPath, perFileMaxLine - 1, 0)) - let toolInfo = `Lines 1-${perFileMaxLine}:\n${content}\n` - - try { - const defResult = await parseSourceCodeDefinitionsForFile( - fullPath, - task.rooIgnoreController, - ) - if (defResult) { - const truncatedDefs = truncateDefinitionsToLineLimit(defResult, perFileMaxLine) - toolInfo += `\nCode Definitions:\n${truncatedDefs}\n` - } - - const notice = `Showing only ${perFileMaxLine} of ${totalLines} total lines. Use line_range if you need to read more lines` - toolInfo += `\nNote: ${notice}` - - updateFileResult(relPath, { - nativeContent: `File: ${relPath}\n${toolInfo}`, - }) - } 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)}`, - ) - } - } + await this.handleBinaryFile( + task, + relPath, + fullPath, + supportsImages, + maxImageFileSize, + maxTotalImageSize, + imageMemoryTracker, + updateFileResult, + ) continue } - const { id: modelId, info: modelInfo } = task.api.getModel() - const { contextTokens } = task.getTokenUsage() - const contextWindow = modelInfo.contextWindow - - const maxOutputTokens = - getModelMaxOutputTokens({ - modelId, - model: modelInfo, - settings: task.apiConfiguration, - }) ?? ANTHROPIC_DEFAULT_MAX_TOKENS - - // Calculate available token budget (60% of remaining context) - const remainingTokens = contextWindow - maxOutputTokens - (contextTokens || 0) - const safeReadBudget = Math.floor(remainingTokens * FILE_READ_BUDGET_PERCENT) - - let content: string - let toolInfo = "" - - if (safeReadBudget <= 0) { - // No budget available - const notice = "No available context budget for file reading" - toolInfo = `Note: ${notice}` - } else { - // Read file with incremental token counting - const result = await readFileWithTokenBudget(fullPath, { - budgetTokens: safeReadBudget, - }) - - content = addLineNumbers(result.content) - - // Apply character limit if specified and content exceeds it - if (perFileMaxChar > 0 && content.length > perFileMaxChar) { - const truncated = content.substring(0, perFileMaxChar) - const truncatedLines = truncated.split("\n").length - content = truncated - const charLimitNotice = `Content truncated to ${perFileMaxChar} characters (${truncatedLines} lines). Use line_range to read specific sections.` - toolInfo = `Lines 1-${truncatedLines}:\n${content}\n\nNote: ${charLimitNotice}` - } else if (!result.complete) { - // File was truncated - const notice = `File truncated: showing ${result.lineCount} lines (${result.tokenCount} tokens) due to context budget. Use line_range to read specific sections.` - toolInfo = - result.lineCount > 0 - ? `Lines 1-${result.lineCount}:\n${content}\n\nNote: ${notice}` - : `Note: ${notice}` - } else { - // Full file read - if (result.lineCount === 0) { - toolInfo = "Note: File is empty" - } else if (perFileMaxChar > 0 && content.length > perFileMaxChar) { - const truncated = content.substring(0, perFileMaxChar) - const truncatedLines = truncated.split("\n").length - content = truncated - const charLimitNotice = `Content truncated to ${perFileMaxChar} characters (${truncatedLines} lines).` - toolInfo = `Lines 1-${truncatedLines}:\n${content}\n\nNote: ${charLimitNotice}` - } else { - toolInfo = `Lines 1-${result.lineCount}:\n${content}` - } - } - } + // Read text file content with lossy UTF-8 conversion + // Reading as Buffer first allows graceful handling of non-UTF8 bytes + // (they become U+FFFD replacement characters instead of throwing) + const buffer = await fs.readFile(fullPath) + const fileContent = buffer.toString("utf-8") + const result = this.processTextFile(fileContent, entry) await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) updateFileResult(relPath, { - nativeContent: `File: ${relPath}\n${toolInfo}`, + nativeContent: `File: ${relPath}\n${result}`, }) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) updateFileResult(relPath, { status: "error", error: `Error reading file: ${errorMsg}`, - nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`, + nativeContent: `File: ${relPath}\nError: ${errorMsg}`, }) await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) } } - // Check if any files had errors or were blocked and mark the turn as failed - const hasErrors = fileResults.some((result) => result.status === "error" || result.status === "blocked") + // Phase 4: Build and return result + const hasErrors = fileResults.some((r) => r.status === "error" || r.status === "blocked") if (hasErrors) { task.didToolFailInCurrentTurn = true } - // Build final result - const finalResult = fileResults - .filter((result) => result.nativeContent) - .map((result) => result.nativeContent) - .join("\n\n---\n\n") + this.buildAndPushResult(task, fileResults, pushToolResult) + } catch (error) { + const relPath = filePath || "unknown" + const errorMsg = error instanceof Error ? error.message : String(error) - const fileImageUrls = fileResults - .filter((result) => result.imageDataUrl) - .map((result) => result.imageDataUrl as string) + updateFileResult(relPath, { + status: "error", + error: `Error reading file: ${errorMsg}`, + nativeContent: `File: ${relPath}\nError: ${errorMsg}`, + }) - let statusMessage = "" - let feedbackImages: any[] = [] + await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) + task.didToolFailInCurrentTurn = true - const deniedWithFeedback = fileResults.find((result) => result.status === "denied" && result.feedbackText) + const errorResult = fileResults + .filter((r) => r.nativeContent) + .map((r) => r.nativeContent) + .join("\n\n---\n\n") - 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, - ) + pushToolResult(errorResult || `Error: ${errorMsg}`) + } + } - if (approvedWithFeedback && approvedWithFeedback.feedbackText) { - statusMessage = formatResponse.toolApprovedWithFeedback(approvedWithFeedback.feedbackText) - feedbackImages = approvedWithFeedback.feedbackImages || [] - } + /** + * Process a text file according to the requested mode. + */ + private processTextFile(content: string, entry: InternalFileEntry): string { + const mode = entry.mode || "slice" + + if (mode === "indentation") { + // Indentation mode: semantic block extraction + // When anchor_line is not provided, default to offset (which defaults to 1) + const anchorLine = entry.anchor_line ?? entry.offset ?? 1 + const result = readWithIndentation(content, { + anchorLine, + maxLevels: entry.max_levels, + includeSiblings: entry.include_siblings, + includeHeader: entry.include_header, + limit: entry.limit ?? DEFAULT_LINE_LIMIT, + maxLines: entry.max_lines, + }) + + let output = result.content + + if (result.wasTruncated && result.includedRanges.length > 0) { + const [start, end] = result.includedRanges[0] + const nextOffset = end + 1 + const effectiveLimit = entry.limit ?? DEFAULT_LINE_LIMIT + // Put truncation warning at TOP (before content) to match @ mention format + output = `IMPORTANT: File content truncated. + Status: Showing lines ${start}-${end} of ${result.totalLines} total lines. + To read more: Use the read_file tool with offset=${nextOffset} and limit=${effectiveLimit}. + + ${result.content}` + } else if (result.includedRanges.length > 0) { + const rangeStr = result.includedRanges.map(([s, e]) => `${s}-${e}`).join(", ") + output += `\n\nIncluded ranges: ${rangeStr} (total: ${result.totalLines} lines)` } - const allImages = [...feedbackImages, ...fileImageUrls] + return output + } + + // Slice mode (default): simple offset/limit reading + // NOTE: read_file offset is 1-based externally; convert to 0-based for readWithSlice. + const offset1 = entry.offset ?? 1 + const offset0 = Math.max(0, offset1 - 1) + const limit = entry.limit ?? DEFAULT_LINE_LIMIT + + const result = readWithSlice(content, offset0, limit) + + let output = result.content + + if (result.wasTruncated) { + const startLine = offset1 + const endLine = offset1 + result.returnedLines - 1 + const nextOffset = endLine + 1 + // Put truncation warning at TOP (before content) to match @ mention format + output = `IMPORTANT: File content truncated. + Status: Showing lines ${startLine}-${endLine} of ${result.totalLines} total lines. + To read more: Use the read_file tool with offset=${nextOffset} and limit=${limit}. + + ${result.content}` + } else if (result.returnedLines === 0) { + output = "Note: File is empty" + } - const finalModelSupportsImages = task.api.getModel().info.supportsImages ?? false - const imagesToInclude = finalModelSupportsImages ? allImages : [] + return output + } - if (statusMessage || imagesToInclude.length > 0) { - const result = formatResponse.toolResult( - statusMessage || finalResult, - imagesToInclude.length > 0 ? imagesToInclude : undefined, + /** + * Handle binary file processing (images, PDF, DOCX, etc.). + */ + private async handleBinaryFile( + task: Task, + relPath: string, + fullPath: string, + supportsImages: boolean, + maxImageFileSize: number, + maxTotalImageSize: number, + imageMemoryTracker: ImageMemoryTracker, + updateFileResult: (path: string, updates: Partial) => void, + ): Promise { + const fileExtension = path.extname(relPath).toLowerCase() + const supportedBinaryFormats = getSupportedBinaryFormats() + + // Handle image files + if (isSupportedImageFormat(fileExtension)) { + try { + const validationResult = await validateImageForProcessing( + fullPath, + supportsImages, + maxImageFileSize, + maxTotalImageSize, + imageMemoryTracker.getTotalMemoryUsed(), ) - 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) - } + if (!validationResult.isValid) { + await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + updateFileResult(relPath, { + nativeContent: `File: ${relPath}\nNote: ${validationResult.notice}`, + }) + return } - } else { - pushToolResult(finalResult) + + const imageResult = await processImageFile(fullPath) + imageMemoryTracker.addMemoryUsage(imageResult.sizeInMB) + await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + + updateFileResult(relPath, { + nativeContent: `File: ${relPath}\nNote: ${imageResult.notice}`, + imageDataUrl: imageResult.dataUrl, + }) + return + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + updateFileResult(relPath, { + status: "error", + error: `Error reading image file: ${errorMsg}`, + nativeContent: `File: ${relPath}\nError: ${errorMsg}`, + }) + await task.say("error", `Error reading image file ${relPath}: ${errorMsg}`) + return } - } catch (error) { - const relPath = fileEntries[0]?.path || "unknown" - const errorMsg = error instanceof Error ? error.message : String(error) + } - if (fileResults.length > 0) { + // Handle other supported binary formats (PDF, DOCX, etc.) + if (supportedBinaryFormats && supportedBinaryFormats.includes(fileExtension)) { + try { + const content = await extractTextFromFile(fullPath) + const numberedContent = addLineNumbers(content) + const lineCount = content.split("\n").length + + await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + + updateFileResult(relPath, { + nativeContent: + lineCount > 0 + ? `File: ${relPath}\nLines 1-${lineCount}:\n${numberedContent}` + : `File: ${relPath}\nNote: File is empty`, + }) + return + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) updateFileResult(relPath, { status: "error", - error: `Error reading file: ${errorMsg}`, - nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`, + error: `Error extracting text: ${errorMsg}`, + nativeContent: `File: ${relPath}\nError: ${errorMsg}`, }) + await task.say("error", `Error extracting text from ${relPath}: ${errorMsg}`) + return } + } - await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) + // Unsupported binary format + const fileFormat = fileExtension.slice(1) || "bin" + updateFileResult(relPath, { + notice: `Binary file format: ${fileFormat}`, + nativeContent: `File: ${relPath}\nBinary file (${fileFormat}) - content not displayed`, + }) + } - // Mark that a tool failed in this turn - task.didToolFailInCurrentTurn = true + /** + * Request user approval for file reads. + */ + private async requestApproval( + task: Task, + filesToApprove: FileResult[], + updateFileResult: (path: string, updates: Partial) => void, + ): Promise { + if (filesToApprove.length === 0) return + + if (filesToApprove.length > 1) { + // Batch approval + const batchFiles = filesToApprove.map((fileResult) => { + const relPath = fileResult.path + const fullPath = path.resolve(task.cwd, relPath) + const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) + const readablePath = getReadablePath(task.cwd, relPath) - const errorResult = fileResults - .filter((result) => result.nativeContent) - .map((result) => result.nativeContent) - .join("\n\n---\n\n") + const lineSnippet = this.getLineSnippet(fileResult.entry!) + 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((fr) => { + updateFileResult(fr.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((fr) => { + updateFileResult(fr.path, { + status: "denied", + nativeContent: `File: ${fr.path}\nStatus: Denied by user`, + feedbackText: text, + feedbackImages: images, + }) + }) + } else { + // Individual permissions + 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", + nativeContent: `File: ${fileResult.path}\nStatus: Denied by user`, + }) + } + }) + + if (hasAnyDenial) task.didRejectTool = true + } catch { + task.didRejectTool = true + filesToApprove.forEach((fr) => { + updateFileResult(fr.path, { + status: "denied", + nativeContent: `File: ${fr.path}\nStatus: Denied by user`, + }) + }) + } + } + } else { + // Single file approval + const fileResult = filesToApprove[0] + const relPath = fileResult.path + const fullPath = path.resolve(task.cwd, relPath) + const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) + const lineSnippet = this.getLineSnippet(fileResult.entry!) + + const startLine = this.getStartLine(fileResult.entry!) + + const completeMessage = JSON.stringify({ + tool: "readFile", + path: getReadablePath(task.cwd, relPath), + isOutsideWorkspace, + content: fullPath, + reason: lineSnippet, + startLine, + } 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", + 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 }) + } + } + } + + /** + * Get the starting line number for navigation purposes. + */ + private getStartLine(entry: InternalFileEntry): number | undefined { + if (entry.mode === "indentation") { + // For indentation mode, always return the effective anchor line + return entry.anchor_line ?? entry.offset ?? 1 + } + const offset = entry.offset ?? 1 + return offset > 1 ? offset : undefined + } - pushToolResult(errorResult) + /** + * Generate a human-readable line snippet for approval messages. + */ + private getLineSnippet(entry: InternalFileEntry): string { + if (entry.mode === "indentation") { + // Always show indentation mode with the effective anchor line + const effectiveAnchor = entry.anchor_line ?? entry.offset ?? 1 + return `(indentation mode at line ${effectiveAnchor})` } + + const limit = entry.limit ?? DEFAULT_LINE_LIMIT + const offset1 = entry.offset ?? 1 + + if (offset1 > 1) { + return `(lines ${offset1}-${offset1 + limit - 1})` + } + + // Always show the line limit, even when using the default + return `(up to ${limit} lines)` } - 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}]` + /** + * Build and push the final result to the tool output. + */ + private buildAndPushResult(task: Task, fileResults: FileResult[], pushToolResult: PushToolResult): void { + const finalResult = fileResults + .filter((r) => r.nativeContent) + .map((r) => r.nativeContent) + .join("\n\n---\n\n") + + const fileImageUrls = fileResults.filter((r) => r.imageDataUrl).map((r) => r.imageDataUrl as string) + + let statusMessage = "" + let feedbackImages: string[] = [] + + const deniedWithFeedback = fileResults.find((r) => r.status === "denied" && r.feedbackText) + + if (deniedWithFeedback?.feedbackText) { + statusMessage = formatResponse.toolDeniedWithFeedback(deniedWithFeedback.feedbackText) + feedbackImages = deniedWithFeedback.feedbackImages || [] + } else if (task.didRejectTool) { + statusMessage = formatResponse.toolDenied() + } else { + const approvedWithFeedback = fileResults.find((r) => r.status === "approved" && r.feedbackText) + if (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") { + pushToolResult(statusMessage ? `${result}\n${finalResult}` : result) } else { - return `[${blockName} for ${paths.length} files]` + if (statusMessage) { + const textBlock = { type: "text" as const, text: finalResult } + pushToolResult([...result, textBlock] as any) + } else { + pushToolResult(result as any) + } } + } else { + pushToolResult(finalResult) + } + } + + getReadFileToolDescription(blockName: string, blockParams: { path?: string }): string + getReadFileToolDescription(blockName: string, nativeArgs: ReadFileParams): string + getReadFileToolDescription(blockName: string, second: unknown): string { + // If native typed args were provided + if (second && typeof second === "object" && "path" in second && typeof (second as any).path === "string") { + return `[${blockName} for '${(second as any).path}']` } - const blockParams = second as any + const blockParams = second as Record 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.]` + return `[${blockName} for '${blockParams.path}']` } - return `[${blockName} with missing files]` + return `[${blockName} with missing path]` } override async handlePartial(task: Task, block: ToolUse<"read_file">): Promise { + // Handle both legacy and new format for partial display let filePath = "" - if (block.nativeArgs && "files" in block.nativeArgs && Array.isArray(block.nativeArgs.files)) { - const files = block.nativeArgs.files - if (files.length > 0 && files[0]?.path) { - filePath = files[0].path + if (block.nativeArgs) { + if (isLegacyReadFileParams(block.nativeArgs)) { + // Legacy format - show first file + filePath = block.nativeArgs.files[0]?.path ?? "" + } else { + filePath = block.nativeArgs.path ?? "" } } @@ -682,6 +660,155 @@ export class ReadFileTool extends BaseTool<"read_file"> { } satisfies ClineSayTool) await task.ask("tool", partialMessage, block.partial).catch(() => {}) } + + /** + * Execute legacy multi-file format for backward compatibility. + * This handles the old format: { files: [{ path: string, lineRanges?: [...] }] } + */ + private async executeLegacy(fileEntries: FileEntry[], task: Task, callbacks: ToolCallbacks): Promise { + const { pushToolResult } = callbacks + const modelInfo = task.api.getModel().info + + // Temporary indicator for testing legacy format detection + console.warn("[read_file] Legacy format detected - using backward compatibility path") + + if (!fileEntries || fileEntries.length === 0) { + task.consecutiveMistakeCount++ + task.recordToolError("read_file") + const errorMsg = await task.sayAndCreateMissingParamError("read_file", "files") + pushToolResult(`Error: ${errorMsg}`) + return + } + + const supportsImages = modelInfo.supportsImages ?? false + + // Process each file sequentially (legacy behavior) + const results: string[] = [] + + for (const entry of fileEntries) { + const relPath = entry.path + const fullPath = path.resolve(task.cwd, relPath) + + // RooIgnore validation + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) + if (!accessAllowed) { + await task.say("rooignore_error", relPath) + const errorMsg = formatResponse.rooIgnoreError(relPath) + results.push(`File: ${relPath}\nError: ${errorMsg}`) + continue + } + + // Request approval for single file + const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) + let lineSnippet = "" + if (entry.lineRanges && entry.lineRanges.length > 0) { + const ranges = entry.lineRanges.map((range: LineRange) => `(lines ${range.start}-${range.end})`) + lineSnippet = ranges.join(", ") + } + + const completeMessage = JSON.stringify({ + tool: "readFile", + path: getReadablePath(task.cwd, relPath), + isOutsideWorkspace, + content: fullPath, + reason: lineSnippet || undefined, + } 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 + results.push(`File: ${relPath}\nStatus: Denied by user`) + continue + } + + if (text) await task.say("user_feedback", text, images) + + try { + // Check if the path is a directory + const stats = await fs.stat(fullPath) + if (stats.isDirectory()) { + const errorMsg = `Cannot read '${relPath}' because it is a directory.` + results.push(`File: ${relPath}\nError: ${errorMsg}`) + await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) + continue + } + + const isBinary = await isBinaryFileWithEncodingDetection(fullPath).catch(() => false) + + if (isBinary) { + // Handle binary files (images) + const fileExtension = path.extname(relPath).toLowerCase() + if (supportsImages && isSupportedImageFormat(fileExtension)) { + const state = await task.providerRef.deref()?.getState() + const { + maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + } = state ?? {} + const validation = await validateImageForProcessing( + fullPath, + supportsImages, + maxImageFileSize, + maxTotalImageSize, + 0, // Legacy path doesn't track cumulative memory + ) + if (!validation.isValid) { + results.push(`File: ${relPath}\nNotice: ${validation.notice ?? "Image validation failed"}`) + continue + } + const imageResult = await processImageFile(fullPath) + if (imageResult) { + results.push(`File: ${relPath}\n[Image file - content processed for vision model]`) + } + } else { + results.push(`File: ${relPath}\nError: Cannot read binary file`) + } + continue + } + + // Read text file + const rawContent = await fs.readFile(fullPath, "utf8") + + // Handle line ranges if specified + let content: string + if (entry.lineRanges && entry.lineRanges.length > 0) { + const lines = rawContent.split("\n") + const selectedLines: string[] = [] + + for (const range of entry.lineRanges) { + // Convert to 0-based index, ranges are 1-based inclusive + const startIdx = Math.max(0, range.start - 1) + const endIdx = Math.min(lines.length - 1, range.end - 1) + + for (let i = startIdx; i <= endIdx; i++) { + selectedLines.push(`${i + 1} | ${lines[i]}`) + } + } + content = selectedLines.join("\n") + } else { + // Read with default limits using slice mode + const result = readWithSlice(rawContent, 0, DEFAULT_LINE_LIMIT) + content = result.content + if (result.wasTruncated) { + content += `\n\n[File truncated: showing ${result.returnedLines} of ${result.totalLines} total lines]` + } + } + + results.push(`File: ${relPath}\n${content}`) + + // Track file in context + await task.fileContextTracker.trackFileContext(relPath, "read_tool") + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + results.push(`File: ${relPath}\nError: ${errorMsg}`) + await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) + } + } + + // Push combined results + pushToolResult(results.join("\n\n---\n\n")) + } } export const readFileTool = new ReadFileTool() diff --git a/src/core/tools/SkillTool.ts b/src/core/tools/SkillTool.ts new file mode 100644 index 0000000000..e346f9924c --- /dev/null +++ b/src/core/tools/SkillTool.ts @@ -0,0 +1,112 @@ +import { Task } from "../task/Task" +import { formatResponse } from "../prompts/responses" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +interface SkillParams { + skill: string + args?: string | null +} + +export class SkillTool extends BaseTool<"skill"> { + readonly name = "skill" as const + + async execute(params: SkillParams, task: Task, callbacks: ToolCallbacks): Promise { + const { skill: skillName, args } = params + const { askApproval, handleError, pushToolResult } = callbacks + + try { + // Validate skill name parameter + if (!skillName) { + task.consecutiveMistakeCount++ + task.recordToolError("skill") + task.didToolFailInCurrentTurn = true + pushToolResult(await task.sayAndCreateMissingParamError("skill", "skill")) + return + } + + task.consecutiveMistakeCount = 0 + + // Get SkillsManager from provider + const provider = task.providerRef.deref() + const skillsManager = provider?.getSkillsManager() + + if (!skillsManager) { + task.recordToolError("skill") + task.didToolFailInCurrentTurn = true + pushToolResult(formatResponse.toolError("Skills Manager not available")) + return + } + + // Get current mode for skill resolution + const state = await provider?.getState() + const currentMode = state?.mode ?? "code" + + // Fetch skill content + const skillContent = await skillsManager.getSkillContent(skillName, currentMode) + + if (!skillContent) { + // Get available skills for error message + const availableSkills = skillsManager.getSkillsForMode(currentMode) + const skillNames = availableSkills.map((s) => s.name) + + task.recordToolError("skill") + task.didToolFailInCurrentTurn = true + pushToolResult( + formatResponse.toolError( + `Skill '${skillName}' not found. Available skills: ${skillNames.join(", ") || "(none)"}`, + ), + ) + return + } + + // Build approval message + const toolMessage = JSON.stringify({ + tool: "skill", + skill: skillName, + args: args, + source: skillContent.source, + description: skillContent.description, + }) + + const didApprove = await askApproval("tool", toolMessage) + + if (!didApprove) { + return + } + + // Build the result message + let result = `Skill: ${skillName}` + + if (skillContent.description) { + result += `\nDescription: ${skillContent.description}` + } + + if (args) { + result += `\nProvided arguments: ${args}` + } + + result += `\nSource: ${skillContent.source}` + result += `\n\n--- Skill Instructions ---\n\n${skillContent.instructions}` + + pushToolResult(result) + } catch (error) { + await handleError("executing skill", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"skill">): Promise { + const skillName: string | undefined = block.params.skill + const args: string | undefined = block.params.args + + const partialMessage = JSON.stringify({ + tool: "skill", + skill: skillName, + args: args, + }) + + await task.ask("tool", partialMessage, block.partial).catch(() => {}) + } +} + +export const skillTool = new SkillTool() diff --git a/src/core/tools/UseMcpToolTool.ts b/src/core/tools/UseMcpToolTool.ts index 52c27c2381..1cfd5daf2d 100644 --- a/src/core/tools/UseMcpToolTool.ts +++ b/src/core/tools/UseMcpToolTool.ts @@ -255,13 +255,15 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { }) } - private processToolContent(toolResult: any): string { + private processToolContent(toolResult: any): { text: string; images: string[] } { if (!toolResult?.content || toolResult.content.length === 0) { - return "" + return { text: "", images: [] } } - return toolResult?.content - ?.map((item: any) => { + const images: string[] = [] + + const textContent = toolResult.content + .map((item: any) => { if (item.type === "text") { return item.text } @@ -269,10 +271,23 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { const { blob: _, ...rest } = item.resource return JSON.stringify(rest, null, 2) } + if (item.type === "image") { + // Handle image content (MCP image content has mimeType and data properties) + if (item.mimeType && item.data) { + if (item.data.startsWith("data:")) { + images.push(item.data) + } else { + images.push(`data:${item.mimeType};base64,${item.data}`) + } + } + return "" + } return "" }) .filter(Boolean) .join("\n\n") + + return { text: textContent, images } } private async executeToolAndProcessResult( @@ -296,18 +311,22 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { const toolResult = await task.providerRef.deref()?.getMcpHub()?.callTool(serverName, toolName, parsedArguments) let toolResultPretty = "(No response)" + let images: string[] = [] if (toolResult) { - const outputText = this.processToolContent(toolResult) + const { text: outputText, images: extractedImages } = this.processToolContent(toolResult) + images = extractedImages - if (outputText) { + if (outputText || images.length > 0) { await this.sendExecutionStatus(task, { executionId, status: "output", - response: outputText, + response: outputText || (images.length > 0 ? `[${images.length} image(s)]` : ""), }) - toolResultPretty = (toolResult.isError ? "Error:\n" : "") + outputText + toolResultPretty = + (toolResult.isError ? "Error:\n" : "") + + (outputText || (images.length > 0 ? `[${images.length} image(s) received]` : "")) } // Send completion status @@ -326,8 +345,8 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { }) } - await task.say("mcp_server_response", toolResultPretty) - pushToolResult(formatResponse.toolResult(toolResultPretty)) + await task.say("mcp_server_response", toolResultPretty, images) + pushToolResult(formatResponse.toolResult(toolResultPretty, images)) } } diff --git a/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts b/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts index 3e156dd7c4..bda80d711f 100644 --- a/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts +++ b/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts @@ -575,7 +575,7 @@ describe("ToolRepetitionDetector", () => { params: {}, // Empty for native protocol partial: false, nativeArgs: { - files: [{ path: "file1.ts" }], + path: "file1.ts", }, } @@ -585,7 +585,7 @@ describe("ToolRepetitionDetector", () => { params: {}, // Empty for native protocol partial: false, nativeArgs: { - files: [{ path: "file2.ts" }], + path: "file2.ts", }, } @@ -609,7 +609,7 @@ describe("ToolRepetitionDetector", () => { params: {}, // Empty for native protocol partial: false, nativeArgs: { - files: [{ path: "same-file.ts" }], + path: "same-file.ts", }, } @@ -625,7 +625,7 @@ describe("ToolRepetitionDetector", () => { expect(result.askUser).toBeDefined() }) - it("should differentiate read_file calls with multiple files in different orders", () => { + it("should treat different slice offsets as distinct read_file calls", () => { const detector = new ToolRepetitionDetector(2) const readFile1: ToolUse = { @@ -634,7 +634,9 @@ describe("ToolRepetitionDetector", () => { params: {}, partial: false, nativeArgs: { - files: [{ path: "a.ts" }, { path: "b.ts" }], + path: "a.ts", + offset: 1, + limit: 2000, }, } @@ -644,11 +646,13 @@ describe("ToolRepetitionDetector", () => { params: {}, partial: false, nativeArgs: { - files: [{ path: "b.ts" }, { path: "a.ts" }], + path: "a.ts", + offset: 2001, + limit: 2000, }, } - // Different order should be treated as different calls + // Different offsets should be treated as different calls expect(detector.check(readFile1).allowExecution).toBe(true) expect(detector.check(readFile2).allowExecution).toBe(true) }) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 08364cfb82..28ad88aaa5 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -1,14 +1,34 @@ -// npx vitest src/core/tools/__tests__/readFileTool.spec.ts +/** + * Tests for ReadFileTool - Codex-inspired file reading with indentation mode support. + * + * These tests cover: + * - Input validation (missing path parameter) + * - RooIgnore blocking + * - Directory read error handling + * - Binary file handling (images, PDF, DOCX, unsupported) + * - Image memory limits + * - Approval flow (approve, deny, feedback) + * - Text file processing (slice and indentation modes) + * - Output structure formatting + */ + +import path from "path" + +import { isBinaryFile } from "isbinaryfile" +import { isBinaryFileWithEncodingDetection } from "../../../utils/encoding" -import * as path from "path" +import { readFileTool, ReadFileTool } from "../ReadFileTool" +import { formatResponse } from "../../prompts/responses" +import { + validateImageForProcessing, + processImageFile, + isSupportedImageFormat, + ImageMemoryTracker, +} from "../helpers/imageHelpers" +import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../../integrations/misc/extract-text" +import { readWithIndentation, readWithSlice } from "../../../integrations/misc/indentation-reader" -import { countFileLines } from "../../../integrations/misc/line-counter" -import { readLines } from "../../../integrations/misc/read-lines" -import { extractTextFromFile } from "../../../integrations/misc/extract-text" -import { parseSourceCodeDefinitionsForFile } from "../../../services/tree-sitter" -import { isBinaryFileWithEncodingDetection } from "../../../utils/encoding" -import { ReadFileToolUse, ToolResponse } from "../../../shared/tools" -import { readFileTool } from "../ReadFileTool" +// ─── Mocks ──────────────────────────────────────────────────────────────────── vi.mock("path", async () => { const originalPath = await vi.importActual("path") @@ -19,79 +39,44 @@ vi.mock("path", async () => { } }) -// Already mocked above with hoisted fsPromises - -// vi.mock("isbinaryfile") -vi.mock("../../../utils/encoding", () => ({ - isBinaryFileWithEncodingDetection: vi.fn(), -})) - -vi.mock("../../../integrations/misc/line-counter") -vi.mock("../../../integrations/misc/read-lines") - -// Mock fs/promises readFile for image tests -const fsPromises = vi.hoisted(() => ({ +vi.mock("fs/promises", () => ({ readFile: vi.fn(), - stat: vi.fn().mockResolvedValue({ size: 1024 }), + stat: vi.fn(), })) -vi.mock("fs/promises", () => fsPromises) - -// Mock input content for tests -let mockInputContent = "" -// Create hoisted mocks that can be used in vi.mock factories -const { addLineNumbersMock, mockReadFileWithTokenBudget } = vi.hoisted(() => { - const addLineNumbersMock = vi.fn().mockImplementation((text: string, startLine = 1) => { - if (!text) return "" - const lines = typeof text === "string" ? text.split("\n") : [text] - return lines.map((line: string, i: number) => `${startLine + i} | ${line}`).join("\n") - }) - const mockReadFileWithTokenBudget = vi.fn() - return { addLineNumbersMock, mockReadFileWithTokenBudget } -}) +vi.mock("isbinaryfile", () => ({ + isBinaryFile: vi.fn().mockResolvedValue(false), +})) +vi.mock("../../../utils/encoding", () => ({ + isBinaryFileWithEncodingDetection: vi.fn().mockResolvedValue(false), +})) -// First create all the mocks vi.mock("../../../integrations/misc/extract-text", () => ({ extractTextFromFile: vi.fn(), - addLineNumbers: addLineNumbersMock, + addLineNumbers: vi.fn().mockImplementation((text: string, startLine = 1) => { + if (!text) return "" + const lines = text.split("\n") + return lines.map((line, i) => `${startLine + i} | ${line}`).join("\n") + }), getSupportedBinaryFormats: vi.fn(() => [".pdf", ".docx", ".ipynb"]), })) -vi.mock("../../../services/tree-sitter") -// Mock readFileWithTokenBudget - must be mocked to prevent actual file system access -vi.mock("../../../integrations/misc/read-file-with-budget", () => ({ - readFileWithTokenBudget: (...args: any[]) => mockReadFileWithTokenBudget(...args), +vi.mock("../../../integrations/misc/indentation-reader", () => ({ + readWithIndentation: vi.fn(), + readWithSlice: vi.fn(), })) -const extractTextFromFileMock = vi.fn() -const getSupportedBinaryFormatsMock = vi.fn(() => [".pdf", ".docx", ".ipynb"]) - -// Mock formatResponse - use vi.hoisted to ensure mocks are available before vi.mock -const { toolResultMock, imageBlocksMock } = vi.hoisted(() => { - const toolResultMock = vi.fn((text: string, images?: string[]) => { - if (images && images.length > 0) { - return [ - { type: "text", text }, - ...images.map((img) => { - const [header, data] = img.split(",") - const media_type = header.match(/:(.*?);/)?.[1] || "image/png" - return { type: "image", source: { type: "base64", media_type, data } } - }), - ] - } - return text - }) - const imageBlocksMock = vi.fn((images?: string[]) => { - return images - ? images.map((img) => { - const [header, data] = img.split(",") - const media_type = header.match(/:(.*?);/)?.[1] || "image/png" - return { type: "image", source: { type: "base64", media_type, data } } - }) - : [] - }) - return { toolResultMock, imageBlocksMock } -}) +vi.mock("../helpers/imageHelpers", () => ({ + DEFAULT_MAX_IMAGE_FILE_SIZE_MB: 5, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB: 20, + isSupportedImageFormat: vi.fn(), + validateImageForProcessing: vi.fn(), + processImageFile: vi.fn(), + ImageMemoryTracker: vi.fn().mockImplementation(() => ({ + getTotalMemoryUsed: vi.fn().mockReturnValue(0), + addMemoryUsage: vi.fn(), + })), +})) vi.mock("../../prompts/responses", () => ({ formatResponse: { @@ -105,1908 +90,652 @@ vi.mock("../../prompts/responses", () => ({ `The user approved this operation and responded with the message:\n\n${feedback}\n`, ), rooIgnoreError: vi.fn( - (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.`, + (filePath: string) => + `Access to ${filePath} 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.`, ), - toolResult: toolResultMock, - imageBlocks: imageBlocksMock, - }, -})) - -vi.mock("../../ignore/RooIgnoreController", () => ({ - RooIgnoreController: class { - initialize() { - return Promise.resolve() - } - validateAccess() { - return true - } + toolResult: vi.fn((text: string, images?: string[]) => { + if (images && images.length > 0) { + return [ + { type: "text", text }, + ...images.map((img) => { + const [header, data] = img.split(",") + const media_type = header.match(/:(.*?);/)?.[1] || "image/png" + return { type: "image", source: { type: "base64", media_type, data } } + }), + ] + } + return text + }), + imageBlocks: vi.fn((images?: string[]) => { + return images + ? images.map((img) => { + const [header, data] = img.split(",") + const media_type = header.match(/:(.*?);/)?.[1] || "image/png" + return { type: "image", source: { type: "base64", media_type, data } } + }) + : [] + }), }, })) -vi.mock("../../../utils/fs", () => ({ - fileExistsAtPath: vi.fn().mockReturnValue(true), -})) - -// Global beforeEach to ensure clean mock state between all test suites -beforeEach(() => { - // NOTE: Removed vi.clearAllMocks() to prevent interference with setImageSupport calls - // Instead, individual suites clear their specific mocks to maintain isolation - - // Explicitly reset the hoisted mock implementations to prevent cross-suite pollution - toolResultMock.mockImplementation((text: string, images?: string[]) => { - if (images && images.length > 0) { - return [ - { type: "text", text }, - ...images.map((img) => { - const [header, data] = img.split(",") - const media_type = header.match(/:(.*?);/)?.[1] || "image/png" - return { type: "image", source: { type: "base64", media_type, data } } - }), - ] - } - return text - }) - - imageBlocksMock.mockImplementation((images?: string[]) => { - return images - ? images.map((img) => { - const [header, data] = img.split(",") - const media_type = header.match(/:(.*?);/)?.[1] || "image/png" - return { type: "image", source: { type: "base64", media_type, data } } - }) - : [] - }) - - // Reset addLineNumbers mock to its default implementation (prevents cross-test pollution) - addLineNumbersMock.mockReset() - addLineNumbersMock.mockImplementation((text: string, startLine = 1) => { - if (!text) return "" - const lines = typeof text === "string" ? text.split("\n") : [text] - return lines.map((line: string, i: number) => `${startLine + i} | ${line}`).join("\n") - }) - - // Reset readFileWithTokenBudget mock with default implementation - mockReadFileWithTokenBudget.mockClear() - mockReadFileWithTokenBudget.mockImplementation(async (_filePath: string, _options: any) => { - // Default: return the mockInputContent with 5 lines - const lines = mockInputContent ? mockInputContent.split("\n") : [] - return { - content: mockInputContent, - tokenCount: mockInputContent.length / 4, // rough estimate - lineCount: lines.length, - complete: true, - } - }) -}) - -// Mock i18n translation function -vi.mock("../../../i18n", () => ({ - t: vi.fn((key: string, params?: Record) => { - // Map translation keys to English text - const translations: Record = { - "tools:readFile.imageWithSize": "Image file ({{size}} KB)", - "tools:readFile.imageTooLarge": - "Image file is too large ({{size}}). The maximum allowed size is {{max}} MB.", - "tools:readFile.linesRange": " (lines {{start}}-{{end}})", - "tools:readFile.definitionsOnly": " (definitions only)", - "tools:readFile.maxLines": " (max {{max}} lines)", - } - - let result = translations[key] || key - - // Simple template replacement - if (params) { - Object.entries(params).forEach(([param, value]) => { - result = result.replace(new RegExp(`{{${param}}}`, "g"), String(value)) - }) - } - - return result - }), -})) +// Mock fs/promises +const fsPromises = await import("fs/promises") +const mockedFsReadFile = vi.mocked(fsPromises.readFile) +const mockedFsStat = vi.mocked(fsPromises.stat) + +const mockedIsBinaryFile = vi.mocked(isBinaryFile) +const mockedIsBinaryFileWithEncodingDetection = vi.mocked(isBinaryFileWithEncodingDetection) +const mockedExtractTextFromFile = vi.mocked(extractTextFromFile) +const mockedReadWithSlice = vi.mocked(readWithSlice) +const mockedReadWithIndentation = vi.mocked(readWithIndentation) +const mockedIsSupportedImageFormat = vi.mocked(isSupportedImageFormat) +const mockedValidateImageForProcessing = vi.mocked(validateImageForProcessing) +const mockedProcessImageFile = vi.mocked(processImageFile) + +// ─── Test Helpers ───────────────────────────────────────────────────────────── + +interface MockTaskOptions { + supportsImages?: boolean + rooIgnoreAllowed?: boolean + maxImageFileSize?: number + maxTotalImageSize?: number +} -// Shared mock setup function to ensure consistent state across all test suites -function createMockCline(): any { - const mockProvider = { - getState: vi.fn(), - deref: vi.fn().mockReturnThis(), - } +function createMockTask(options: MockTaskOptions = {}) { + const { supportsImages = false, rooIgnoreAllowed = true, maxImageFileSize = 5, maxTotalImageSize = 20 } = options - const mockCline: any = { - cwd: "/", - task: "Test", - providerRef: mockProvider, - rooIgnoreController: { - validateAccess: vi.fn().mockReturnValue(true), + return { + cwd: "/test/workspace", + api: { + getModel: vi.fn().mockReturnValue({ + info: { supportsImages }, + }), }, + consecutiveMistakeCount: 0, + didToolFailInCurrentTurn: false, + didRejectTool: false, + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }), say: vi.fn().mockResolvedValue(undefined), - ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), - presentAssistantMessage: vi.fn(), - handleError: vi.fn().mockResolvedValue(undefined), - pushToolResult: vi.fn(), + sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing required parameter: path"), + recordToolError: vi.fn(), + rooIgnoreController: { + validateAccess: vi.fn().mockReturnValue(rooIgnoreAllowed), + }, fileContextTracker: { trackFileContext: vi.fn().mockResolvedValue(undefined), }, - recordToolUsage: vi.fn().mockReturnValue(undefined), - recordToolError: vi.fn().mockReturnValue(undefined), - didRejectTool: false, - getTokenUsage: vi.fn().mockReturnValue({ - contextTokens: 10000, - }), - apiConfiguration: { - apiProvider: "anthropic", - toolProtocol: "native", - }, - // Set taskToolProtocol to ensure native protocol is used - taskToolProtocol: "native", - // CRITICAL: Always ensure image support is enabled - api: { - getModel: vi.fn().mockReturnValue({ - id: "test-model", - info: { - supportsImages: true, - contextWindow: 200000, - maxTokens: 4096, - supportsPromptCache: false, - // (native tool support is determined at request-time; no model flag) - }, + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + maxImageFileSize, + maxTotalImageSize, + }), }), }, } - - return { mockCline, mockProvider } } -// Helper function to set image support without affecting shared state -function setImageSupport(mockCline: any, supportsImages: boolean | undefined): void { - mockCline.api = { - getModel: vi.fn().mockReturnValue({ - id: "test-model", - info: { supportsImages }, - }), +function createMockCallbacks() { + return { + pushToolResult: vi.fn(), + askApproval: vi.fn(), + handleError: vi.fn(), } } -describe("read_file tool with maxReadFileLine setting", () => { - // Test data - const testFilePath = "test/file.txt" - const absoluteFilePath = "/test/file.txt" - const fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" - const numberedFileContent = "1 | Line 1\n2 | Line 2\n3 | Line 3\n4 | Line 4\n5 | Line 5\n" - const sourceCodeDef = "\n\n# file.txt\n1--5 | Content" - - // Mocked functions with correct types - const mockedCountFileLines = vi.mocked(countFileLines) - const mockedReadLines = vi.mocked(readLines) - const mockedExtractTextFromFile = vi.mocked(extractTextFromFile) - const mockedParseSourceCodeDefinitionsForFile = vi.mocked(parseSourceCodeDefinitionsForFile) - - const mockedIsBinaryFile = vi.mocked(isBinaryFileWithEncodingDetection) - const mockedPathResolve = vi.mocked(path.resolve) - - let mockCline: any - let mockProvider: any - let toolResult: ToolResponse | undefined +// ─── Tests ──────────────────────────────────────────────────────────────────── +describe("ReadFileTool", () => { beforeEach(() => { - // Clear specific mocks (not all mocks to preserve shared state) - mockedCountFileLines.mockClear() - mockedExtractTextFromFile.mockClear() - mockedIsBinaryFile.mockClear() - mockedPathResolve.mockClear() - addLineNumbersMock.mockClear() - extractTextFromFileMock.mockClear() - toolResultMock.mockClear() - - // Use shared mock setup function - const mocks = createMockCline() - mockCline = mocks.mockCline - mockProvider = mocks.mockProvider - - // Explicitly disable image support for text file tests to prevent cross-suite pollution - setImageSupport(mockCline, false) - - mockedPathResolve.mockReturnValue(absoluteFilePath) - mockedIsBinaryFile.mockResolvedValue(false) + vi.clearAllMocks() - // Mock fsPromises.stat to return a file (not directory) by default - fsPromises.stat.mockResolvedValue({ - isDirectory: () => false, - isFile: () => true, - isSymbolicLink: () => false, - } as any) + // Default mock implementations + mockedFsStat.mockResolvedValue({ isDirectory: () => false } as any) + mockedIsBinaryFile.mockResolvedValue(false) + mockedIsBinaryFileWithEncodingDetection.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("test content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | test content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + }) - mockInputContent = fileContent + describe("input validation", () => { + it("should return error when path is missing", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - // Setup the extractTextFromFile mock implementation with the current mockInputContent - // Reset the spy before each test - addLineNumbersMock.mockClear() + await readFileTool.execute({ path: "" } as any, mockTask as any, callbacks) - // Setup the extractTextFromFile mock to call our spy - mockedExtractTextFromFile.mockImplementation((_filePath) => { - // Call the spy and return its result - return Promise.resolve(addLineNumbersMock(mockInputContent)) + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(mockTask.recordToolError).toHaveBeenCalledWith("read_file") + expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("read_file", "path") + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error:")) }) - toolResult = undefined - }) + it("should return error when path is undefined", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - /** - * Helper function to execute the read file tool with different maxReadFileLine settings - */ - async function executeReadFileTool( - params: Partial = {}, - options: { - maxReadFileLine?: number - totalLines?: number - skipAddLineNumbersCheck?: boolean // Flag to skip addLineNumbers check - path?: string - start_line?: string - end_line?: string - toolProtocol?: "xml" | "native" - } = {}, - ): Promise { - // Configure mocks based on test scenario - const maxReadFileLine = options.maxReadFileLine ?? 500 - const totalLines = options.totalLines ?? 5 - - mockProvider.getState.mockResolvedValue({ maxReadFileLine, maxImageFileSize: 20, maxTotalImageSize: 20 }) - mockedCountFileLines.mockResolvedValue(totalLines) - - // Reset the spy before each test - addLineNumbersMock.mockClear() - - const lineRanges = - options.start_line && options.end_line - ? [ - { - start: Number(options.start_line), - end: Number(options.end_line), - }, - ] - : [] + await readFileTool.execute({} as any, mockTask as any, callbacks) - // Create a tool use object - const toolUse: ReadFileToolUse = { - type: "tool_use", - name: "read_file", - params: { ...params }, - partial: false, - nativeArgs: { - files: [ - { - path: options.path || testFilePath, - lineRanges, - }, - ], - }, - } - - await readFileTool.handle(mockCline, toolUse, { - askApproval: mockCline.ask, - handleError: vi.fn(), - pushToolResult: (result: ToolResponse) => { - toolResult = result - }, + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error:")) }) - return toolResult - } - - describe("when maxReadFileLine is negative", () => { - it("should read the entire file using extractTextFromFile", async () => { - // Setup - use default mockInputContent - mockInputContent = fileContent + it("should return error when offset is 0 or negative", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - // Execute - const result = await executeReadFileTool({}, { maxReadFileLine: -1 }) + await readFileTool.execute({ path: "test.txt", offset: 0 }, mockTask as any, callbacks) - // Verify - check that the result contains the expected native format elements - expect(result).toContain(`File: ${testFilePath}`) - expect(result).toContain(`Lines 1-5:`) + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("offset must be a 1-indexed line number"), + ) }) - it("should not show line snippet in approval message when maxReadFileLine is -1", async () => { - // This test verifies the line snippet behavior for the approval message - // Setup - use default mockInputContent - mockInputContent = fileContent + it("should return error when offset is negative", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - // Execute - we'll reuse executeReadFileTool to run the tool - await executeReadFileTool({}, { maxReadFileLine: -1 }) + await readFileTool.execute({ path: "test.txt", offset: -5 }, mockTask as any, callbacks) - // Verify the empty line snippet for full read was passed to the approval message - // Look at the parameters passed to the 'ask' method in the approval message - const askCall = mockCline.ask.mock.calls[0] - const completeMessage = JSON.parse(askCall[1]) - - // Verify the reason (lineSnippet) is empty or undefined for full read - expect(completeMessage.reason).toBeFalsy() + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("offset must be a 1-indexed line number"), + ) }) - }) - describe("when maxReadFileLine is 0", () => { - it("should return an empty content with source code definitions", async () => { - // Setup - for maxReadFileLine = 0, the implementation won't call readLines - mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(sourceCodeDef) + it("should return error when anchor_line is 0 or negative", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - // Execute - skip addLineNumbers check as it's not called for maxReadFileLine=0 - const result = await executeReadFileTool( - {}, + await readFileTool.execute( { - maxReadFileLine: 0, - totalLines: 5, - skipAddLineNumbersCheck: true, + path: "test.txt", + mode: "indentation", + indentation: { anchor_line: 0 }, }, + mockTask as any, + callbacks, ) - // Verify - native format - expect(result).toContain(`File: ${testFilePath}`) - expect(result).toContain(`Code Definitions:`) - - // Verify native structure - expect(result).toContain("Note: Showing only 0 of 5 total lines") - expect(result).toContain(sourceCodeDef.trim()) - expect(result).not.toContain("Lines 1-") // No content when maxReadFileLine is 0 - }) - }) - - describe("when maxReadFileLine is less than file length", () => { - it("should read only maxReadFileLine lines and add source code definitions", async () => { - // Setup - const content = "Line 1\nLine 2\nLine 3" - const numberedContent = "1 | Line 1\n2 | Line 2\n3 | Line 3" - mockedReadLines.mockResolvedValue(content) - mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(sourceCodeDef) - - // Setup addLineNumbers to always return numbered content - addLineNumbersMock.mockReturnValue(numberedContent) - - // Execute - const result = await executeReadFileTool({}, { maxReadFileLine: 3 }) - - // Verify - native format - expect(result).toContain(`File: ${testFilePath}`) - expect(result).toContain(`Lines 1-3:`) - expect(result).toContain(`Code Definitions:`) - expect(result).toContain("Note: Showing only 3 of 5 total lines") - }) - - it("should truncate code definitions when file exceeds maxReadFileLine", async () => { - // Setup - file with 100 lines but we'll only read first 30 - const content = "Line 1\nLine 2\nLine 3" - const numberedContent = "1 | Line 1\n2 | Line 2\n3 | Line 3" - const fullDefinitions = `# file.txt -10--20 | function foo() { -50--60 | function bar() { -80--90 | function baz() {` - const truncatedDefinitions = `# file.txt -10--20 | function foo() {` - - mockedReadLines.mockResolvedValue(content) - mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(fullDefinitions) - addLineNumbersMock.mockReturnValue(numberedContent) - - // Execute with maxReadFileLine = 30 - const result = await executeReadFileTool({}, { maxReadFileLine: 30, totalLines: 100 }) - - // Verify - native format - expect(result).toContain(`File: ${testFilePath}`) - expect(result).toContain(`Lines 1-30:`) - expect(result).toContain(`Code Definitions:`) - - // Should include foo (starts at line 10) but not bar (starts at line 50) or baz (starts at line 80) - expect(result).toContain("10--20 | function foo()") - expect(result).not.toContain("50--60 | function bar()") - expect(result).not.toContain("80--90 | function baz()") - - expect(result).toContain("Note: Showing only 30 of 100 total lines") - }) - - it("should handle truncation when all definitions are beyond the line limit", async () => { - // Setup - all definitions start after maxReadFileLine - const content = "Line 1\nLine 2\nLine 3" - const numberedContent = "1 | Line 1\n2 | Line 2\n3 | Line 3" - const fullDefinitions = `# file.txt -50--60 | function foo() { -80--90 | function bar() {` - - mockedReadLines.mockResolvedValue(content) - mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(fullDefinitions) - addLineNumbersMock.mockReturnValue(numberedContent) - - // Execute with maxReadFileLine = 30 - const result = await executeReadFileTool({}, { maxReadFileLine: 30, totalLines: 100 }) - - // Verify - native format - expect(result).toContain(`File: ${testFilePath}`) - expect(result).toContain(`Lines 1-30:`) - expect(result).toContain(`Code Definitions:`) - expect(result).toContain("# file.txt") - expect(result).not.toContain("50--60 | function foo()") - expect(result).not.toContain("80--90 | function bar()") - }) - }) - - describe("when maxReadFileLine equals or exceeds file length", () => { - it("should use extractTextFromFile when maxReadFileLine > totalLines", async () => { - // Setup - mockedCountFileLines.mockResolvedValue(5) // File shorter than maxReadFileLine - mockInputContent = fileContent - - // Execute - const result = await executeReadFileTool({}, { maxReadFileLine: 10, totalLines: 5 }) - - // Verify - native format - expect(result).toContain(`File: ${testFilePath}`) - expect(result).toContain(`Lines 1-5:`) - }) - - it("should read with extractTextFromFile when file has few lines", async () => { - // Setup - mockedCountFileLines.mockResolvedValue(3) // File shorter than maxReadFileLine - const threeLineContent = "Line 1\nLine 2\nLine 3" - mockInputContent = threeLineContent - - // Configure the mock to return the correct content for this test - mockReadFileWithTokenBudget.mockResolvedValueOnce({ - content: threeLineContent, - tokenCount: threeLineContent.length / 4, - lineCount: 3, - complete: true, - }) - - // Execute - const result = await executeReadFileTool({}, { maxReadFileLine: 5, totalLines: 3 }) - - // Verify - native format - expect(result).toContain(`File: ${testFilePath}`) - expect(result).toContain(`Lines 1-3:`) - }) - }) - - describe("when file is binary", () => { - it("should always use extractTextFromFile regardless of maxReadFileLine", async () => { - // Setup - mockedIsBinaryFile.mockResolvedValue(true) - mockedCountFileLines.mockResolvedValue(3) - mockedExtractTextFromFile.mockResolvedValue("") - - // Execute - const result = await executeReadFileTool({}, { maxReadFileLine: 3, totalLines: 3 }) - - // Verify - native format for binary files - expect(result).toContain(`File: ${testFilePath}`) - expect(typeof result).toBe("string") + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("anchor_line must be a 1-indexed line number"), + ) }) - }) - describe("with range parameters", () => { - it("should honor start_line and end_line when provided", async () => { - // Setup - mockedReadLines.mockResolvedValue("Line 2\nLine 3\nLine 4") + it("should return error when anchor_line is negative", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - // Execute using executeReadFileTool with range parameters - const rangeResult = await executeReadFileTool( - {}, + await readFileTool.execute( { - start_line: "2", - end_line: "4", + path: "test.txt", + mode: "indentation", + indentation: { anchor_line: -10 }, }, + mockTask as any, + callbacks, ) - // Verify - native format - expect(rangeResult).toContain(`File: ${testFilePath}`) - expect(rangeResult).toContain(`Lines 2-4:`) + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("anchor_line must be a 1-indexed line number"), + ) }) }) -}) -describe("read_file tool output structure", () => { - // Test basic native structure - const testFilePath = "test/file.txt" - const absoluteFilePath = "/test/file.txt" - const fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" - - const mockedCountFileLines = vi.mocked(countFileLines) - const mockedExtractTextFromFile = vi.mocked(extractTextFromFile) - const mockedIsBinaryFile = vi.mocked(isBinaryFileWithEncodingDetection) - const mockedPathResolve = vi.mocked(path.resolve) - const mockedFsReadFile = vi.mocked(fsPromises.readFile) - const imageBuffer = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", - "base64", - ) - - let mockCline: any - let mockProvider: any - let toolResult: ToolResponse | undefined + describe("RooIgnore handling", () => { + it("should block access to rooignore-protected files", async () => { + const mockTask = createMockTask({ rooIgnoreAllowed: false }) + const callbacks = createMockCallbacks() - beforeEach(() => { - // Clear specific mocks (not all mocks to preserve shared state) - mockedCountFileLines.mockClear() - mockedExtractTextFromFile.mockClear() - mockedIsBinaryFile.mockClear() - mockedPathResolve.mockClear() - addLineNumbersMock.mockClear() - extractTextFromFileMock.mockClear() - toolResultMock.mockClear() - - // CRITICAL: Reset fsPromises mocks to prevent cross-test contamination - fsPromises.stat.mockClear() - fsPromises.stat.mockResolvedValue({ - size: 1024, - isDirectory: () => false, - isFile: () => true, - isSymbolicLink: () => false, - } as any) - fsPromises.readFile.mockClear() - - // Use shared mock setup function - const mocks = createMockCline() - mockCline = mocks.mockCline - mockProvider = mocks.mockProvider - - // Explicitly enable image support for this test suite (contains image memory tests) - setImageSupport(mockCline, true) - - mockedPathResolve.mockReturnValue(absoluteFilePath) - mockedIsBinaryFile.mockResolvedValue(false) + await readFileTool.execute({ path: "secret.env" }, mockTask as any, callbacks) - // Set default implementation for extractTextFromFile - mockedExtractTextFromFile.mockImplementation((filePath) => { - return Promise.resolve(addLineNumbersMock(mockInputContent)) + expect(mockTask.say).toHaveBeenCalledWith("rooignore_error", "secret.env") + expect(formatResponse.rooIgnoreError).toHaveBeenCalledWith("secret.env") + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("blocked by the .rooignore")) }) - - mockInputContent = fileContent - - // Setup mock provider with default maxReadFileLine - mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, maxTotalImageSize: 20 }) // Default to full file read - - // Add additional properties needed for missing param validation tests - mockCline.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing required parameter") - - toolResult = undefined }) - async function executeReadFileTool( - options: { - totalLines?: number - maxReadFileLine?: number - isBinary?: boolean - validateAccess?: boolean - filePath?: string - } = {}, - ): Promise { - // Configure mocks based on test scenario - const totalLines = options.totalLines ?? 5 - const maxReadFileLine = options.maxReadFileLine ?? 500 - const isBinary = options.isBinary ?? false - const validateAccess = options.validateAccess ?? true - - mockProvider.getState.mockResolvedValue({ maxReadFileLine, maxImageFileSize: 20, maxTotalImageSize: 20 }) - mockedCountFileLines.mockResolvedValue(totalLines) - mockedIsBinaryFile.mockResolvedValue(isBinary) - mockCline.rooIgnoreController.validateAccess = vi.fn().mockReturnValue(validateAccess) - const filePath = options.filePath ?? testFilePath - - // Create a tool use object - const toolUse: ReadFileToolUse = { - type: "tool_use", - name: "read_file", - params: {}, - partial: false, - nativeArgs: { - files: [{ path: filePath, lineRanges: [] }], - }, - } - - // Execute the tool - await readFileTool.handle(mockCline, toolUse, { - askApproval: mockCline.ask, - handleError: vi.fn(), - pushToolResult: (result: ToolResponse) => { - toolResult = result - }, - }) - - return toolResult - } - - describe("Basic Structure Tests", () => { - it("should produce native output with proper format", async () => { - // Setup - const numberedContent = "1 | Line 1\n2 | Line 2\n3 | Line 3\n4 | Line 4\n5 | Line 5" - - // Configure mockReadFileWithTokenBudget to return the 5-line content - mockReadFileWithTokenBudget.mockResolvedValueOnce({ - content: fileContent, // "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" - tokenCount: fileContent.length / 4, - lineCount: 5, - complete: true, - }) + describe("directory handling", () => { + it("should return error when trying to read a directory", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - mockProvider.getState.mockResolvedValue({ - maxReadFileLine: -1, - maxImageFileSize: 20, - maxTotalImageSize: 20, - }) // Allow up to 20MB per image and total size + mockedFsStat.mockResolvedValue({ isDirectory: () => true } as any) - // Execute - const result = await executeReadFileTool() + await readFileTool.execute({ path: "src/utils" }, mockTask as any, callbacks) - // Verify native format - expect(result).toBe(`File: ${testFilePath}\nLines 1-5:\n${numberedContent}`) + expect(mockTask.say).toHaveBeenCalledWith( + "error", + expect.stringContaining("Cannot read 'src/utils' because it is a directory"), + ) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("it is a directory")) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) }) + }) - it("should follow the correct native structure format", async () => { - // Setup - mockInputContent = fileContent - // Execute - const result = await executeReadFileTool({ maxReadFileLine: -1 }) - - // Verify using regex to check native structure - const nativeStructureRegex = new RegExp(`^File: ${testFilePath}\\nLines 1-5:\\n.*$`, "s") - expect(result).toMatch(nativeStructureRegex) + describe("image handling", () => { + beforeEach(() => { + mockedIsBinaryFileWithEncodingDetection.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(true) }) - it("should handle empty files correctly", async () => { - // Setup - mockedCountFileLines.mockResolvedValue(0) + it("should process image file when model supports images", async () => { + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() - // Configure mockReadFileWithTokenBudget to return empty content - mockReadFileWithTokenBudget.mockResolvedValueOnce({ - content: "", - tokenCount: 0, - lineCount: 0, - complete: true, + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: true, + sizeInMB: 0.5, + }) + mockedProcessImageFile.mockResolvedValue({ + dataUrl: "data:image/png;base64,abc123", + buffer: Buffer.from("test"), + sizeInKB: 512, + sizeInMB: 0.5, + notice: "Image processed successfully", }) - mockProvider.getState.mockResolvedValue({ - maxReadFileLine: -1, - maxImageFileSize: 20, - maxTotalImageSize: 20, - }) // Allow up to 20MB per image and total size - - // Execute - const result = await executeReadFileTool({ totalLines: 0 }) + await readFileTool.execute({ path: "image.png" }, mockTask as any, callbacks) - // Verify native format for empty file - expect(result).toBe(`File: ${testFilePath}\nNote: File is empty`) + expect(mockedValidateImageForProcessing).toHaveBeenCalled() + expect(mockedProcessImageFile).toHaveBeenCalled() + expect(callbacks.pushToolResult).toHaveBeenCalled() }) - describe("Total Image Memory Limit", () => { - const testImages = [ - { path: "test/image1.png", sizeKB: 5120 }, // 5MB - { path: "test/image2.jpg", sizeKB: 10240 }, // 10MB - { path: "test/image3.gif", sizeKB: 8192 }, // 8MB - ] - - // Define imageBuffer for this test suite - const imageBuffer = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", - "base64", - ) + it("should skip image when model does not support images", async () => { + const mockTask = createMockTask({ supportsImages: false }) + const callbacks = createMockCallbacks() - beforeEach(() => { - // CRITICAL: Reset fsPromises mocks to prevent cross-test contamination within this suite - fsPromises.stat.mockClear() - fsPromises.readFile.mockClear() + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: false, + reason: "unsupported_model", + notice: "Model does not support image processing", }) - async function executeReadMultipleImagesTool(imagePaths: string[]): Promise { - // Ensure image support is enabled before calling the tool - setImageSupport(mockCline, true) - - const toolUse: ReadFileToolUse = { - type: "tool_use", - name: "read_file", - params: {}, - partial: false, - nativeArgs: { - files: imagePaths.map((p) => ({ path: p, lineRanges: [] })), - }, - } - - let localResult: ToolResponse | undefined - await readFileTool.handle(mockCline, toolUse, { - askApproval: mockCline.ask, - handleError: vi.fn(), - pushToolResult: (result: ToolResponse) => { - localResult = result - }, - }) - // 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) { - return mockCline.pushToolResult.mock.calls[0][0] - } - - return localResult - } + await readFileTool.execute({ path: "image.png" }, mockTask as any, callbacks) - it("should allow multiple images under the total memory limit", async () => { - // Setup required mocks (don't clear all mocks - preserve API setup) - mockedIsBinaryFile.mockResolvedValue(true) - mockedCountFileLines.mockResolvedValue(0) - fsPromises.readFile.mockResolvedValue( - Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", - "base64", - ), - ) - - // Setup mockProvider - mockProvider.getState.mockResolvedValue({ - maxReadFileLine: -1, - maxImageFileSize: 20, - maxTotalImageSize: 20, - }) // Allow up to 20MB per image and total size - - // Setup mockCline properties (preserve existing API) - mockCline.cwd = "/" - mockCline.task = "Test" - mockCline.providerRef = mockProvider - mockCline.rooIgnoreController = { - validateAccess: vi.fn().mockReturnValue(true), - } - mockCline.say = vi.fn().mockResolvedValue(undefined) - mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) - mockCline.presentAssistantMessage = vi.fn() - mockCline.handleError = vi.fn().mockResolvedValue(undefined) - mockCline.pushToolResult = vi.fn() - mockCline.fileContextTracker = { - trackFileContext: vi.fn().mockResolvedValue(undefined), - } - mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) - mockCline.recordToolError = vi.fn().mockReturnValue(undefined) - setImageSupport(mockCline, true) - - // Setup - images that fit within 20MB limit - const smallImages = [ - { path: "test/small1.png", sizeKB: 2048 }, // 2MB - { path: "test/small2.jpg", sizeKB: 3072 }, // 3MB - { path: "test/small3.gif", sizeKB: 4096 }, // 4MB - ] // Total: 9MB (under 20MB limit) - - // Mock file stats for each image - fsPromises.stat = vi.fn().mockImplementation((filePath) => { - const normalizedFilePath = path.normalize(filePath.toString()) - const image = smallImages.find((img) => normalizedFilePath.includes(path.normalize(img.path))) - return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024, isDirectory: () => false }) - }) - - // Mock path.resolve for each image - mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) - - // Execute - const result = await executeReadMultipleImagesTool(smallImages.map((img) => img.path)) - - // Verify all images were processed (should be a multi-part response) - expect(Array.isArray(result)).toBe(true) - const parts = result as any[] - - // Should have text part and 3 image parts - const textPart = parts.find((p) => p.type === "text")?.text - const imageParts = parts.filter((p) => p.type === "image") - - expect(textPart).toBeDefined() - expect(imageParts).toHaveLength(3) - - // Verify no memory limit notices - expect(textPart).not.toContain("Total image memory would exceed") - }) - - it("should skip images that would exceed the total memory limit", async () => { - // Setup required mocks (don't clear all mocks) - mockedIsBinaryFile.mockResolvedValue(true) - mockedCountFileLines.mockResolvedValue(0) - fsPromises.readFile.mockResolvedValue( - Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", - "base64", - ), - ) - - // Setup mockProvider - mockProvider.getState.mockResolvedValue({ - maxReadFileLine: -1, - maxImageFileSize: 15, - maxTotalImageSize: 20, - }) // Allow up to 15MB per image and 20MB total size - - // Setup mockCline properties - mockCline.cwd = "/" - mockCline.task = "Test" - mockCline.providerRef = mockProvider - mockCline.rooIgnoreController = { - validateAccess: vi.fn().mockReturnValue(true), - } - mockCline.say = vi.fn().mockResolvedValue(undefined) - mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) - mockCline.presentAssistantMessage = vi.fn() - mockCline.handleError = vi.fn().mockResolvedValue(undefined) - mockCline.pushToolResult = vi.fn() - mockCline.fileContextTracker = { - trackFileContext: vi.fn().mockResolvedValue(undefined), - } - mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) - mockCline.recordToolError = vi.fn().mockReturnValue(undefined) - setImageSupport(mockCline, true) - - // Setup - images where later ones would exceed 20MB total limit - // Each must be under 5MB per-file limit (5120KB) - const largeImages = [ - { path: "test/large1.png", sizeKB: 5017 }, // ~4.9MB - { path: "test/large2.jpg", sizeKB: 5017 }, // ~4.9MB - { path: "test/large3.gif", sizeKB: 5017 }, // ~4.9MB - { path: "test/large4.png", sizeKB: 5017 }, // ~4.9MB - { path: "test/large5.jpg", sizeKB: 5017 }, // ~4.9MB - This should be skipped (total would be ~24.5MB > 20MB) - ] - - // Mock file stats for each image - fsPromises.stat = vi.fn().mockImplementation((filePath) => { - const normalizedFilePath = path.normalize(filePath.toString()) - const image = largeImages.find((img) => normalizedFilePath.includes(path.normalize(img.path))) - return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024, isDirectory: () => false }) - }) - - // Mock path.resolve for each image - mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) - - // Execute - const result = await executeReadMultipleImagesTool(largeImages.map((img) => img.path)) - - // Verify result structure - should be a mix of successful images and skipped notices - expect(Array.isArray(result)).toBe(true) - const parts = result as any[] - - const textPart = Array.isArray(result) ? result.find((p) => p.type === "text")?.text : result - const imageParts = Array.isArray(result) ? result.filter((p) => p.type === "image") : [] - - expect(textPart).toBeDefined() + expect(mockedValidateImageForProcessing).toHaveBeenCalled() + expect(mockedProcessImageFile).not.toHaveBeenCalled() + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Model does not support image processing"), + ) + }) - // Debug: Show what we actually got vs expected - if (imageParts.length !== 4) { - throw new Error( - `Expected 4 images, got ${imageParts.length}. Full result: ${JSON.stringify(result, null, 2)}. Text part: ${textPart}`, - ) - } - expect(imageParts).toHaveLength(4) // First 4 images should be included (~19.6MB total) + it("should skip image when file exceeds size limit", async () => { + const mockTask = createMockTask({ supportsImages: true, maxImageFileSize: 1 }) + const callbacks = createMockCallbacks() - // Verify memory limit notice for the fifth image - expect(textPart).toContain("Image skipped to avoid size limit (20MB)") - expect(textPart).toMatch(/Current: \d+(\.\d+)? MB/) - expect(textPart).toMatch(/this file: \d+(\.\d+)? MB/) + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: false, + reason: "size_limit", + notice: "Image file size (10 MB) exceeds the maximum allowed size (1 MB)", }) - it("should track memory usage correctly across multiple images", async () => { - // Setup mocks (don't clear all mocks) - - // Setup required mocks - mockedIsBinaryFile.mockResolvedValue(true) - mockedCountFileLines.mockResolvedValue(0) - fsPromises.readFile.mockResolvedValue( - Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", - "base64", - ), - ) - - // Setup mockProvider - mockProvider.getState.mockResolvedValue({ - maxReadFileLine: -1, - maxImageFileSize: 15, - maxTotalImageSize: 20, - }) // Allow up to 15MB per image and 20MB total size - - // Setup mockCline properties - mockCline.cwd = "/" - mockCline.task = "Test" - mockCline.providerRef = mockProvider - mockCline.rooIgnoreController = { - validateAccess: vi.fn().mockReturnValue(true), - } - mockCline.say = vi.fn().mockResolvedValue(undefined) - mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) - mockCline.presentAssistantMessage = vi.fn() - mockCline.handleError = vi.fn().mockResolvedValue(undefined) - mockCline.pushToolResult = vi.fn() - mockCline.fileContextTracker = { - trackFileContext: vi.fn().mockResolvedValue(undefined), - } - mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) - mockCline.recordToolError = vi.fn().mockReturnValue(undefined) - setImageSupport(mockCline, true) - - // Setup - images that exactly reach the limit - const exactLimitImages = [ - { path: "test/exact1.png", sizeKB: 10240 }, // 10MB - { path: "test/exact2.jpg", sizeKB: 10240 }, // 10MB - Total exactly 20MB - { path: "test/exact3.gif", sizeKB: 1024 }, // 1MB - This should be skipped - ] + await readFileTool.execute({ path: "large-image.png" }, mockTask as any, callbacks) - // Mock file stats with simpler logic - fsPromises.stat = vi.fn().mockImplementation((filePath) => { - const normalizedFilePath = path.normalize(filePath.toString()) - const image = exactLimitImages.find((img) => normalizedFilePath.includes(path.normalize(img.path))) - if (image) { - return Promise.resolve({ size: image.sizeKB * 1024, isDirectory: () => false }) - } - return Promise.resolve({ size: 1024 * 1024, isDirectory: () => false }) // Default 1MB - }) - - // Mock path.resolve - mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) - - // Execute - const result = await executeReadMultipleImagesTool(exactLimitImages.map((img) => img.path)) - - // Verify - const textPart = Array.isArray(result) ? result.find((p) => p.type === "text")?.text : result - const imageParts = Array.isArray(result) ? result.filter((p) => p.type === "image") : [] - - expect(imageParts).toHaveLength(2) // First 2 images should fit - expect(textPart).toContain("Image skipped to avoid size limit (20MB)") - expect(textPart).toMatch(/Current: \d+(\.\d+)? MB/) - expect(textPart).toMatch(/this file: \d+(\.\d+)? MB/) - }) + expect(mockedProcessImageFile).not.toHaveBeenCalled() + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("exceeds the maximum allowed"), + ) + }) - it("should handle individual image size limit and total memory limit together", async () => { - // Setup mocks (don't clear all mocks) - - // Setup required mocks - mockedIsBinaryFile.mockResolvedValue(true) - mockedCountFileLines.mockResolvedValue(0) - fsPromises.readFile.mockResolvedValue( - Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", - "base64", - ), - ) - - // Setup mockProvider - mockProvider.getState.mockResolvedValue({ - maxReadFileLine: -1, - maxImageFileSize: 20, - maxTotalImageSize: 20, - }) // Allow up to 20MB per image and total size - - // Setup mockCline properties (complete setup) - mockCline.cwd = "/" - mockCline.task = "Test" - mockCline.providerRef = mockProvider - mockCline.rooIgnoreController = { - validateAccess: vi.fn().mockReturnValue(true), - } - mockCline.say = vi.fn().mockResolvedValue(undefined) - mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) - mockCline.presentAssistantMessage = vi.fn() - mockCline.handleError = vi.fn().mockResolvedValue(undefined) - mockCline.pushToolResult = vi.fn() - mockCline.fileContextTracker = { - trackFileContext: vi.fn().mockResolvedValue(undefined), - } - mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) - mockCline.recordToolError = vi.fn().mockReturnValue(undefined) - setImageSupport(mockCline, true) - - // Setup - mix of images with individual size violations and total memory issues - const mixedImages = [ - { path: "test/ok.png", sizeKB: 3072 }, // 3MB - OK - { path: "test/too-big.jpg", sizeKB: 6144 }, // 6MB - Exceeds individual 5MB limit - { path: "test/ok2.gif", sizeKB: 4096 }, // 4MB - OK individually but might exceed total - ] + it("should skip image when total memory limit exceeded", async () => { + const mockTask = createMockTask({ supportsImages: true, maxTotalImageSize: 5 }) + const callbacks = createMockCallbacks() - // Mock file stats - fsPromises.stat = vi.fn().mockImplementation((filePath) => { - const fileName = path.basename(filePath) - const baseName = path.parse(fileName).name - const image = mixedImages.find((img) => img.path.includes(baseName)) - return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024, isDirectory: () => false }) - }) - - // Mock provider state with 5MB individual limit - mockProvider.getState.mockResolvedValue({ - maxReadFileLine: -1, - maxImageFileSize: 5, - maxTotalImageSize: 20, - }) - - // Mock path.resolve - mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) - - // Execute - const result = await executeReadMultipleImagesTool(mixedImages.map((img) => img.path)) - - // Verify - expect(Array.isArray(result)).toBe(true) - const parts = result as any[] - - const textPart = parts.find((p) => p.type === "text")?.text - const imageParts = parts.filter((p) => p.type === "image") - - // Should have 2 images (ok.png and ok2.gif) - expect(imageParts).toHaveLength(2) - - // Should show individual size limit violation - expect(textPart).toMatch( - /Image file is too large \(\d+(\.\d+)? MB\)\. The maximum allowed size is 5 MB\./, - ) + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: false, + reason: "memory_limit", + notice: "Skipping image: would exceed total memory limit", }) - it("should correctly calculate total memory and skip the last image", async () => { - // Setup - const testImages = [ - { path: "test/image1.png", sizeMB: 8 }, - { path: "test/image2.png", sizeMB: 8 }, - { path: "test/image3.png", sizeMB: 8 }, // This one should be skipped - ] - - mockProvider.getState.mockResolvedValue({ - maxReadFileLine: -1, - maxImageFileSize: 10, // 10MB per image - maxTotalImageSize: 20, // 20MB total - }) - - mockedIsBinaryFile.mockResolvedValue(true) - mockedCountFileLines.mockResolvedValue(0) - mockedFsReadFile.mockResolvedValue(imageBuffer) - - fsPromises.stat.mockImplementation(async (filePath) => { - const normalizedFilePath = path.normalize(filePath.toString()) - const file = testImages.find((f) => normalizedFilePath.includes(path.normalize(f.path))) - if (file) { - return { size: file.sizeMB * 1024 * 1024, isDirectory: () => false } - } - return { size: 1024 * 1024, isDirectory: () => false } // Default 1MB - }) - - const imagePaths = testImages.map((img) => img.path) - const result = await executeReadMultipleImagesTool(imagePaths) - - expect(Array.isArray(result)).toBe(true) - const parts = result as any[] - const textPart = parts.find((p) => p.type === "text")?.text - const imageParts = parts.filter((p) => p.type === "image") - - expect(imageParts).toHaveLength(2) // First two images should be processed - expect(textPart).toContain("Image skipped to avoid size limit (20MB)") - expect(textPart).toMatch(/Current: \d+(\.\d+)? MB/) - expect(textPart).toMatch(/this file: \d+(\.\d+)? MB/) - }) + await readFileTool.execute({ path: "another-image.png" }, mockTask as any, callbacks) - it("should reset total memory tracking for each tool invocation", async () => { - // Setup mocks (don't clear all mocks) - - // Setup required mocks for first batch - mockedIsBinaryFile.mockResolvedValue(true) - mockedCountFileLines.mockResolvedValue(0) - fsPromises.readFile.mockResolvedValue( - Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", - "base64", - ), - ) - - // Setup mockProvider - mockProvider.getState.mockResolvedValue({ - maxReadFileLine: -1, - maxImageFileSize: 20, - maxTotalImageSize: 20, - }) - - // Setup mockCline properties (complete setup) - mockCline.cwd = "/" - mockCline.task = "Test" - mockCline.providerRef = mockProvider - mockCline.rooIgnoreController = { - validateAccess: vi.fn().mockReturnValue(true), - } - mockCline.say = vi.fn().mockResolvedValue(undefined) - mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) - mockCline.presentAssistantMessage = vi.fn() - mockCline.handleError = vi.fn().mockResolvedValue(undefined) - mockCline.pushToolResult = vi.fn() - mockCline.fileContextTracker = { - trackFileContext: vi.fn().mockResolvedValue(undefined), - } - mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) - mockCline.recordToolError = vi.fn().mockReturnValue(undefined) - setImageSupport(mockCline, true) - - // Setup - first call with images that use memory - const firstBatch = [{ path: "test/first.png", sizeKB: 10240 }] // 10MB - - fsPromises.stat = vi.fn().mockResolvedValue({ size: 10240 * 1024, isDirectory: () => false }) - mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) - - // Execute first batch - await executeReadMultipleImagesTool(firstBatch.map((img) => img.path)) - - // Setup second batch (don't clear all mocks) - mockedIsBinaryFile.mockResolvedValue(true) - mockedCountFileLines.mockResolvedValue(0) - fsPromises.readFile.mockResolvedValue( - Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", - "base64", - ), - ) - mockProvider.getState.mockResolvedValue({ - maxReadFileLine: -1, - maxImageFileSize: 20, - maxTotalImageSize: 20, - }) - - // Reset path resolving for second batch - mockedPathResolve.mockClear() - - // Re-setup mockCline properties for second batch (complete setup) - mockCline.cwd = "/" - mockCline.task = "Test" - mockCline.providerRef = mockProvider - mockCline.rooIgnoreController = { - validateAccess: vi.fn().mockReturnValue(true), - } - mockCline.say = vi.fn().mockResolvedValue(undefined) - mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) - mockCline.presentAssistantMessage = vi.fn() - mockCline.handleError = vi.fn().mockResolvedValue(undefined) - mockCline.pushToolResult = vi.fn() - mockCline.fileContextTracker = { - trackFileContext: vi.fn().mockResolvedValue(undefined), - } - mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) - mockCline.recordToolError = vi.fn().mockReturnValue(undefined) - setImageSupport(mockCline, true) - - const secondBatch = [{ path: "test/second.png", sizeKB: 15360 }] // 15MB - - // Clear and reset file system mocks for second batch - fsPromises.stat.mockClear() - fsPromises.readFile.mockClear() - mockedIsBinaryFile.mockClear() - mockedCountFileLines.mockClear() - - // Reset mocks for second batch - fsPromises.stat = vi.fn().mockResolvedValue({ size: 15360 * 1024, isDirectory: () => false }) - fsPromises.readFile.mockResolvedValue( - Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", - "base64", - ), - ) - mockedIsBinaryFile.mockResolvedValue(true) - mockedCountFileLines.mockResolvedValue(0) - mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) - - // Execute second batch - const result = await executeReadMultipleImagesTool(secondBatch.map((img) => img.path)) - - // Verify second batch is processed successfully (memory tracking was reset) - expect(Array.isArray(result)).toBe(true) - const parts = result as any[] - const imageParts = parts.filter((p) => p.type === "image") - - expect(imageParts).toHaveLength(1) // Second image should be processed - }) + expect(mockedProcessImageFile).not.toHaveBeenCalled() + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("would exceed total memory")) + }) - it("should handle a folder with many images just under the individual size limit", async () => { - // Setup - Create many images that are each just under the 5MB individual limit - // but together approach the 20MB total limit - const manyImages = [ - { path: "test/img1.png", sizeKB: 4900 }, // 4.78MB - { path: "test/img2.png", sizeKB: 4900 }, // 4.78MB - { path: "test/img3.png", sizeKB: 4900 }, // 4.78MB - { path: "test/img4.png", sizeKB: 4900 }, // 4.78MB - { path: "test/img5.png", sizeKB: 4900 }, // 4.78MB - This should be skipped (total would be ~23.9MB) - ] + it("should handle image read errors gracefully", async () => { + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() - // Setup mocks - mockedIsBinaryFile.mockResolvedValue(true) - mockedCountFileLines.mockResolvedValue(0) - fsPromises.readFile.mockResolvedValue(imageBuffer) - - // Setup provider with 5MB individual limit and 20MB total limit - mockProvider.getState.mockResolvedValue({ - maxReadFileLine: -1, - maxImageFileSize: 5, - maxTotalImageSize: 20, - }) - - // Mock file stats for each image - fsPromises.stat = vi.fn().mockImplementation((filePath) => { - const normalizedFilePath = path.normalize(filePath.toString()) - const image = manyImages.find((img) => normalizedFilePath.includes(path.normalize(img.path))) - return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024, isDirectory: () => false }) - }) - - // Mock path.resolve - mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) - - // Execute - const result = await executeReadMultipleImagesTool(manyImages.map((img) => img.path)) - - // Verify - expect(Array.isArray(result)).toBe(true) - const parts = result as any[] - const textPart = parts.find((p) => p.type === "text")?.text - const imageParts = parts.filter((p) => p.type === "image") - - // Should process first 4 images (total ~19.12MB, under 20MB limit) - expect(imageParts).toHaveLength(4) - - // Should show memory limit notice for the 5th image - expect(textPart).toContain("Image skipped to avoid size limit (20MB)") - expect(textPart).toContain("test/img5.png") - - // Verify memory tracking worked correctly - // The notice should show current memory usage around 20MB (4 * 4900KB ≈ 19.14MB, displayed as 20.1MB) - expect(textPart).toMatch(/Current: \d+(\.\d+)? MB/) + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: true, + sizeInMB: 0.5, }) + mockedProcessImageFile.mockRejectedValue(new Error("Failed to read image")) - it("should reset memory tracking between separate tool invocations more explicitly", async () => { - // This test verifies that totalImageMemoryUsed is reset between calls - // by making two separate tool invocations and ensuring the second one - // starts with fresh memory tracking + await readFileTool.execute({ path: "corrupt.png" }, mockTask as any, callbacks) - // Setup mocks - mockedIsBinaryFile.mockResolvedValue(true) - mockedCountFileLines.mockResolvedValue(0) - fsPromises.readFile.mockResolvedValue(imageBuffer) - - // Setup provider - mockProvider.getState.mockResolvedValue({ - maxReadFileLine: -1, - maxImageFileSize: 20, - maxTotalImageSize: 20, - }) - - // First invocation - use 15MB of memory - const firstBatch = [{ path: "test/large1.png", sizeKB: 15360 }] // 15MB - - fsPromises.stat = vi.fn().mockResolvedValue({ size: 15360 * 1024, isDirectory: () => false }) - mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) - - // Execute first batch - const result1 = await executeReadMultipleImagesTool(firstBatch.map((img) => img.path)) + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading image file")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error")) + }) + }) - // Verify first batch processed successfully - expect(Array.isArray(result1)).toBe(true) - const parts1 = result1 as any[] - const imageParts1 = parts1.filter((p) => p.type === "image") - expect(imageParts1).toHaveLength(1) + describe("binary file handling", () => { + beforeEach(() => { + mockedIsBinaryFileWithEncodingDetection.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(false) + }) - // Second invocation - should start with 0 memory used, not 15MB - // If memory tracking wasn't reset, this 18MB image would be rejected - const secondBatch = [{ path: "test/large2.png", sizeKB: 18432 }] // 18MB + it("should extract text from PDF files", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - // Reset mocks for second invocation - fsPromises.stat.mockClear() - fsPromises.readFile.mockClear() - mockedPathResolve.mockClear() + mockedExtractTextFromFile.mockResolvedValue("PDF content here") - fsPromises.stat = vi.fn().mockResolvedValue({ size: 18432 * 1024, isDirectory: () => false }) - fsPromises.readFile.mockResolvedValue(imageBuffer) - mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) + await readFileTool.execute({ path: "document.pdf" }, mockTask as any, callbacks) - // Execute second batch - const result2 = await executeReadMultipleImagesTool(secondBatch.map((img) => img.path)) + expect(mockedExtractTextFromFile).toHaveBeenCalled() + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("PDF content here")) + }) - // Verify second batch processed successfully - expect(Array.isArray(result2)).toBe(true) - const parts2 = result2 as any[] - const imageParts2 = parts2.filter((p) => p.type === "image") - const textPart2 = parts2.find((p) => p.type === "text")?.text + it("should extract text from DOCX files", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - // The 18MB image should be processed successfully because memory was reset - expect(imageParts2).toHaveLength(1) + mockedExtractTextFromFile.mockResolvedValue("DOCX content here") - // Should NOT contain any memory limit notices - expect(textPart2).not.toContain("Image skipped to avoid memory limit") + await readFileTool.execute({ path: "document.docx" }, mockTask as any, callbacks) - // This proves memory tracking was reset between invocations - }) + expect(mockedExtractTextFromFile).toHaveBeenCalled() + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("DOCX content here")) }) - }) - describe("Error Handling Tests", () => { - it("should include error in output for invalid path", async () => { - // Setup - missing path parameter - const toolUse: ReadFileToolUse = { - type: "tool_use", - name: "read_file", - params: {}, - partial: false, - nativeArgs: { - files: [], - }, - } - - // Execute the tool - await readFileTool.handle(mockCline, toolUse, { - askApproval: mockCline.ask, - handleError: vi.fn(), - pushToolResult: (result: ToolResponse) => { - toolResult = result - }, - }) + it("should handle unsupported binary formats", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - // Verify - native format for error - expect(toolResult).toBe(`Error: Missing required parameter`) - }) + // Return empty array to indicate .exe is not supported + vi.mocked(getSupportedBinaryFormats).mockReturnValue([".pdf", ".docx"]) - it("should include error for RooIgnore error", async () => { - // Execute - skip addLineNumbers check as it returns early with an error - const result = await executeReadFileTool({ validateAccess: false }) + await readFileTool.execute({ path: "program.exe" }, mockTask as any, callbacks) - // Verify - native format for error - expect(result).toBe( - `File: ${testFilePath}\nError: Access to ${testFilePath} 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.`, - ) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Binary file")) }) - it("should provide helpful error when trying to read a directory", async () => { - // Setup - mock fsPromises.stat to indicate the path is a directory - const dirPath = "test/my-directory" - const absoluteDirPath = "/test/my-directory" - - mockedPathResolve.mockReturnValue(absoluteDirPath) + it("should handle extraction errors gracefully", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - // Mock fs/promises stat to return directory - fsPromises.stat.mockResolvedValue({ - isDirectory: () => true, - isFile: () => false, - isSymbolicLink: () => false, - } as any) + mockedExtractTextFromFile.mockRejectedValue(new Error("Extraction failed")) - // Mock isBinaryFile won't be called since we check directory first - mockedIsBinaryFile.mockResolvedValue(false) + await readFileTool.execute({ path: "corrupt.pdf" }, mockTask as any, callbacks) - // Execute - const result = await executeReadFileTool({ filePath: dirPath }) - - // Verify - native format for error - expect(result).toContain(`File: ${dirPath}`) - expect(result).toContain(`Error: Error reading file: Cannot read '${dirPath}' because it is a directory`) - expect(result).toContain("use the list_files tool instead") - - // Verify that task.say was called with the error - expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("Cannot read")) - expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("is a directory")) - expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("list_files tool")) + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error extracting text")) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) }) }) -}) -describe("read_file tool with image support", () => { - const testImagePath = "test/image.png" - const absoluteImagePath = "/test/image.png" - const base64ImageData = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" - const imageBuffer = Buffer.from(base64ImageData, "base64") + describe("text file processing", () => { + beforeEach(() => { + mockedIsBinaryFileWithEncodingDetection.mockResolvedValue(false) + }) - const mockedCountFileLines = vi.mocked(countFileLines) - const mockedIsBinaryFile = vi.mocked(isBinaryFileWithEncodingDetection) - const mockedPathResolve = vi.mocked(path.resolve) - const mockedFsReadFile = vi.mocked(fsPromises.readFile) - const mockedExtractTextFromFile = vi.mocked(extractTextFromFile) + it("should read text file with slice mode (default)", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - let localMockCline: any - let localMockProvider: any - let toolResult: ToolResponse | undefined + const content = "line 1\nline 2\nline 3" + mockedFsReadFile.mockResolvedValue(Buffer.from(content)) + mockedReadWithSlice.mockReturnValue({ + content: "1 | line 1\n2 | line 2\n3 | line 3", + returnedLines: 3, + totalLines: 3, + wasTruncated: false, + includedRanges: [[1, 3]], + }) - beforeEach(() => { - // Clear specific mocks (not all mocks to preserve shared state) - mockedPathResolve.mockClear() - mockedIsBinaryFile.mockClear() - mockedCountFileLines.mockClear() - mockedFsReadFile.mockClear() - mockedExtractTextFromFile.mockClear() - toolResultMock.mockClear() - - // CRITICAL: Reset fsPromises.stat to prevent cross-test contamination - fsPromises.stat.mockClear() - fsPromises.stat.mockResolvedValue({ - size: 1024, - isDirectory: () => false, - isFile: () => true, - isSymbolicLink: () => false, - } as any) - - // Use shared mock setup function with local variables - const mocks = createMockCline() - localMockCline = mocks.mockCline - localMockProvider = mocks.mockProvider - - // CRITICAL: Explicitly ensure image support is enabled for all tests in this suite - setImageSupport(localMockCline, true) - - mockedPathResolve.mockReturnValue(absoluteImagePath) - mockedIsBinaryFile.mockResolvedValue(true) - mockedCountFileLines.mockResolvedValue(0) - mockedFsReadFile.mockResolvedValue(imageBuffer) - - // Setup mock provider with default maxReadFileLine - localMockProvider.getState.mockResolvedValue({ maxReadFileLine: -1 }) - - toolResult = undefined - }) + await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - async function executeReadImageTool(imagePath: string = testImagePath): Promise { - const toolUse: ReadFileToolUse = { - type: "tool_use", - name: "read_file", - params: {}, - partial: false, - nativeArgs: { - files: [{ path: imagePath, lineRanges: [] }], - }, - } - - // Debug: Check if mock is working - console.log("Mock API:", localMockCline.api) - console.log("Supports images:", localMockCline.api?.getModel?.()?.info?.supportsImages) - - await readFileTool.handle(localMockCline, toolUse, { - askApproval: localMockCline.ask, - handleError: vi.fn(), - pushToolResult: (result: ToolResponse) => { - toolResult = result - }, + expect(mockedReadWithSlice).toHaveBeenCalled() + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("line 1")) }) - console.log("Result type:", Array.isArray(toolResult) ? "array" : typeof toolResult) - console.log("Result:", toolResult) + it("should read text file with offset and limit", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - return toolResult - } - - describe("Image Format Detection", () => { - it.each([ - [".png", "image.png", "image/png"], - [".jpg", "photo.jpg", "image/jpeg"], - [".jpeg", "picture.jpeg", "image/jpeg"], - [".gif", "animation.gif", "image/gif"], - [".bmp", "bitmap.bmp", "image/bmp"], - [".svg", "vector.svg", "image/svg+xml"], - [".webp", "modern.webp", "image/webp"], - [".ico", "favicon.ico", "image/x-icon"], - [".avif", "new-format.avif", "image/avif"], - ])("should detect %s as an image format", async (ext, filename, expectedMimeType) => { - // Setup - const imagePath = `test/${filename}` - const absolutePath = `/test/${filename}` - mockedPathResolve.mockReturnValue(absolutePath) - - // Ensure API mock supports images - setImageSupport(localMockCline, true) - - // Execute - const result = await executeReadImageTool(imagePath) - - // Verify result is a multi-part response - expect(Array.isArray(result)).toBe(true) - const textPart = (result as any[]).find((p) => p.type === "text")?.text - const imagePart = (result as any[]).find((p) => p.type === "image") - - // Verify text part - native format - expect(textPart).toContain(`File: ${imagePath}`) - expect(textPart).not.toContain("") - expect(textPart).toContain(`Note: Image file`) - - // Verify image part - expect(imagePart).toBeDefined() - expect(imagePart.source.media_type).toBe(expectedMimeType) - expect(imagePart.source.data).toBe(base64ImageData) - }) - }) + mockedFsReadFile.mockResolvedValue(Buffer.from("line 1\nline 2\nline 3\nline 4\nline 5")) + mockedReadWithSlice.mockReturnValue({ + content: "2 | line 2\n3 | line 3", + returnedLines: 2, + totalLines: 5, + wasTruncated: true, + includedRanges: [[2, 3]], + }) - describe("Image Reading Functionality", () => { - it("should read image file and return a multi-part response", async () => { - // Execute - const result = await executeReadImageTool() - - // Verify result is a multi-part response - expect(Array.isArray(result)).toBe(true) - const textPart = (result as any[]).find((p) => p.type === "text")?.text - const imagePart = (result as any[]).find((p) => p.type === "image") - - // Verify text part - native format - expect(textPart).toContain(`File: ${testImagePath}`) - expect(textPart).not.toContain(``) - expect(textPart).toContain(`Note: Image file`) - - // Verify image part - expect(imagePart).toBeDefined() - expect(imagePart.source.media_type).toBe("image/png") - expect(imagePart.source.data).toBe(base64ImageData) - }) + await readFileTool.execute( + { path: "test.ts", mode: "slice", offset: 2, limit: 2 }, + mockTask as any, + callbacks, + ) - it("should call formatResponse.toolResult with text and image data", async () => { - // Execute - await executeReadImageTool() - - // Verify toolResultMock was called correctly - expect(toolResultMock).toHaveBeenCalledTimes(1) - const callArgs = toolResultMock.mock.calls[0] - const textArg = callArgs[0] - const imagesArg = callArgs[1] - - // Native format - expect(textArg).toContain(`File: ${testImagePath}`) - expect(imagesArg).toBeDefined() - expect(imagesArg).toBeInstanceOf(Array) - expect(imagesArg!.length).toBe(1) - expect(imagesArg![0]).toBe(`data:image/png;base64,${base64ImageData}`) + expect(mockedReadWithSlice).toHaveBeenCalledWith(expect.any(String), 1, 2) // offset converted to 0-based }) - it("should handle large image files", async () => { - // Setup - simulate a large image - const largeBase64 = "A".repeat(1000000) // 1MB of base64 data - const largeBuffer = Buffer.from(largeBase64, "base64") - mockedFsReadFile.mockResolvedValue(largeBuffer) - - // Execute - const result = await executeReadImageTool() - - // Verify it still works with large data - expect(Array.isArray(result)).toBe(true) - const imagePart = (result as any[]).find((p) => p.type === "image") - expect(imagePart).toBeDefined() - expect(imagePart.source.media_type).toBe("image/png") - expect(imagePart.source.data).toBe(largeBase64) - }) + it("should read text file with indentation mode", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - it("should exclude images when model does not support images", async () => { - // Setup - mock API handler that doesn't support images - setImageSupport(localMockCline, false) + const content = "class Foo {\n method() {\n return 42\n }\n}" + mockedFsReadFile.mockResolvedValue(Buffer.from(content)) + mockedReadWithIndentation.mockReturnValue({ + content: "1 | class Foo {\n2 | method() {\n3 | return 42\n4 | }\n5 | }", + returnedLines: 5, + totalLines: 5, + wasTruncated: false, + includedRanges: [[1, 5]], + }) - // Execute - const result = await executeReadImageTool() + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + indentation: { anchor_line: 3 }, + }, + mockTask as any, + callbacks, + ) - // When images are not supported, the tool should return just text (not call formatResponse.toolResult) - expect(toolResultMock).not.toHaveBeenCalled() - expect(typeof result).toBe("string") - // Native format - expect(result).toContain(`File: ${testImagePath}`) - expect(result).toContain(`Note: Image file`) + expect(mockedReadWithIndentation).toHaveBeenCalledWith( + content, + expect.objectContaining({ + anchorLine: 3, + }), + ) }) - it("should include images when model supports images", async () => { - // Setup - mock API handler that supports images - setImageSupport(localMockCline, true) - - // Execute - const result = await executeReadImageTool() - - // Verify toolResultMock was called with images - expect(toolResultMock).toHaveBeenCalledTimes(1) - const callArgs = toolResultMock.mock.calls[0] - const textArg = callArgs[0] - const imagesArg = callArgs[1] - - // Native format - expect(textArg).toContain(`File: ${testImagePath}`) - expect(imagesArg).toBeDefined() // Images should be included - expect(imagesArg).toBeInstanceOf(Array) - expect(imagesArg!.length).toBe(1) - expect(imagesArg![0]).toBe(`data:image/png;base64,${base64ImageData}`) - }) + it("should show truncation notice when content is truncated", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - it("should handle undefined supportsImages gracefully", async () => { - // Setup - mock API handler with undefined supportsImages - setImageSupport(localMockCline, undefined) + mockedFsReadFile.mockResolvedValue(Buffer.from("lots of content...")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | truncated content", + returnedLines: 100, + totalLines: 5000, + wasTruncated: true, + includedRanges: [[1, 100]], + }) - // Execute - const result = await executeReadImageTool() + await readFileTool.execute({ path: "large.ts" }, mockTask as any, callbacks) - // When supportsImages is undefined, should default to false and return just text - expect(toolResultMock).not.toHaveBeenCalled() - expect(typeof result).toBe("string") - // Native format - expect(result).toContain(`File: ${testImagePath}`) - expect(result).toContain(`Note: Image file`) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("truncated")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("To read more")) }) - it("should handle errors when reading image files", async () => { - // Setup - simulate read error - mockedFsReadFile.mockRejectedValue(new Error("Failed to read image")) - - // Execute - const toolUse: ReadFileToolUse = { - type: "tool_use", - name: "read_file", - params: {}, - partial: false, - nativeArgs: { - files: [{ path: testImagePath, lineRanges: [] }], - }, - } + it("should handle empty files", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - await readFileTool.handle(localMockCline, toolUse, { - askApproval: localMockCline.ask, - handleError: vi.fn(), - pushToolResult: (result: ToolResponse) => { - toolResult = result - }, + mockedFsReadFile.mockResolvedValue(Buffer.from("")) + mockedReadWithSlice.mockReturnValue({ + content: "", + returnedLines: 0, + totalLines: 0, + wasTruncated: false, + includedRanges: [], }) - // Verify error handling - native format - expect(toolResult).toContain("Error: Error reading image file: Failed to read image") - // Verify that say was called to show error to user - expect(localMockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("Failed to read image")) + await readFileTool.execute({ path: "empty.ts" }, mockTask as any, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("empty")) }) }) - describe("Binary File Handling", () => { - it("should not treat non-image binary files as images", async () => { - // Setup - const binaryPath = "test/document.pdf" - const absolutePath = "/test/document.pdf" - mockedPathResolve.mockReturnValue(absolutePath) - mockedExtractTextFromFile.mockResolvedValue("PDF content extracted") - - // Execute - const result = await executeReadImageTool(binaryPath) - - // Verify it uses extractTextFromFile instead - expect(result).not.toContain("") - // Make the test platform-agnostic by checking the call was made (path normalization can vary) - expect(mockedExtractTextFromFile).toHaveBeenCalledTimes(1) - const callArgs = mockedExtractTextFromFile.mock.calls[0] - expect(callArgs[0]).toMatch(/[\\\/]test[\\\/]document\.pdf$/) - }) + describe("approval flow", () => { + it("should approve file read when user clicks yes", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - it("should handle unknown binary formats", async () => { - // Setup - const binaryPath = "test/unknown.bin" - const absolutePath = "/test/unknown.bin" - mockedPathResolve.mockReturnValue(absolutePath) - mockedExtractTextFromFile.mockResolvedValue("") + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - // Execute - const result = await executeReadImageTool(binaryPath) + await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - // Verify - native format for binary files - expect(result).not.toContain("") - expect(result).toContain("Binary file (bin)") + expect(mockTask.ask).toHaveBeenCalledWith("tool", expect.any(String), false) + expect(mockTask.didRejectTool).toBe(false) }) - }) - describe("Edge Cases", () => { - it("should handle case-insensitive image extensions", async () => { - // Test uppercase extensions - const uppercasePath = "test/IMAGE.PNG" - const absolutePath = "/test/IMAGE.PNG" - mockedPathResolve.mockReturnValue(absolutePath) - - // Execute - const result = await executeReadImageTool(uppercasePath) - - // Verify - expect(Array.isArray(result)).toBe(true) - const imagePart = (result as any[]).find((p) => p.type === "image") - expect(imagePart).toBeDefined() - expect(imagePart.source.media_type).toBe("image/png") - }) + it("should deny file read when user clicks no", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - it("should handle files with multiple dots in name", async () => { - // Setup - const complexPath = "test/my.photo.backup.png" - const absolutePath = "/test/my.photo.backup.png" - mockedPathResolve.mockReturnValue(absolutePath) + mockTask.ask.mockResolvedValue({ response: "noButtonClicked", text: undefined, images: undefined }) - // Execute - const result = await executeReadImageTool(complexPath) + await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - // Verify - expect(Array.isArray(result)).toBe(true) - const imagePart = (result as any[]).find((p) => p.type === "image") - expect(imagePart).toBeDefined() - expect(imagePart.source.media_type).toBe("image/png") + expect(mockTask.didRejectTool).toBe(true) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Denied by user")) }) - it("should handle empty image files", async () => { - // Setup - empty buffer - mockedFsReadFile.mockResolvedValue(Buffer.from("")) + it("should include user feedback when provided with approval", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - // Execute - const result = await executeReadImageTool() + mockTask.ask.mockResolvedValue({ + response: "yesButtonClicked", + text: "Please be careful with this file", + images: undefined, + }) + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) - // Verify - should still create valid data URL - expect(Array.isArray(result)).toBe(true) - const imagePart = (result as any[]).find((p) => p.type === "image") - expect(imagePart).toBeDefined() - expect(imagePart.source.media_type).toBe("image/png") - expect(imagePart.source.data).toBe("") - }) - }) -}) + await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) -describe("read_file tool concurrent file reads limit", () => { - const mockedCountFileLines = vi.mocked(countFileLines) - const mockedIsBinaryFile = vi.mocked(isBinaryFileWithEncodingDetection) - const mockedPathResolve = vi.mocked(path.resolve) + expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "Please be careful with this file", undefined) + expect(formatResponse.toolApprovedWithFeedback).toHaveBeenCalledWith("Please be careful with this file") + }) - let mockCline: any - let mockProvider: any - let toolResult: ToolResponse | undefined + it("should include user feedback when provided with denial", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - beforeEach(() => { - // Clear specific mocks - mockedCountFileLines.mockClear() - mockedIsBinaryFile.mockClear() - mockedPathResolve.mockClear() - addLineNumbersMock.mockClear() - toolResultMock.mockClear() - - // Use shared mock setup function - const mocks = createMockCline() - mockCline = mocks.mockCline - mockProvider = mocks.mockProvider - - // Disable image support for these tests - setImageSupport(mockCline, false) - - mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) - mockedIsBinaryFile.mockResolvedValue(false) - mockedCountFileLines.mockResolvedValue(10) + mockTask.ask.mockResolvedValue({ + response: "noButtonClicked", + text: "This file contains secrets", + images: undefined, + }) - // Mock fsPromises.stat to return a file (not directory) by default - fsPromises.stat.mockResolvedValue({ - isDirectory: () => false, - isFile: () => true, - isSymbolicLink: () => false, - } as any) + await readFileTool.execute({ path: "secrets.env" }, mockTask as any, callbacks) - toolResult = undefined + expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "This file contains secrets", undefined) + expect(formatResponse.toolDeniedWithFeedback).toHaveBeenCalledWith("This file contains secrets") + }) }) - async function executeReadFileToolWithLimit( - fileCount: number, - maxConcurrentFileReads: number, - ): Promise { - // Setup provider state with the specified limit - mockProvider.getState.mockResolvedValue({ - maxReadFileLine: -1, - maxConcurrentFileReads, - maxImageFileSize: 20, - maxTotalImageSize: 20, - }) + describe("output structure", () => { + it("should include file path in output", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) - const toolUse: ReadFileToolUse = { - type: "tool_use", - name: "read_file", - params: {}, - partial: false, - nativeArgs: { - files: Array.from({ length: fileCount }, (_, i) => ({ path: `file${i + 1}.txt`, lineRanges: [] })), - }, - } - - // Configure mocks for successful file reads - mockReadFileWithTokenBudget.mockResolvedValue({ - content: "test content", - tokenCount: 10, - lineCount: 1, - complete: true, - }) + await readFileTool.execute({ path: "src/app.ts" }, mockTask as any, callbacks) - await readFileTool.handle(mockCline, toolUse, { - askApproval: mockCline.ask, - handleError: vi.fn(), - pushToolResult: (result: ToolResponse) => { - toolResult = result - }, + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("File: src/app.ts")) }) - return toolResult - } + it("should track file context after successful read", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - it("should reject when file count exceeds maxConcurrentFileReads", async () => { - // Try to read 6 files when limit is 5 - const result = await executeReadFileToolWithLimit(6, 5) + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) - // Verify error result - expect(result).toContain("Error: Too many files requested") - expect(result).toContain("You attempted to read 6 files") - expect(result).toContain("but the concurrent file reads limit is 5") - expect(result).toContain("Please read files in batches of 5 or fewer") + await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - // Verify error tracking - expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("Too many files requested")) + expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith("test.ts", "read_tool") + }) }) - it("should allow reading files when count equals maxConcurrentFileReads", async () => { - // Try to read exactly 5 files when limit is 5 - const result = await executeReadFileToolWithLimit(5, 5) + describe("error handling", () => { + it("should handle file read errors gracefully", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - // Should not contain error - expect(result).not.toContain("Error: Too many files requested") + mockedFsReadFile.mockRejectedValue(new Error("ENOENT: no such file or directory")) - // Should contain file results - expect(typeof result === "string" ? result : JSON.stringify(result)).toContain("file1.txt") - }) + await readFileTool.execute({ path: "nonexistent.ts" }, mockTask as any, callbacks) - it("should allow reading files when count is below maxConcurrentFileReads", async () => { - // Try to read 3 files when limit is 5 - const result = await executeReadFileToolWithLimit(3, 5) + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading file")) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) - // Should not contain error - expect(result).not.toContain("Error: Too many files requested") + it("should handle stat errors gracefully", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() - // Should contain file results - expect(typeof result === "string" ? result : JSON.stringify(result)).toContain("file1.txt") - }) + mockedFsStat.mockRejectedValue(new Error("Permission denied")) - it("should respect custom maxConcurrentFileReads value of 1", async () => { - // Try to read 2 files when limit is 1 - const result = await executeReadFileToolWithLimit(2, 1) + await readFileTool.execute({ path: "protected.ts" }, mockTask as any, callbacks) - // Verify error result with limit of 1 - expect(result).toContain("Error: Too many files requested") - expect(result).toContain("You attempted to read 2 files") - expect(result).toContain("but the concurrent file reads limit is 1") + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading file")) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) }) - it("should allow single file read when maxConcurrentFileReads is 1", async () => { - // Try to read 1 file when limit is 1 - const result = await executeReadFileToolWithLimit(1, 1) + describe("getReadFileToolDescription", () => { + it("should return description with path when nativeArgs provided", () => { + const description = readFileTool.getReadFileToolDescription("read_file", { path: "src/app.ts" }) - // Should not contain error - expect(result).not.toContain("Error: Too many files requested") - - // Should contain file result - expect(typeof result === "string" ? result : JSON.stringify(result)).toContain("file1.txt") - }) + expect(description).toBe("[read_file for 'src/app.ts']") + }) - it("should respect higher maxConcurrentFileReads value", async () => { - // Try to read 15 files when limit is 10 - const result = await executeReadFileToolWithLimit(15, 10) + it("should return description with path when params provided", () => { + const description = readFileTool.getReadFileToolDescription("read_file", { path: "src/app.ts" }) - // Verify error result - expect(result).toContain("Error: Too many files requested") - expect(result).toContain("You attempted to read 15 files") - expect(result).toContain("but the concurrent file reads limit is 10") - }) - - it("should use default value of 5 when maxConcurrentFileReads is not set", async () => { - // Setup provider state without maxConcurrentFileReads - mockProvider.getState.mockResolvedValue({ - maxReadFileLine: -1, - maxImageFileSize: 20, - maxTotalImageSize: 20, + expect(description).toBe("[read_file for 'src/app.ts']") }) - const toolUse: ReadFileToolUse = { - type: "tool_use", - name: "read_file", - params: {}, - partial: false, - nativeArgs: { - files: Array.from({ length: 6 }, (_, i) => ({ path: `file${i + 1}.txt`, lineRanges: [] })), - }, - } - - mockReadFileWithTokenBudget.mockResolvedValue({ - content: "test content", - tokenCount: 10, - lineCount: 1, - complete: true, - }) + it("should return description indicating missing path", () => { + const description = readFileTool.getReadFileToolDescription("read_file", {}) - await readFileTool.handle(mockCline, toolUse, { - askApproval: mockCline.ask, - handleError: vi.fn(), - pushToolResult: (result: ToolResponse) => { - toolResult = result - }, + expect(description).toBe("[read_file with missing path]") }) - - // Should use default limit of 5 and reject 6 files - expect(toolResult).toContain("Error: Too many files requested") - expect(toolResult).toContain("but the concurrent file reads limit is 5") }) }) diff --git a/src/core/tools/__tests__/skillTool.spec.ts b/src/core/tools/__tests__/skillTool.spec.ts new file mode 100644 index 0000000000..fc1b3396e5 --- /dev/null +++ b/src/core/tools/__tests__/skillTool.spec.ts @@ -0,0 +1,345 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { skillTool } from "../SkillTool" +import { Task } from "../../task/Task" +import { formatResponse } from "../../prompts/responses" +import type { ToolUse } from "../../../shared/tools" + +describe("skillTool", () => { + let mockTask: any + let mockCallbacks: any + let mockSkillsManager: any + + beforeEach(() => { + vi.clearAllMocks() + + mockSkillsManager = { + getSkillContent: vi.fn(), + getSkillsForMode: vi.fn().mockReturnValue([]), + } + + mockTask = { + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + didToolFailInCurrentTurn: false, + sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing parameter error"), + ask: vi.fn().mockResolvedValue({}), + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ mode: "code" }), + getSkillsManager: vi.fn().mockReturnValue(mockSkillsManager), + }), + }, + } + + mockCallbacks = { + askApproval: vi.fn().mockResolvedValue(true), + handleError: vi.fn(), + pushToolResult: vi.fn(), + } + }) + + it("should handle missing skill parameter", async () => { + const block: ToolUse<"skill"> = { + type: "tool_use" as const, + name: "skill" as const, + params: {}, + partial: false, + nativeArgs: { + skill: "", + }, + } + + await skillTool.handle(mockTask as Task, block, mockCallbacks) + + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(mockTask.recordToolError).toHaveBeenCalledWith("skill") + expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("skill", "skill") + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith("Missing parameter error") + }) + + it("should handle skill not found", async () => { + const block: ToolUse<"skill"> = { + type: "tool_use" as const, + name: "skill" as const, + params: {}, + partial: false, + nativeArgs: { + skill: "non-existent", + }, + } + + mockSkillsManager.getSkillContent.mockResolvedValue(null) + mockSkillsManager.getSkillsForMode.mockReturnValue([{ name: "create-mcp-server" }]) + + await skillTool.handle(mockTask as Task, block, mockCallbacks) + + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( + formatResponse.toolError("Skill 'non-existent' not found. Available skills: create-mcp-server"), + ) + }) + + it("should handle empty available skills list", async () => { + const block: ToolUse<"skill"> = { + type: "tool_use" as const, + name: "skill" as const, + params: {}, + partial: false, + nativeArgs: { + skill: "non-existent", + }, + } + + mockSkillsManager.getSkillContent.mockResolvedValue(null) + mockSkillsManager.getSkillsForMode.mockReturnValue([]) + + await skillTool.handle(mockTask as Task, block, mockCallbacks) + + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( + formatResponse.toolError("Skill 'non-existent' not found. Available skills: (none)"), + ) + }) + + it("should successfully load built-in skill", async () => { + const block: ToolUse<"skill"> = { + type: "tool_use" as const, + name: "skill" as const, + params: {}, + partial: false, + nativeArgs: { + skill: "create-mcp-server", + }, + } + + const mockSkillContent = { + name: "create-mcp-server", + description: "Instructions for creating MCP servers", + source: "built-in", + instructions: "Step 1: Create the server...", + } + + mockSkillsManager.getSkillContent.mockResolvedValue(mockSkillContent) + + await skillTool.handle(mockTask as Task, block, mockCallbacks) + + expect(mockCallbacks.askApproval).toHaveBeenCalledWith( + "tool", + JSON.stringify({ + tool: "skill", + skill: "create-mcp-server", + args: undefined, + source: "built-in", + description: "Instructions for creating MCP servers", + }), + ) + + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( + `Skill: create-mcp-server +Description: Instructions for creating MCP servers +Source: built-in + +--- Skill Instructions --- + +Step 1: Create the server...`, + ) + }) + + it("should successfully load skill with arguments", async () => { + const block: ToolUse<"skill"> = { + type: "tool_use" as const, + name: "skill" as const, + params: {}, + partial: false, + nativeArgs: { + skill: "create-mcp-server", + args: "weather API server", + }, + } + + const mockSkillContent = { + name: "create-mcp-server", + description: "Instructions for creating MCP servers", + source: "built-in", + instructions: "Step 1: Create the server...", + } + + mockSkillsManager.getSkillContent.mockResolvedValue(mockSkillContent) + + await skillTool.handle(mockTask as Task, block, mockCallbacks) + + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( + `Skill: create-mcp-server +Description: Instructions for creating MCP servers +Provided arguments: weather API server +Source: built-in + +--- Skill Instructions --- + +Step 1: Create the server...`, + ) + }) + + it("should handle user rejection", async () => { + const block: ToolUse<"skill"> = { + type: "tool_use" as const, + name: "skill" as const, + params: {}, + partial: false, + nativeArgs: { + skill: "create-mcp-server", + }, + } + + mockSkillsManager.getSkillContent.mockResolvedValue({ + name: "create-mcp-server", + description: "Test", + source: "built-in", + instructions: "Test instructions", + }) + + mockCallbacks.askApproval.mockResolvedValue(false) + + await skillTool.handle(mockTask as Task, block, mockCallbacks) + + expect(mockCallbacks.pushToolResult).not.toHaveBeenCalled() + }) + + it("should handle partial block", async () => { + const block: ToolUse<"skill"> = { + type: "tool_use" as const, + name: "skill" as const, + params: { + skill: "create-mcp-server", + args: "", + }, + partial: true, + } + + await skillTool.handle(mockTask as Task, block, mockCallbacks) + + expect(mockTask.ask).toHaveBeenCalledWith( + "tool", + JSON.stringify({ + tool: "skill", + skill: "create-mcp-server", + args: "", + }), + true, + ) + + expect(mockCallbacks.pushToolResult).not.toHaveBeenCalled() + }) + + it("should handle errors during execution", async () => { + const block: ToolUse<"skill"> = { + type: "tool_use" as const, + name: "skill" as const, + params: {}, + partial: false, + nativeArgs: { + skill: "create-mcp-server", + }, + } + + const error = new Error("Test error") + mockSkillsManager.getSkillContent.mockRejectedValue(error) + + await skillTool.handle(mockTask as Task, block, mockCallbacks) + + expect(mockCallbacks.handleError).toHaveBeenCalledWith("executing skill", error) + }) + + it("should reset consecutive mistake count on valid skill", async () => { + const block: ToolUse<"skill"> = { + type: "tool_use" as const, + name: "skill" as const, + params: {}, + partial: false, + nativeArgs: { + skill: "create-mcp-server", + }, + } + + mockTask.consecutiveMistakeCount = 5 + + const mockSkillContent = { + name: "create-mcp-server", + description: "Test", + source: "built-in", + instructions: "Test instructions", + } + + mockSkillsManager.getSkillContent.mockResolvedValue(mockSkillContent) + + await skillTool.handle(mockTask as Task, block, mockCallbacks) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + }) + + it("should handle Skills Manager not available", async () => { + const block: ToolUse<"skill"> = { + type: "tool_use" as const, + name: "skill" as const, + params: {}, + partial: false, + nativeArgs: { + skill: "create-mcp-server", + }, + } + + mockTask.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ mode: "code" }), + getSkillsManager: vi.fn().mockReturnValue(undefined), + }) + + await skillTool.handle(mockTask as Task, block, mockCallbacks) + + expect(mockTask.recordToolError).toHaveBeenCalledWith("skill") + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( + formatResponse.toolError("Skills Manager not available"), + ) + }) + + it("should load project skill", async () => { + const block: ToolUse<"skill"> = { + type: "tool_use" as const, + name: "skill" as const, + params: {}, + partial: false, + nativeArgs: { + skill: "my-project-skill", + }, + } + + const mockSkillContent = { + name: "my-project-skill", + description: "A custom project skill", + source: "project", + instructions: "Follow these project-specific instructions...", + } + + mockSkillsManager.getSkillContent.mockResolvedValue(mockSkillContent) + + await skillTool.handle(mockTask as Task, block, mockCallbacks) + + expect(mockCallbacks.askApproval).toHaveBeenCalledWith( + "tool", + JSON.stringify({ + tool: "skill", + skill: "my-project-skill", + args: undefined, + source: "project", + description: "A custom project skill", + }), + ) + + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( + `Skill: my-project-skill +Description: A custom project skill +Source: project + +--- Skill Instructions --- + +Follow these project-specific instructions...`, + ) + }) +}) diff --git a/src/core/tools/__tests__/truncateDefinitions.spec.ts b/src/core/tools/__tests__/truncateDefinitions.spec.ts deleted file mode 100644 index 06cbb03e90..0000000000 --- a/src/core/tools/__tests__/truncateDefinitions.spec.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { describe, it, expect } from "vitest" -import { truncateDefinitionsToLineLimit } from "../helpers/truncateDefinitions" - -describe("truncateDefinitionsToLineLimit", () => { - it("should not truncate when maxReadFileLine is -1 (no limit)", () => { - const definitions = `# test.ts -10--20 | function foo() { -30--40 | function bar() { -50--60 | function baz() {` - - const result = truncateDefinitionsToLineLimit(definitions, -1) - expect(result).toBe(definitions) - }) - - it("should not truncate when maxReadFileLine is 0 (definitions only mode)", () => { - const definitions = `# test.ts -10--20 | function foo() { -30--40 | function bar() { -50--60 | function baz() {` - - const result = truncateDefinitionsToLineLimit(definitions, 0) - expect(result).toBe(definitions) - }) - - it("should truncate definitions beyond the line limit", () => { - const definitions = `# test.ts -10--20 | function foo() { -30--40 | function bar() { -50--60 | function baz() {` - - const result = truncateDefinitionsToLineLimit(definitions, 25) - const expected = `# test.ts -10--20 | function foo() {` - - expect(result).toBe(expected) - }) - - it("should include definitions that start within limit even if they end beyond it", () => { - const definitions = `# test.ts -10--50 | function foo() { -60--80 | function bar() {` - - const result = truncateDefinitionsToLineLimit(definitions, 30) - const expected = `# test.ts -10--50 | function foo() {` - - expect(result).toBe(expected) - }) - - it("should handle single-line definitions", () => { - const definitions = `# test.ts -10 | const foo = 1 -20 | const bar = 2 -30 | const baz = 3` - - const result = truncateDefinitionsToLineLimit(definitions, 25) - const expected = `# test.ts -10 | const foo = 1 -20 | const bar = 2` - - expect(result).toBe(expected) - }) - - it("should preserve header line when all definitions are beyond limit", () => { - const definitions = `# test.ts -100--200 | function foo() {` - - const result = truncateDefinitionsToLineLimit(definitions, 50) - const expected = `# test.ts` - - expect(result).toBe(expected) - }) - - it("should handle empty definitions", () => { - const definitions = `# test.ts` - - const result = truncateDefinitionsToLineLimit(definitions, 50) - expect(result).toBe(definitions) - }) - - it("should handle definitions without header", () => { - const definitions = `10--20 | function foo() { -30--40 | function bar() {` - - const result = truncateDefinitionsToLineLimit(definitions, 25) - const expected = `10--20 | function foo() {` - - expect(result).toBe(expected) - }) - - it("should not preserve empty lines (only definition lines)", () => { - const definitions = `# test.ts -10--20 | function foo() { - -30--40 | function bar() {` - - const result = truncateDefinitionsToLineLimit(definitions, 25) - const expected = `# test.ts -10--20 | function foo() {` - - expect(result).toBe(expected) - }) - - it("should handle mixed single and range definitions", () => { - const definitions = `# test.ts -5 | const x = 1 -10--20 | function foo() { -25 | const y = 2 -30--40 | function bar() {` - - const result = truncateDefinitionsToLineLimit(definitions, 26) - const expected = `# test.ts -5 | const x = 1 -10--20 | function foo() { -25 | const y = 2` - - expect(result).toBe(expected) - }) - - it("should handle definitions at exactly the limit", () => { - const definitions = `# test.ts -10--20 | function foo() { -30--40 | function bar() { -50--60 | function baz() {` - - const result = truncateDefinitionsToLineLimit(definitions, 30) - const expected = `# test.ts -10--20 | function foo() { -30--40 | function bar() {` - - expect(result).toBe(expected) - }) - - it("should handle definitions with leading whitespace", () => { - const definitions = `# test.ts - 10--20 | function foo() { - 30--40 | function bar() { - 50--60 | function baz() {` - - const result = truncateDefinitionsToLineLimit(definitions, 25) - const expected = `# test.ts - 10--20 | function foo() {` - - expect(result).toBe(expected) - }) - - it("should handle definitions with mixed whitespace patterns", () => { - const definitions = `# test.ts -10--20 | function foo() { - 30--40 | function bar() { - 50--60 | function baz() {` - - const result = truncateDefinitionsToLineLimit(definitions, 35) - const expected = `# test.ts -10--20 | function foo() { - 30--40 | function bar() {` - - expect(result).toBe(expected) - }) -}) diff --git a/src/core/tools/__tests__/useMcpToolTool.spec.ts b/src/core/tools/__tests__/useMcpToolTool.spec.ts index 9f41b3cde9..5ee826774f 100644 --- a/src/core/tools/__tests__/useMcpToolTool.spec.ts +++ b/src/core/tools/__tests__/useMcpToolTool.spec.ts @@ -7,7 +7,12 @@ import { ToolUse } from "../../../shared/tools" // Mock dependencies vi.mock("../../prompts/responses", () => ({ formatResponse: { - toolResult: vi.fn((result: string) => `Tool result: ${result}`), + toolResult: vi.fn((result: string, images?: string[]) => { + if (images && images.length > 0) { + return `Tool result: ${result} [with ${images.length} image(s)]` + } + return `Tool result: ${result}` + }), toolError: vi.fn((error: string) => `Tool error: ${error}`), invalidMcpToolArgumentError: vi.fn((server: string, tool: string) => `Invalid args for ${server}:${tool}`), unknownMcpToolError: vi.fn((server: string, tool: string, availableTools: string[]) => { @@ -245,7 +250,7 @@ describe("useMcpToolTool", () => { expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockAskApproval).toHaveBeenCalled() expect(mockTask.say).toHaveBeenCalledWith("mcp_server_request_started") - expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Tool executed successfully") + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Tool executed successfully", []) expect(mockPushToolResult).toHaveBeenCalledWith("Tool result: Tool executed successfully") }) @@ -483,7 +488,7 @@ describe("useMcpToolTool", () => { expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockTask.recordToolError).not.toHaveBeenCalled() expect(mockTask.say).toHaveBeenCalledWith("mcp_server_request_started") - expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Tool executed successfully") + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Tool executed successfully", []) }) it("should reject unknown server names with available servers listed", async () => { @@ -636,4 +641,234 @@ describe("useMcpToolTool", () => { expect(callToolMock).toHaveBeenCalledWith("test-server", "get-user-profile", {}) }) }) + + describe("image handling", () => { + it("should handle tool response with image content", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "figma-server", + tool_name: "get_screenshot", + arguments: '{"nodeId": "123"}', + }, + nativeArgs: { + server_name: "figma-server", + tool_name: "get_screenshot", + arguments: { nodeId: "123" }, + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { + type: "image", + mimeType: "image/png", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ", + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + getAllServers: vi.fn().mockReturnValue([ + { + name: "figma-server", + tools: [{ name: "get_screenshot", description: "Get screenshot" }], + }, + ]), + }), + postMessageToWebview: vi.fn(), + }) + + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_request_started") + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "[1 image(s) received]", [ + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ", + ]) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("with 1 image(s)")) + }) + + it("should handle tool response with both text and image content", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "figma-server", + tool_name: "get_node_info", + arguments: '{"nodeId": "123"}', + }, + nativeArgs: { + server_name: "figma-server", + tool_name: "get_node_info", + arguments: { nodeId: "123" }, + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { type: "text", text: "Node name: Button" }, + { + type: "image", + mimeType: "image/png", + data: "base64imagedata", + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + getAllServers: vi + .fn() + .mockReturnValue([ + { name: "figma-server", tools: [{ name: "get_node_info", description: "Get node info" }] }, + ]), + }), + postMessageToWebview: vi.fn(), + }) + + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_request_started") + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Node name: Button", [ + "data:image/png;base64,base64imagedata", + ]) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("with 1 image(s)")) + }) + + it("should handle image with data URL already formatted", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "figma-server", + tool_name: "get_screenshot", + arguments: '{"nodeId": "123"}', + }, + nativeArgs: { + server_name: "figma-server", + tool_name: "get_screenshot", + arguments: { nodeId: "123" }, + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { + type: "image", + mimeType: "image/jpeg", + data: "data:image/jpeg;base64,/9j/4AAQSkZJRg==", + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + getAllServers: vi.fn().mockReturnValue([ + { + name: "figma-server", + tools: [{ name: "get_screenshot", description: "Get screenshot" }], + }, + ]), + }), + postMessageToWebview: vi.fn(), + }) + + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // Should not double-prefix the data URL + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "[1 image(s) received]", [ + "data:image/jpeg;base64,/9j/4AAQSkZJRg==", + ]) + }) + + it("should handle multiple images in response", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "figma-server", + tool_name: "get_screenshots", + arguments: '{"nodeIds": ["1", "2"]}', + }, + nativeArgs: { + server_name: "figma-server", + tool_name: "get_screenshots", + arguments: { nodeIds: ["1", "2"] }, + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { + type: "image", + mimeType: "image/png", + data: "image1data", + }, + { + type: "image", + mimeType: "image/png", + data: "image2data", + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + getAllServers: vi.fn().mockReturnValue([ + { + name: "figma-server", + tools: [{ name: "get_screenshots", description: "Get screenshots" }], + }, + ]), + }), + postMessageToWebview: vi.fn(), + }) + + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "[2 image(s) received]", [ + "data:image/png;base64,image1data", + "data:image/png;base64,image2data", + ]) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("with 2 image(s)")) + }) + }) }) diff --git a/src/core/tools/helpers/fileTokenBudget.ts b/src/core/tools/helpers/fileTokenBudget.ts deleted file mode 100644 index 4023802680..0000000000 --- a/src/core/tools/helpers/fileTokenBudget.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Re-export the new incremental token-based file reader -export { readFileWithTokenBudget } from "../../../integrations/misc/read-file-with-budget" -export type { ReadWithBudgetResult, ReadWithBudgetOptions } from "../../../integrations/misc/read-file-with-budget" - -/** - * Percentage of available context to reserve for file reading. - * The remaining percentage is reserved for the model's response and overhead. - */ -export const FILE_READ_BUDGET_PERCENT = 0.6 // 60% for file, 40% for response diff --git a/src/core/tools/helpers/truncateDefinitions.ts b/src/core/tools/helpers/truncateDefinitions.ts deleted file mode 100644 index 7c193ef52a..0000000000 --- a/src/core/tools/helpers/truncateDefinitions.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Truncate code definitions to only include those within the line limit - * @param definitions - The full definitions string from parseSourceCodeDefinitionsForFile - * @param maxReadFileLine - Maximum line number to include (-1 for no limit, 0 for definitions only) - * @returns Truncated definitions string - */ -export function truncateDefinitionsToLineLimit(definitions: string, maxReadFileLine: number): string { - // If no limit or definitions-only mode (0), return as-is - if (maxReadFileLine <= 0) { - return definitions - } - - const lines = definitions.split("\n") - const result: string[] = [] - let startIndex = 0 - - // Keep the header line (e.g., "# filename.ts") - if (lines.length > 0 && lines[0].startsWith("#")) { - result.push(lines[0]) - startIndex = 1 - } - - // Process definition lines - for (let i = startIndex; i < lines.length; i++) { - const line = lines[i] - - // Match definition format: "startLine--endLine | content" or "lineNumber | content" - // Allow optional leading whitespace to handle indented output or CRLF artifacts - const rangeMatch = line.match(/^\s*(\d+)(?:--(\d+))?\s*\|/) - - if (rangeMatch) { - const startLine = parseInt(rangeMatch[1], 10) - - // Only include definitions that start within the truncated range - if (startLine <= maxReadFileLine) { - result.push(line) - } - } - // Note: We don't preserve empty lines or other non-definition content - // as they're not part of the actual code definitions - } - - return result.join("\n") -} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 86b4869a17..31a0a86343 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -176,7 +176,7 @@ export class ClineProvider public isViewLaunched = false public settingsImportedAt?: number - public readonly latestAnnouncementId = "jan-2026-v3.45.0-smart-code-folding" // v3.45.0 Smart Code Folding + public readonly latestAnnouncementId = "jan-2026-v3.46.0-parallel-tools" // v3.46.0 Parallel Tools & Smarter Reading public readonly providerSettingsManager: ProviderSettingsManager public readonly customModesManager: CustomModesManager @@ -2212,7 +2212,6 @@ export class ClineProvider terminalZshP10k, terminalZdotdir, mcpEnabled, - enableMcpServerCreation, currentApiConfigName, listApiConfigMeta, pinnedApiConfigs, @@ -2232,8 +2231,6 @@ export class ClineProvider showRooIgnoredFiles, enableSubfolderRules, language, - maxReadFileLine, - maxReadCharacterLimit, maxImageFileSize, maxTotalImageSize, historyPreviewCollapsed, @@ -2248,7 +2245,6 @@ export class ClineProvider publicSharingEnabled, organizationAllowList, organizationSettingsVersion, - maxConcurrentFileReads, customCondensingPrompt, codebaseIndexConfig, codebaseIndexModels, @@ -2366,7 +2362,6 @@ export class ClineProvider terminalZshP10k: terminalZshP10k ?? false, terminalZdotdir: terminalZdotdir ?? false, mcpEnabled: mcpEnabled ?? true, - enableMcpServerCreation: enableMcpServerCreation ?? true, currentApiConfigName: currentApiConfigName ?? "default", listApiConfigMeta: listApiConfigMeta ?? [], pinnedApiConfigs: pinnedApiConfigs ?? {}, @@ -2391,11 +2386,8 @@ export class ClineProvider language: language ?? formatLanguage(await defaultLang()), enableSubfolderRules: enableSubfolderRules ?? false, renderContext: this.renderContext, - maxReadFileLine: maxReadFileLine ?? 500, - maxReadCharacterLimit: maxReadCharacterLimit ?? DEFAULT_FILE_READ_CHARACTER_LIMIT, maxImageFileSize: maxImageFileSize ?? 5, maxTotalImageSize: maxTotalImageSize ?? 20, - maxConcurrentFileReads: maxConcurrentFileReads ?? 5, settingsImportedAt: this.settingsImportedAt, hasSystemPromptOverride, historyPreviewCollapsed: historyPreviewCollapsed ?? false, @@ -2632,7 +2624,6 @@ export class ClineProvider zgsmCodeMode: stateValues.zgsmCodeMode ?? "vibe", language: stateValues.language ?? formatLanguage(await defaultLang()), mcpEnabled: stateValues.mcpEnabled ?? true, - enableMcpServerCreation: stateValues.enableMcpServerCreation ?? true, mcpServers: this.mcpHub?.getAllServers() ?? [], currentApiConfigName: stateValues.currentApiConfigName ?? "default", listApiConfigMeta: stateValues.listApiConfigMeta ?? [], @@ -2650,12 +2641,9 @@ export class ClineProvider browserToolEnabled: stateValues.browserToolEnabled ?? true, telemetrySetting: stateValues.telemetrySetting || "unset", showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, - maxReadFileLine: stateValues.maxReadFileLine ?? 500, - maxReadCharacterLimit: stateValues.maxReadCharacterLimit ?? DEFAULT_FILE_READ_CHARACTER_LIMIT, enableSubfolderRules: stateValues.enableSubfolderRules ?? false, maxImageFileSize: stateValues.maxImageFileSize ?? 5, maxTotalImageSize: stateValues.maxTotalImageSize ?? 20, - maxConcurrentFileReads: stateValues.maxConcurrentFileReads ?? 5, historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true, showSpeedInfo: stateValues.showSpeedInfo ?? false, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 22af111a70..101bf20a66 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -349,6 +349,7 @@ vi.mock("../../../api/providers/fetchers/modelCache", () => ({ vi.mock("../diff/strategies/multi-search-replace", () => ({ MultiSearchReplaceDiffStrategy: vi.fn().mockImplementation(() => ({ + getToolDescription: () => "test", getName: () => "test-strategy", applyDiff: vi.fn(), })), @@ -593,7 +594,6 @@ describe("ClineProvider", () => { writeDelayMs: 1000, browserViewportSize: "900x600", mcpEnabled: true, - enableMcpServerCreation: false, mode: defaultModeSlug, customModes: [], experiments: experimentDefault, @@ -606,7 +606,6 @@ describe("ClineProvider", () => { showRooIgnoredFiles: false, enableSubfolderRules: false, renderContext: "sidebar", - maxReadFileLine: 500, maxImageFileSize: 5, maxTotalImageSize: 20, cloudUserInfo: null, @@ -1392,7 +1391,6 @@ describe("ClineProvider", () => { apiProvider: "openrouter" as const, }, mcpEnabled: true, - enableMcpServerCreation: false, mode: "code" as const, experiments: experimentDefault, } as any) @@ -1417,7 +1415,6 @@ describe("ClineProvider", () => { apiProvider: "openrouter" as const, }, mcpEnabled: false, - enableMcpServerCreation: false, mode: "code" as const, experiments: experimentDefault, } as any) @@ -1474,38 +1471,6 @@ describe("ClineProvider", () => { ) }) - test("generates system prompt with various configurations", async () => { - await provider.resolveWebviewView(mockWebviewView) - - // Mock getState with typical configuration - vi.spyOn(provider, "getState").mockResolvedValue({ - apiConfiguration: { - apiProvider: "openrouter", - apiModelId: "test-model", - }, - customModePrompts: {}, - mode: "code", - enableMcpServerCreation: true, - mcpEnabled: false, - browserViewportSize: "900x600", - experiments: experimentDefault, - browserToolEnabled: true, - } as any) - - // Trigger getSystemPrompt - const handler = getMessageHandler() - await handler({ type: "getSystemPrompt", mode: "code" }) - - // Verify system prompt was generated and sent - expect(mockPostMessage).toHaveBeenCalledWith( - expect.objectContaining({ - type: "systemPrompt", - text: expect.any(String), - mode: "code", - }), - ) - }) - test("uses correct mode-specific instructions when mode is specified", async () => { await provider.resolveWebviewView(mockWebviewView) @@ -1518,7 +1483,6 @@ describe("ClineProvider", () => { architect: { customInstructions: "Architect mode instructions" }, }, mode: "architect", - enableMcpServerCreation: false, mcpEnabled: false, browserViewportSize: "900x600", experiments: experimentDefault, diff --git a/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts b/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts index 702c932fd5..9ad2709b61 100644 --- a/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts +++ b/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts @@ -60,11 +60,8 @@ function makeProviderStub() { browserViewportSize: "900x600", mcpEnabled: false, experiments: {}, - enableMcpServerCreation: false, browserToolEnabled: true, // critical: enabled in settings language: "en", - maxReadFileLine: -1, - maxConcurrentFileReads: 5, }), } as any } diff --git a/src/core/webview/__tests__/skillsMessageHandler.spec.ts b/src/core/webview/__tests__/skillsMessageHandler.spec.ts index 900796dd6c..f26194ee81 100644 --- a/src/core/webview/__tests__/skillsMessageHandler.spec.ts +++ b/src/core/webview/__tests__/skillsMessageHandler.spec.ts @@ -26,7 +26,9 @@ vi.mock("../../../i18n", () => ({ "skills:errors.missing_create_fields": "Missing required fields: skillName, source, or skillDescription", "skills:errors.manager_unavailable": "Skills manager not available", "skills:errors.missing_delete_fields": "Missing required fields: skillName or source", + "skills:errors.missing_move_fields": "Missing required fields: skillName or source", "skills:errors.skill_not_found": `Skill "${params?.name}" not found`, + "skills:errors.cannot_modify_builtin": "Built-in skills cannot be created, deleted, or moved", } return translations[key] || key }, @@ -34,7 +36,13 @@ vi.mock("../../../i18n", () => ({ import * as vscode from "vscode" import { openFile } from "../../../integrations/misc/open-file" -import { handleRequestSkills, handleCreateSkill, handleDeleteSkill, handleOpenSkillFile } from "../skillsMessageHandler" +import { + handleRequestSkills, + handleCreateSkill, + handleDeleteSkill, + handleMoveSkill, + handleOpenSkillFile, +} from "../skillsMessageHandler" describe("skillsMessageHandler", () => { const mockLog = vi.fn() @@ -42,6 +50,7 @@ describe("skillsMessageHandler", () => { const mockGetSkillsMetadata = vi.fn() const mockCreateSkill = vi.fn() const mockDeleteSkill = vi.fn() + const mockMoveSkill = vi.fn() const mockGetSkill = vi.fn() const createMockProvider = (hasSkillsManager: boolean = true): ClineProvider => { @@ -50,6 +59,7 @@ describe("skillsMessageHandler", () => { getSkillsMetadata: mockGetSkillsMetadata, createSkill: mockCreateSkill, deleteSkill: mockDeleteSkill, + moveSkill: mockMoveSkill, getSkill: mockGetSkill, } : undefined @@ -253,6 +263,95 @@ describe("skillsMessageHandler", () => { }) }) + describe("handleMoveSkill", () => { + it("moves a skill successfully", async () => { + const provider = createMockProvider(true) + mockMoveSkill.mockResolvedValue(undefined) + mockGetSkillsMetadata.mockReturnValue([mockSkills[0]]) + + const result = await handleMoveSkill(provider, { + type: "moveSkill", + skillName: "test-skill", + source: "global", + skillMode: undefined, + newSkillMode: "code", + } as WebviewMessage) + + expect(result).toEqual([mockSkills[0]]) + expect(mockMoveSkill).toHaveBeenCalledWith("test-skill", "global", undefined, "code") + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: [mockSkills[0]] }) + }) + + it("moves a skill from one mode to another", async () => { + const provider = createMockProvider(true) + mockMoveSkill.mockResolvedValue(undefined) + mockGetSkillsMetadata.mockReturnValue([mockSkills[1]]) + + const result = await handleMoveSkill(provider, { + type: "moveSkill", + skillName: "project-skill", + source: "project", + skillMode: "code", + newSkillMode: "architect", + } as WebviewMessage) + + expect(result).toEqual([mockSkills[1]]) + expect(mockMoveSkill).toHaveBeenCalledWith("project-skill", "project", "code", "architect") + }) + + it("returns undefined when required fields are missing", async () => { + const provider = createMockProvider(true) + + const result = await handleMoveSkill(provider, { + type: "moveSkill", + skillName: "test-skill", + // missing source + } as WebviewMessage) + + expect(result).toBeUndefined() + expect(mockLog).toHaveBeenCalledWith("Error moving skill: Missing required fields: skillName or source") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to move skill: Missing required fields: skillName or source", + ) + }) + + it("returns undefined when skills manager is not available", async () => { + const provider = createMockProvider(false) + + const result = await handleMoveSkill(provider, { + type: "moveSkill", + skillName: "test-skill", + source: "global", + newSkillMode: "code", + } as WebviewMessage) + + expect(result).toBeUndefined() + expect(mockLog).toHaveBeenCalledWith("Error moving skill: Skills manager not available") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to move skill: Skills manager not available", + ) + }) + + it("returns undefined when trying to move a built-in skill", async () => { + const provider = createMockProvider(true) + + const result = await handleMoveSkill(provider, { + type: "moveSkill", + skillName: "test-skill", + source: "built-in", + newSkillMode: "code", + } as WebviewMessage) + + expect(result).toBeUndefined() + expect(mockLog).toHaveBeenCalledWith( + "Error moving skill: Built-in skills cannot be created, deleted, or moved", + ) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to move skill: Built-in skills cannot be created, deleted, or moved", + ) + }) + }) + describe("handleOpenSkillFile", () => { it("opens a skill file successfully", async () => { const provider = createMockProvider(true) diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index ff4662a84f..fb906c9cc8 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -18,11 +18,8 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web browserViewportSize, mcpEnabled, experiments, - enableMcpServerCreation, browserToolEnabled, language, - maxReadFileLine, - maxConcurrentFileReads, terminalShellIntegrationDisabled, enableSubfolderRules, } = await provider.getState() @@ -71,13 +68,10 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web customModes, customInstructions, experiments, - enableMcpServerCreation, language, rooIgnoreInstructions, - maxReadFileLine !== -1, { terminalShellIntegrationDisabled, - maxConcurrentFileReads: maxConcurrentFileReads ?? 5, todoListEnabled: apiConfiguration?.todoListEnabled ?? true, useAgentRules: vscode.workspace.getConfiguration(Package.name).get("useAgentRules") ?? true, enableSubfolderRules: enableSubfolderRules ?? false, diff --git a/src/core/webview/skillsMessageHandler.ts b/src/core/webview/skillsMessageHandler.ts index 649c036b4c..f09f22f58c 100644 --- a/src/core/webview/skillsMessageHandler.ts +++ b/src/core/webview/skillsMessageHandler.ts @@ -44,6 +44,11 @@ export async function handleCreateSkill( throw new Error(t("skills:errors.missing_create_fields")) } + // Built-in skills cannot be created + if (source === "built-in") { + throw new Error(t("skills:errors.cannot_modify_builtin")) + } + const skillsManager = provider.getSkillsManager() if (!skillsManager) { throw new Error(t("skills:errors.manager_unavailable")) @@ -82,6 +87,11 @@ export async function handleDeleteSkill( throw new Error(t("skills:errors.missing_delete_fields")) } + // Built-in skills cannot be deleted + if (source === "built-in") { + throw new Error(t("skills:errors.cannot_modify_builtin")) + } + const skillsManager = provider.getSkillsManager() if (!skillsManager) { throw new Error(t("skills:errors.manager_unavailable")) @@ -101,6 +111,47 @@ export async function handleDeleteSkill( } } +/** + * Handles the moveSkill message - moves a skill to a different mode + */ +export async function handleMoveSkill( + provider: ClineProvider, + message: WebviewMessage, +): Promise { + try { + const skillName = message.skillName + const source = message.source + const currentMode = message.skillMode + const newMode = message.newSkillMode + + if (!skillName || !source) { + throw new Error(t("skills:errors.missing_move_fields")) + } + + // Built-in skills cannot be moved + if (source === "built-in") { + throw new Error(t("skills:errors.cannot_modify_builtin")) + } + + const skillsManager = provider.getSkillsManager() + if (!skillsManager) { + throw new Error(t("skills:errors.manager_unavailable")) + } + + await skillsManager.moveSkill(skillName, source, currentMode, newMode) + + // Send updated skills list + const skills = skillsManager.getSkillsMetadata() + await provider.postMessageToWebview({ type: "skills", skills }) + return skills + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Error moving skill: ${errorMessage}`) + vscode.window.showErrorMessage(`Failed to move skill: ${errorMessage}`) + return undefined + } +} + /** * Handles the openSkillFile message - opens a skill file in the editor */ @@ -114,6 +165,11 @@ export async function handleOpenSkillFile(provider: ClineProvider, message: Webv throw new Error(t("skills:errors.missing_delete_fields")) } + // Built-in skills cannot be opened as files (they have no file path) + if (source === "built-in") { + throw new Error(t("skills:errors.cannot_open_builtin")) + } + const skillsManager = provider.getSkillsManager() if (!skillsManager) { throw new Error(t("skills:errors.manager_unavailable")) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 360c3bf5f8..ba20e7ad70 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -36,7 +36,13 @@ import { ClineProvider } from "./ClineProvider" import { BrowserSessionPanelManager } from "./BrowserSessionPanelManager" import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler" import { generateErrorDiagnostics } from "./diagnosticsHandler" -import { handleRequestSkills, handleCreateSkill, handleDeleteSkill, handleOpenSkillFile } from "./skillsMessageHandler" +import { + handleRequestSkills, + handleCreateSkill, + handleDeleteSkill, + handleMoveSkill, + handleOpenSkillFile, +} from "./skillsMessageHandler" import { changeLanguage, t } from "../../i18n" import { Package } from "../../shared/package" import { type RouterName, toRouterName } from "../../shared/api" @@ -1577,10 +1583,6 @@ export const webviewMessageHandler = async ( } break } - case "enableMcpServerCreation": - await updateGlobalState("enableMcpServerCreation", message.bool ?? true) - await provider.postStateToWebview() - break case "remoteControlEnabled": try { await CloudService.instance.updateUserSettings({ extensionBridgeEnabled: message.bool ?? false }) @@ -2413,10 +2415,9 @@ export const webviewMessageHandler = async ( const yamlContent = await fs.readFile(fileUri[0].fsPath, "utf-8") // Import the mode with the specified source level - const result = await provider.customModesManager.importModeWithRules( - yamlContent, - message.source || "project", // Default to project if not specified - ) + // Note: "built-in" is not a valid source for importing modes + const importSource = message.source === "global" ? "global" : "project" + const result = await provider.customModesManager.importModeWithRules(yamlContent, importSource) if (result.success) { // Update state after importing @@ -3265,6 +3266,10 @@ export const webviewMessageHandler = async ( await handleDeleteSkill(provider, message) break } + case "moveSkill": { + await handleMoveSkill(provider, message) + break + } case "openSkillFile": { await handleOpenSkillFile(provider, message) break diff --git a/src/extension.ts b/src/extension.ts index a5eb15fc59..52fa9e1450 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,16 +1,21 @@ import * as vscode from "vscode" import * as dotenvx from "@dotenvx/dotenvx" +import * as fs from "fs" import * as path from "path" import * as ZgsmCore from "./core/costrict" // Load environment variables from .env file -try { - // Specify path to .env file in the project root directory - const envPath = path.join(__dirname, "..", ".env") - dotenvx.config({ path: envPath }) -} catch (e) { - // Silently handle environment loading errors - console.warn("Failed to load environment variables:", e) +// The extension-level .env is optional (not shipped in production builds). +// Avoid calling dotenvx when the file doesn't exist, otherwise dotenvx emits +// a noisy [MISSING_ENV_FILE] error to the extension host console. +const envPath = path.join(__dirname, "..", ".env") +if (fs.existsSync(envPath)) { + try { + dotenvx.config({ path: envPath }) + } catch (e) { + // Best-effort only: never fail extension activation due to optional env loading. + console.warn("Failed to load environment variables:", e) + } } // import type { CloudUserInfo, AuthState } from "@roo-code/types" diff --git a/src/i18n/locales/en/skills.json b/src/i18n/locales/en/skills.json index 9cf7369bc9..ef4d7e68e3 100644 --- a/src/i18n/locales/en/skills.json +++ b/src/i18n/locales/en/skills.json @@ -7,8 +7,11 @@ "already_exists": "Skill \"{{name}}\" already exists at {{path}}", "not_found": "Skill \"{{name}}\" not found in {{source}}{{modeInfo}}", "missing_create_fields": "Missing required fields: skillName, source, or skillDescription", + "missing_move_fields": "Missing required fields: skillName or source", "manager_unavailable": "Skills manager not available", "missing_delete_fields": "Missing required fields: skillName or source", - "skill_not_found": "Skill \"{{name}}\" not found" + "skill_not_found": "Skill \"{{name}}\" not found", + "cannot_modify_builtin": "Built-in skills cannot be created, deleted, or moved", + "cannot_open_builtin": "Built-in skills cannot be opened as files" } } diff --git a/src/i18n/locales/zh-CN/skills.json b/src/i18n/locales/zh-CN/skills.json index 629aaeb6d7..566f583fee 100644 --- a/src/i18n/locales/zh-CN/skills.json +++ b/src/i18n/locales/zh-CN/skills.json @@ -7,8 +7,11 @@ "already_exists": "技能 \"{{name}}\" 已存在于 {{path}}", "not_found": "在 {{source}}{{modeInfo}} 中未找到技能 \"{{name}}\"", "missing_create_fields": "缺少必填字段:skillName、source 或 skillDescription", + "missing_move_fields": "缺少必填字段:skillName 或 source", "manager_unavailable": "技能管理器不可用", "missing_delete_fields": "缺少必填字段:skillName 或 source", - "skill_not_found": "未找到技能 \"{{name}}\"" + "skill_not_found": "未找到技能 \"{{name}}\"", + "cannot_modify_builtin": "内置技能无法创建或删除", + "cannot_open_builtin": "内置技能无法作为文件打开" } } diff --git a/src/i18n/locales/zh-TW/skills.json b/src/i18n/locales/zh-TW/skills.json index 10705f4cce..633bb1a6b2 100644 --- a/src/i18n/locales/zh-TW/skills.json +++ b/src/i18n/locales/zh-TW/skills.json @@ -7,8 +7,11 @@ "already_exists": "技能「{{name}}」已存在於 {{path}}", "not_found": "在 {{source}}{{modeInfo}} 中找不到技能「{{name}}」", "missing_create_fields": "缺少必填欄位:skillName、source 或 skillDescription", + "missing_move_fields": "缺少必填欄位:skillName 或 source", "manager_unavailable": "技能管理器無法使用", "missing_delete_fields": "缺少必填欄位:skillName 或 source", - "skill_not_found": "找不到技能「{{name}}」" + "skill_not_found": "找不到技能「{{name}}」", + "cannot_modify_builtin": "內建技能無法建立或刪除", + "cannot_open_builtin": "內建技能無法作為檔案開啟" } } diff --git a/src/integrations/misc/__tests__/extract-text-large-files.spec.ts b/src/integrations/misc/__tests__/extract-text-large-files.spec.ts deleted file mode 100644 index de9d1da789..0000000000 --- a/src/integrations/misc/__tests__/extract-text-large-files.spec.ts +++ /dev/null @@ -1,225 +0,0 @@ -// npx vitest run integrations/misc/__tests__/extract-text-large-files.spec.ts - -import * as fs from "fs/promises" - -import { extractTextFromFile } from "../extract-text" -import { countFileLines } from "../line-counter" -import { readLines } from "../read-lines" -import { isBinaryFileWithEncodingDetection, readFileWithEncodingDetection } from "../../../utils/encoding" - -// Mock all dependencies -vi.mock("fs/promises") -vi.mock("../line-counter") -vi.mock("../read-lines") -vi.mock("../../../utils/encoding", () => ({ - isBinaryFileWithEncodingDetection: vi.fn(), - readFileWithEncodingDetection: vi.fn(), -})) - -describe("extractTextFromFile - Large File Handling", () => { - // Type the mocks - const mockedFs = vi.mocked(fs) - const mockedCountFileLines = vi.mocked(countFileLines) - const mockedReadLines = vi.mocked(readLines) - const mockedIsBinaryFileWithEncodingDetection = vi.mocked(isBinaryFileWithEncodingDetection) - const mockedReadFileWithEncodingDetection = vi.mocked(readFileWithEncodingDetection) - - beforeEach(() => { - vi.clearAllMocks() - // Set default mock behavior - mockedFs.access.mockResolvedValue(undefined) - mockedIsBinaryFileWithEncodingDetection.mockResolvedValue(false) - }) - - it("should truncate files that exceed maxReadFileLine limit", async () => { - const largeFileContent = Array(150) - .fill(null) - .map((_, i) => `Line ${i + 1}: This is a test line with some content`) - .join("\n") - - mockedCountFileLines.mockResolvedValue(150) - mockedReadLines.mockResolvedValue( - Array(100) - .fill(null) - .map((_, i) => `Line ${i + 1}: This is a test line with some content`) - .join("\n"), - ) - - const result = await extractTextFromFile("/test/large-file.ts", 100) - - // Should only include first 100 lines with line numbers - expect(result).toContain(" 1 | Line 1: This is a test line with some content") - expect(result).toContain("100 | Line 100: This is a test line with some content") - expect(result).not.toContain("101 | Line 101: This is a test line with some content") - - // Should include truncation message - expect(result).toContain( - "[File truncated: showing 100 of 150 total lines. The file is too large and may exhaust the context window if read in full.]", - ) - }) - - it("should not truncate files within the maxReadFileLine limit", async () => { - const smallFileContent = Array(50) - .fill(null) - .map((_, i) => `Line ${i + 1}: This is a test line`) - .join("\n") - - mockedCountFileLines.mockResolvedValue(50) - mockedReadFileWithEncodingDetection.mockResolvedValue(smallFileContent) - - const result = await extractTextFromFile("/test/small-file.ts", 100) - - // Should include all lines with line numbers - expect(result).toContain(" 1 | Line 1: This is a test line") - expect(result).toContain("50 | Line 50: This is a test line") - - // Should not include truncation message - expect(result).not.toContain("[File truncated:") - }) - - it("should handle files with exactly maxReadFileLine lines", async () => { - const exactFileContent = Array(100) - .fill(null) - .map((_, i) => `Line ${i + 1}`) - .join("\n") - - mockedCountFileLines.mockResolvedValue(100) - mockedReadFileWithEncodingDetection.mockResolvedValue(exactFileContent) - - const result = await extractTextFromFile("/test/exact-file.ts", 100) - - // Should include all lines with line numbers - expect(result).toContain(" 1 | Line 1") - expect(result).toContain("100 | Line 100") - - // Should not include truncation message - expect(result).not.toContain("[File truncated:") - }) - - it("should handle undefined maxReadFileLine by not truncating", async () => { - const largeFileContent = Array(200) - .fill(null) - .map((_, i) => `Line ${i + 1}`) - .join("\n") - - mockedReadFileWithEncodingDetection.mockResolvedValue(largeFileContent) - - const result = await extractTextFromFile("/test/large-file.ts", undefined) - - // Should include all lines with line numbers when maxReadFileLine is undefined - expect(result).toContain(" 1 | Line 1") - expect(result).toContain("200 | Line 200") - - // Should not include truncation message - expect(result).not.toContain("[File truncated:") - }) - - it("should handle empty files", async () => { - mockedReadFileWithEncodingDetection.mockResolvedValue("") - - const result = await extractTextFromFile("/test/empty-file.ts", 100) - - expect(result).toBe("") - expect(result).not.toContain("[File truncated:") - }) - - it("should handle files with only newlines", async () => { - const newlineOnlyContent = "\n\n\n\n\n" - - mockedCountFileLines.mockResolvedValue(6) // 5 newlines = 6 lines - mockedReadLines.mockResolvedValue("\n\n") - - const result = await extractTextFromFile("/test/newline-file.ts", 3) - - // Should truncate at line 3 - expect(result).toContain("[File truncated: showing 3 of 6 total lines") - }) - - it("should handle very large files efficiently", async () => { - // Simulate a 10,000 line file - mockedCountFileLines.mockResolvedValue(10000) - mockedReadLines.mockResolvedValue( - Array(500) - .fill(null) - .map((_, i) => `Line ${i + 1}: Some content here`) - .join("\n"), - ) - - const result = await extractTextFromFile("/test/very-large-file.ts", 500) - - // Should only include first 500 lines with line numbers - expect(result).toContain(" 1 | Line 1: Some content here") - expect(result).toContain("500 | Line 500: Some content here") - expect(result).not.toContain("501 | Line 501: Some content here") - - // Should show truncation message - expect(result).toContain("[File truncated: showing 500 of 10000 total lines") - }) - - it("should handle maxReadFileLine of 0 by throwing an error", async () => { - const fileContent = "Line 1\nLine 2\nLine 3" - - mockedReadFileWithEncodingDetection.mockResolvedValue(fileContent) - - // maxReadFileLine of 0 should throw an error - await expect(extractTextFromFile("/test/file.ts", 0)).rejects.toThrow( - "Invalid maxReadFileLine: 0. Must be a positive integer or -1 for unlimited.", - ) - }) - - it("should handle negative maxReadFileLine by treating as undefined", async () => { - const fileContent = "Line 1\nLine 2\nLine 3" - - mockedReadFileWithEncodingDetection.mockResolvedValue(fileContent) - - const result = await extractTextFromFile("/test/file.ts", -1) - - // Should include all content with line numbers when negative - expect(result).toContain("1 | Line 1") - expect(result).toContain("2 | Line 2") - expect(result).toContain("3 | Line 3") - expect(result).not.toContain("[File truncated:") - }) - - it("should preserve file content structure when truncating", async () => { - const structuredContent = [ - "function example() {", - " const x = 1;", - " const y = 2;", - " return x + y;", - "}", - "", - "// More code below", - ].join("\n") - - mockedCountFileLines.mockResolvedValue(7) - mockedReadLines.mockResolvedValue(["function example() {", " const x = 1;", " const y = 2;"].join("\n")) - - const result = await extractTextFromFile("/test/structured.ts", 3) - - // Should preserve the first 3 lines with line numbers - expect(result).toContain("1 | function example() {") - expect(result).toContain("2 | const x = 1;") - expect(result).toContain("3 | const y = 2;") - expect(result).not.toContain("4 | return x + y;") - - // Should include truncation info - expect(result).toContain("[File truncated: showing 3 of 7 total lines") - }) - - it("should handle binary files by throwing an error", async () => { - mockedIsBinaryFileWithEncodingDetection.mockResolvedValue(true) - - await expect(extractTextFromFile("/test/binary.bin", 100)).rejects.toThrow( - "Cannot read text for file type: .bin", - ) - }) - - it("should handle file not found errors", async () => { - mockedFs.access.mockRejectedValue(new Error("ENOENT")) - - await expect(extractTextFromFile("/test/nonexistent.ts", 100)).rejects.toThrow( - "File not found: /test/nonexistent.ts", - ) - }) -}) diff --git a/src/integrations/misc/__tests__/extract-text.spec.ts b/src/integrations/misc/__tests__/extract-text.spec.ts index 96478c983c..bb4b52fe93 100644 --- a/src/integrations/misc/__tests__/extract-text.spec.ts +++ b/src/integrations/misc/__tests__/extract-text.spec.ts @@ -709,169 +709,3 @@ describe("processCarriageReturns", () => { expect(processCarriageReturns(input)).toBe(expected) }) }) - -describe("extractTextFromFile with character limit", () => { - const fs = require("fs/promises") - const path = require("path") - const os = require("os") - - let tempDir: string - let testFilePath: string - - beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "extract-text-test-")) - testFilePath = path.join(tempDir, "test.txt") - }) - - afterEach(async () => { - try { - await fs.rm(tempDir, { recursive: true, force: true }) - } catch (error) { - // Ignore cleanup errors - } - }) - - it("should apply character limit when file content exceeds limit", async () => { - // Create a file with a large number of characters - const longContent = "a".repeat(1000) - await fs.writeFile(testFilePath, longContent) - - const { extractTextFromFile } = await import("../extract-text") - const result = await extractTextFromFile(testFilePath, undefined, 100) - - // 应该被字符限制截断 - expect(result.length).toBeLessThan(longContent.length + 50) // 加上行号和截断信息 - expect(result).toContain("[...") // 应该包含截断标识 - expect(result).toContain("characters omitted...]") - }) - - it("should apply character limit even when line limit is not exceeded", async () => { - // Create a file with few lines but each line is very long - const longLine = "x".repeat(500) - const content = `${longLine}\n${longLine}\n${longLine}` - await fs.writeFile(testFilePath, content) - - const { extractTextFromFile } = await import("../extract-text") - const result = await extractTextFromFile(testFilePath, 10, 200) // Line limit: 10, Character limit: 200 - - // Character limit should take precedence - expect(result).toContain("characters omitted") - expect(result).not.toContain("lines omitted") - }) - - it("should apply both line and character limits when line limit is exceeded first", async () => { - // 创建很多短行的文件 - const lines = Array.from({ length: 50 }, (_, i) => `line${i + 1}`) - const content = lines.join("\n") - await fs.writeFile(testFilePath, content) - - const { extractTextFromFile } = await import("../extract-text") - const result = await extractTextFromFile(testFilePath, 10, 10000) // 行数限制10,字符限制很大 - - // 行数限制应该先生效,应该只显示行数截断信息 - expect(result).toContain("showing 10 of 50 total lines") - expect(result).not.toContain("character limit") - }) - - it("should show different truncation messages for different scenarios", async () => { - // Test scenario 1: Only line limit - const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`) - const content1 = lines.join("\n") - await fs.writeFile(testFilePath, content1) - - const { extractTextFromFile } = await import("../extract-text") - const result1 = await extractTextFromFile(testFilePath, 5, undefined) - expect(result1).toContain("showing 5 of 20 total lines") - expect(result1).not.toContain("character limit") - - // Test scenario 2: Both line and character limits are active - const longLines = Array.from({ length: 20 }, (_, i) => `${"x".repeat(100)}_line${i + 1}`) - const content2 = longLines.join("\n") - await fs.writeFile(testFilePath, content2) - - const result2 = await extractTextFromFile(testFilePath, 5, 200) - expect(result2).toContain("showing 5 of 20 total lines") - expect(result2).toContain("character limit (200)") - - // Test scenario 3: Only character limit - const longContent = "a".repeat(1000) - await fs.writeFile(testFilePath, longContent) - - const result3 = await extractTextFromFile(testFilePath, undefined, 100) - expect(result3).toContain("characters omitted") - expect(result3).toContain("character limit (100)") - expect(result3).not.toContain("total lines") - }) - - it("should not apply character limit when content is within limit", async () => { - const shortContent = "short content" - await fs.writeFile(testFilePath, shortContent) - - const { extractTextFromFile } = await import("../extract-text") - const result = await extractTextFromFile(testFilePath, undefined, 1000) - - // Content should be preserved intact, only add line numbers - expect(result).toBe("1 | short content\n") - expect(result).not.toContain("characters omitted") - }) - - it("should handle character limit with line limit when both are exceeded", async () => { - // Create a file with many long lines - const longLine = "y".repeat(100) - const lines = Array.from({ length: 30 }, (_, i) => `${longLine}_${i + 1}`) - const content = lines.join("\n") - await fs.writeFile(testFilePath, content) - - const { extractTextFromFile } = await import("../extract-text") - const result = await extractTextFromFile(testFilePath, 5, 500) // 行数限制5,字符限制500 - - // 行数限制先生效,然后字符限制应用到截断后的内容 - expect(result).toContain("showing 5 of 30 total lines") - // 字符限制也应该应用 - expect(result).toContain("characters omitted") - }) - - it("should validate maxReadCharacterLimit parameter", async () => { - await fs.writeFile(testFilePath, "test content") - - const { extractTextFromFile } = await import("../extract-text") - - // Test invalid character limit parameter - await expect(extractTextFromFile(testFilePath, undefined, 0)).rejects.toThrow( - "Invalid maxReadCharacterLimit: 0. Must be a positive integer or undefined for unlimited.", - ) - - await expect(extractTextFromFile(testFilePath, undefined, -1)).rejects.toThrow( - "Invalid maxReadCharacterLimit: -1. Must be a positive integer or undefined for unlimited.", - ) - }) - - it("should work correctly when maxReadCharacterLimit is undefined", async () => { - const content = "test content without limit" - await fs.writeFile(testFilePath, content) - - const { extractTextFromFile } = await import("../extract-text") - const result = await extractTextFromFile(testFilePath, undefined, undefined) - - // Should return complete content with line numbers - expect(result).toBe("1 | test content without limit\n") - }) - - it("should apply character limit to line-limited content correctly", async () => { - // Create content where line count exceeds limit but character count is within limit - const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`) - const content = lines.join("\n") - await fs.writeFile(testFilePath, content) - - const { extractTextFromFile } = await import("../extract-text") - const result = await extractTextFromFile(testFilePath, 5, 200) // 行数限制5,字符限制200 - - // 行数限制先生效 - expect(result).toContain("showing 5 of 20 total lines") - - // 然后字符限制应用到结果上 - if (result.length > 200) { - expect(result).toContain("characters omitted") - } - }) -}) diff --git a/src/integrations/misc/__tests__/indentation-reader.spec.ts b/src/integrations/misc/__tests__/indentation-reader.spec.ts new file mode 100644 index 0000000000..d46cb54277 --- /dev/null +++ b/src/integrations/misc/__tests__/indentation-reader.spec.ts @@ -0,0 +1,639 @@ +import { describe, it, expect } from "vitest" +import { + parseLines, + formatWithLineNumbers, + readWithIndentation, + readWithSlice, + computeEffectiveIndents, + type LineRecord, + type IndentationReadResult, +} from "../indentation-reader" + +// ─── Test Fixtures ──────────────────────────────────────────────────────────── + +const PYTHON_CODE = `#!/usr/bin/env python3 +"""Module docstring.""" +import os +import sys +from typing import List + +class Calculator: + """A simple calculator class.""" + + def __init__(self, value: int = 0): + self.value = value + + def add(self, n: int) -> int: + """Add a number.""" + self.value += n + return self.value + + def subtract(self, n: int) -> int: + """Subtract a number.""" + self.value -= n + return self.value + + def reset(self): + """Reset to zero.""" + self.value = 0 + +def main(): + calc = Calculator() + calc.add(5) + print(calc.value) + +if __name__ == "__main__": + main() +` + +const TYPESCRIPT_CODE = `import { something } from "./module" +import type { SomeType } from "./types" + +// Constants +const MAX_VALUE = 100 + +interface Config { + name: string + value: number +} + +class Handler { + private config: Config + + constructor(config: Config) { + this.config = config + } + + process(input: string): string { + // Process the input + const result = input.toUpperCase() + if (result.length > MAX_VALUE) { + return result.slice(0, MAX_VALUE) + } + return result + } + + validate(data: unknown): boolean { + if (typeof data !== "string") { + return false + } + return data.length > 0 + } +} + +export function createHandler(config: Config): Handler { + return new Handler(config) +} +` + +const SIMPLE_CODE = `function outer() { + function inner() { + console.log("hello") + } + inner() +} +` + +const CODE_WITH_BLANKS = `class Example: + def method_one(self): + x = 1 + + y = 2 + + return x + y + + def method_two(self): + return 42 +` + +// ─── parseLines Tests ───────────────────────────────────────────────────────── + +describe("parseLines", () => { + it("should parse lines with correct line numbers", () => { + const content = "line1\nline2\nline3" + const lines = parseLines(content) + + expect(lines).toHaveLength(3) + expect(lines[0].lineNumber).toBe(1) + expect(lines[1].lineNumber).toBe(2) + expect(lines[2].lineNumber).toBe(3) + }) + + it("should calculate indentation levels correctly", () => { + const content = "no indent\n one level\n two levels\n\t\ttab indent" + const lines = parseLines(content) + + expect(lines[0].indentLevel).toBe(0) + expect(lines[1].indentLevel).toBe(1) // 4 spaces = 1 level + expect(lines[2].indentLevel).toBe(2) // 8 spaces = 2 levels + expect(lines[3].indentLevel).toBe(2) // 2 tabs = 2 levels (tabs = 4 spaces each) + }) + + it("should identify blank lines", () => { + const content = "content\n\n \nmore content" + const lines = parseLines(content) + + expect(lines[0].isBlank).toBe(false) + expect(lines[1].isBlank).toBe(true) // empty + expect(lines[2].isBlank).toBe(true) // whitespace only + expect(lines[3].isBlank).toBe(false) + }) + + it("should identify block starts (Python style)", () => { + const content = "def foo():\n pass\nclass Bar:\n pass" + const lines = parseLines(content) + + expect(lines[0].isBlockStart).toBe(true) // def foo(): + expect(lines[1].isBlockStart).toBe(false) // pass + expect(lines[2].isBlockStart).toBe(true) // class Bar: + }) + + it("should identify block starts (C-style)", () => { + const content = "function foo() {\n return\n}\nif (x) {" + const lines = parseLines(content) + + expect(lines[0].isBlockStart).toBe(true) // function foo() { + expect(lines[1].isBlockStart).toBe(false) // return + expect(lines[2].isBlockStart).toBe(false) // } + expect(lines[3].isBlockStart).toBe(true) // if (x) { + }) + + it("should handle empty content", () => { + const lines = parseLines("") + expect(lines).toHaveLength(1) + expect(lines[0].isBlank).toBe(true) + }) +}) + +// ─── computeEffectiveIndents Tests ──────────────────────────────────────────── + +describe("computeEffectiveIndents", () => { + it("should return same indents for non-blank lines", () => { + const content = "line1\n line2\n line3" + const lines = parseLines(content) + const effective = computeEffectiveIndents(lines) + + expect(effective[0]).toBe(0) + expect(effective[1]).toBe(1) + expect(effective[2]).toBe(2) + }) + + it("should inherit previous indent for blank lines", () => { + const content = "line1\n line2\n\n line3" + const lines = parseLines(content) + const effective = computeEffectiveIndents(lines) + + expect(effective[0]).toBe(0) // line1 + expect(effective[1]).toBe(1) // line2 (indent 1) + expect(effective[2]).toBe(1) // blank line inherits from line2 + expect(effective[3]).toBe(1) // line3 + }) + + it("should handle multiple consecutive blank lines", () => { + const content = " start\n\n\n\n end" + const lines = parseLines(content) + const effective = computeEffectiveIndents(lines) + + expect(effective[0]).toBe(1) // start + expect(effective[1]).toBe(1) // blank inherits + expect(effective[2]).toBe(1) // blank inherits + expect(effective[3]).toBe(1) // blank inherits + expect(effective[4]).toBe(1) // end + }) + + it("should handle blank line at start", () => { + const content = "\n content" + const lines = parseLines(content) + const effective = computeEffectiveIndents(lines) + + expect(effective[0]).toBe(0) // blank at start has no previous, defaults to 0 + expect(effective[1]).toBe(1) // content + }) +}) + +// ─── formatWithLineNumbers Tests ────────────────────────────────────────────── + +describe("formatWithLineNumbers", () => { + it("should format lines with line numbers", () => { + const lines: LineRecord[] = [ + { lineNumber: 1, content: "first", indentLevel: 0, isBlank: false, isBlockStart: false }, + { lineNumber: 2, content: "second", indentLevel: 0, isBlank: false, isBlockStart: false }, + ] + + const result = formatWithLineNumbers(lines) + expect(result).toBe("1 | first\n2 | second") + }) + + it("should pad line numbers for alignment", () => { + const lines: LineRecord[] = [ + { lineNumber: 1, content: "a", indentLevel: 0, isBlank: false, isBlockStart: false }, + { lineNumber: 10, content: "b", indentLevel: 0, isBlank: false, isBlockStart: false }, + { lineNumber: 100, content: "c", indentLevel: 0, isBlank: false, isBlockStart: false }, + ] + + const result = formatWithLineNumbers(lines) + expect(result).toBe(" 1 | a\n 10 | b\n100 | c") + }) + + it("should truncate long lines", () => { + const longLine = "x".repeat(600) + const lines: LineRecord[] = [ + { lineNumber: 1, content: longLine, indentLevel: 0, isBlank: false, isBlockStart: false }, + ] + + const result = formatWithLineNumbers(lines, 100) + expect(result.length).toBeLessThan(longLine.length) + expect(result).toContain("...") + }) + + it("should handle empty array", () => { + const result = formatWithLineNumbers([]) + expect(result).toBe("") + }) +}) + +// ─── readWithSlice Tests ────────────────────────────────────────────────────── + +describe("readWithSlice", () => { + it("should read from beginning with default offset", () => { + const result = readWithSlice(SIMPLE_CODE, 0, 10) + + expect(result.totalLines).toBe(7) // 6 lines + empty trailing + expect(result.returnedLines).toBe(7) + expect(result.wasTruncated).toBe(false) + expect(result.content).toContain("1 | function outer()") + }) + + it("should respect offset parameter", () => { + const result = readWithSlice(SIMPLE_CODE, 2, 10) + + expect(result.content).not.toContain("function outer()") + expect(result.content).toContain("console.log") + expect(result.includedRanges[0][0]).toBe(3) // 1-based, offset 2 = line 3 + }) + + it("should respect limit parameter", () => { + const result = readWithSlice(TYPESCRIPT_CODE, 0, 5) + + expect(result.returnedLines).toBe(5) + expect(result.wasTruncated).toBe(true) + }) + + it("should handle offset beyond file end", () => { + const result = readWithSlice(SIMPLE_CODE, 1000, 10) + + expect(result.returnedLines).toBe(0) + expect(result.content).toContain("Error") + }) + + it("should handle negative offset", () => { + const result = readWithSlice(SIMPLE_CODE, -5, 10) + + // Should normalize to 0 + expect(result.includedRanges[0][0]).toBe(1) + }) +}) + +// ─── readWithIndentation Tests ──────────────────────────────────────────────── + +describe("readWithIndentation", () => { + describe("basic block extraction", () => { + it("should extract content around the anchor line", () => { + const result = readWithIndentation(PYTHON_CODE, { + anchorLine: 15, // Inside add() method + maxLevels: 0, // unlimited + includeHeader: false, + includeSiblings: false, + }) + + expect(result.content).toContain("def add") + expect(result.content).toContain("self.value += n") + expect(result.content).toContain("return self.value") + }) + + it("should handle anchor at first line", () => { + const result = readWithIndentation(SIMPLE_CODE, { + anchorLine: 1, + maxLevels: 0, + includeHeader: false, + }) + + expect(result.returnedLines).toBeGreaterThan(0) + expect(result.content).toContain("function outer()") + }) + + it("should handle anchor at last line", () => { + const lines = PYTHON_CODE.trim().split("\n").length + const result = readWithIndentation(PYTHON_CODE, { + anchorLine: lines, + maxLevels: 0, + includeHeader: false, + }) + + expect(result.returnedLines).toBeGreaterThan(0) + }) + }) + + describe("max_levels behavior", () => { + it("should include all content when maxLevels=0 (unlimited)", () => { + const result = readWithIndentation(SIMPLE_CODE, { + anchorLine: 3, // Inside inner() + maxLevels: 0, + includeHeader: false, + includeSiblings: false, + }) + + // With unlimited levels, should get the whole file + expect(result.content).toContain("function outer()") + expect(result.content).toContain("function inner()") + expect(result.content).toContain("console.log") + }) + + it("should limit expansion when maxLevels > 0", () => { + const result = readWithIndentation(SIMPLE_CODE, { + anchorLine: 3, // Inside inner() + maxLevels: 1, + includeHeader: false, + includeSiblings: false, + }) + + // With 1 level, should include inner() context but may not reach outer() + expect(result.content).toContain("console.log") + }) + + it("should handle deeply nested code with unlimited levels", () => { + const result = readWithIndentation(PYTHON_CODE, { + anchorLine: 15, // Inside add() method body + maxLevels: 0, // unlimited + includeHeader: false, + includeSiblings: false, + }) + + // Should expand to include class context + expect(result.content).toContain("class Calculator") + }) + }) + + describe("sibling blocks", () => { + it("should exclude siblings when includeSiblings is false", () => { + const result = readWithIndentation(PYTHON_CODE, { + anchorLine: 15, // Inside add() method + maxLevels: 1, + includeSiblings: false, + includeHeader: false, + }) + + // Should focus on add() but not include subtract() or other siblings + expect(result.content).toContain("def add") + }) + + it("should include siblings when includeSiblings is true", () => { + const result = readWithIndentation(PYTHON_CODE, { + anchorLine: 15, // Inside add() method + maxLevels: 1, + includeSiblings: true, + includeHeader: false, + }) + + // Should include sibling methods + expect(result.content).toContain("def add") + // May include other siblings depending on limit + }) + }) + + describe("file header (includeHeader option)", () => { + it("should allow comment lines at min indent when includeHeader is true", () => { + // The Codex algorithm's includeHeader option allows comment lines at the + // minimum indent level to be included during upward expansion. + // This is different from prepending the file's import header. + const result = readWithIndentation(PYTHON_CODE, { + anchorLine: 15, + maxLevels: 0, // unlimited - will expand to indent 0 + includeHeader: true, + includeSiblings: false, + }) + + // With unlimited levels, bidirectional expansion will include content + // at indent level 0. includeHeader allows comment lines to be included. + expect(result.returnedLines).toBeGreaterThan(0) + expect(result.content).toContain("def add") + }) + + it("should expand to top-level content with maxLevels=0", () => { + const result = readWithIndentation(PYTHON_CODE, { + anchorLine: 15, + maxLevels: 0, // unlimited + includeHeader: false, + includeSiblings: false, + }) + + // With unlimited levels, expansion goes to indent 0 + // which includes the class definition + expect(result.content).toContain("class Calculator") + }) + + it("should include class content when anchored inside a method", () => { + const result = readWithIndentation(TYPESCRIPT_CODE, { + anchorLine: 20, // Inside Handler class + maxLevels: 0, + includeHeader: true, + includeSiblings: false, + }) + + // Should include class context + expect(result.content).toContain("class Handler") + }) + }) + + describe("line limit and max_lines", () => { + it("should truncate output when exceeding limit", () => { + const result = readWithIndentation(TYPESCRIPT_CODE, { + anchorLine: 15, + maxLevels: 0, + includeHeader: true, + includeSiblings: true, + limit: 10, + }) + + expect(result.returnedLines).toBeLessThanOrEqual(10) + expect(result.wasTruncated).toBe(true) + }) + + it("should not truncate when under limit", () => { + const result = readWithIndentation(SIMPLE_CODE, { + anchorLine: 3, + maxLevels: 1, + includeHeader: false, + limit: 100, + }) + + expect(result.wasTruncated).toBe(false) + }) + + it("should respect maxLines as separate hard cap", () => { + const result = readWithIndentation(TYPESCRIPT_CODE, { + anchorLine: 20, + maxLevels: 0, + includeHeader: true, + includeSiblings: true, + limit: 100, + maxLines: 5, // Hard cap at 5 + }) + + expect(result.returnedLines).toBeLessThanOrEqual(5) + }) + + it("should use min of limit and maxLines", () => { + const result = readWithIndentation(TYPESCRIPT_CODE, { + anchorLine: 20, + maxLevels: 0, + includeHeader: true, + includeSiblings: true, + limit: 3, // More restrictive than maxLines + maxLines: 10, + }) + + expect(result.returnedLines).toBeLessThanOrEqual(3) + }) + }) + + describe("blank line handling", () => { + it("should treat blank lines with inherited indentation", () => { + const result = readWithIndentation(CODE_WITH_BLANKS, { + anchorLine: 4, // blank line inside method_one + maxLevels: 1, + includeHeader: false, + includeSiblings: false, + }) + + // Blank line should inherit previous indent and be included in expansion + expect(result.returnedLines).toBeGreaterThan(0) + }) + + it("should trim empty lines from edges of result", () => { + const result = readWithIndentation(CODE_WITH_BLANKS, { + anchorLine: 3, // x = 1 + maxLevels: 1, + includeHeader: false, + includeSiblings: false, + }) + + // Check that result doesn't start or end with blank lines + const lines = result.content.split("\n") + if (lines.length > 0) { + const firstLine = lines[0] + const lastLine = lines[lines.length - 1] + // Lines should have content after the line number prefix + expect(firstLine).toMatch(/\d+\s*\|/) + expect(lastLine).toMatch(/\d+\s*\|/) + } + }) + }) + + describe("error handling", () => { + it("should handle invalid anchor line (too low)", () => { + const result = readWithIndentation(PYTHON_CODE, { + anchorLine: 0, + maxLevels: 1, + }) + + expect(result.content).toContain("Error") + expect(result.returnedLines).toBe(0) + }) + + it("should handle invalid anchor line (too high)", () => { + const result = readWithIndentation(PYTHON_CODE, { + anchorLine: 9999, + maxLevels: 1, + }) + + expect(result.content).toContain("Error") + expect(result.returnedLines).toBe(0) + }) + }) + + describe("bidirectional expansion", () => { + it("should expand both up and down from anchor", () => { + const result = readWithIndentation(SIMPLE_CODE, { + anchorLine: 3, // console.log("hello") - in the middle + maxLevels: 0, + includeHeader: false, + includeSiblings: false, + limit: 10, + }) + + // Should include lines both before and after anchor + expect(result.content).toContain("function inner()") + expect(result.content).toContain("console.log") + }) + + it("should return single line when limit is 1", () => { + const result = readWithIndentation(SIMPLE_CODE, { + anchorLine: 3, + maxLevels: 0, + includeHeader: false, + includeSiblings: false, + limit: 1, + }) + + expect(result.returnedLines).toBe(1) + expect(result.content).toContain("console.log") + }) + + it("should stop expansion when hitting lower indent", () => { + const result = readWithIndentation(PYTHON_CODE, { + anchorLine: 15, // Inside add() method body (return self.value) + maxLevels: 2, // Only go up 2 levels from anchor indent + includeHeader: false, + includeSiblings: false, + }) + + // Should include method but respect maxLevels + expect(result.content).toContain("def add") + }) + }) + + describe("real-world scenarios", () => { + it("should extract a function with its context", () => { + const result = readWithIndentation(TYPESCRIPT_CODE, { + anchorLine: 37, // Inside createHandler function body (return statement) + maxLevels: 0, + includeHeader: true, + includeSiblings: false, + }) + + expect(result.content).toContain("export function createHandler") + expect(result.content).toContain("return new Handler") + }) + + it("should extract a class method with class context", () => { + const result = readWithIndentation(TYPESCRIPT_CODE, { + anchorLine: 19, // Inside process() method + maxLevels: 1, + includeHeader: false, + includeSiblings: false, + }) + + expect(result.content).toContain("process(input: string)") + }) + }) + + describe("includedRanges", () => { + it("should return correct contiguous range", () => { + const result = readWithIndentation(SIMPLE_CODE, { + anchorLine: 3, + maxLevels: 0, + includeHeader: false, + includeSiblings: false, + limit: 10, + }) + + expect(result.includedRanges.length).toBeGreaterThan(0) + // Each range should be [start, end] with start <= end + for (const [start, end] of result.includedRanges) { + expect(start).toBeLessThanOrEqual(end) + expect(start).toBeGreaterThan(0) + } + }) + }) +}) diff --git a/src/integrations/misc/__tests__/read-file-tool.spec.ts b/src/integrations/misc/__tests__/read-file-tool.spec.ts deleted file mode 100644 index fabc5bc829..0000000000 --- a/src/integrations/misc/__tests__/read-file-tool.spec.ts +++ /dev/null @@ -1,147 +0,0 @@ -// npx vitest run integrations/misc/__tests__/read-file-tool.spec.ts - -import type { Mock } from "vitest" -import * as path from "path" -import { countFileLines } from "../line-counter" -import { readLines } from "../read-lines" -import { extractTextFromFile, addLineNumbers } from "../extract-text" - -// Mock the required functions -vitest.mock("../line-counter") -vitest.mock("../read-lines") -vitest.mock("../extract-text") - -describe("read_file tool with maxReadFileLine setting", () => { - // Mock original implementation first to use in tests - let originalCountFileLines: any - let originalReadLines: any - let originalExtractTextFromFile: any - let originalAddLineNumbers: any - - beforeEach(async () => { - // Import actual implementations - originalCountFileLines = ((await vitest.importActual("../line-counter")) as any).countFileLines - originalReadLines = ((await vitest.importActual("../read-lines")) as any).readLines - originalExtractTextFromFile = ((await vitest.importActual("../extract-text")) as any).extractTextFromFile - originalAddLineNumbers = ((await vitest.importActual("../extract-text")) as any).addLineNumbers - - vitest.resetAllMocks() - // Reset mocks to simulate original behavior - ;(countFileLines as Mock).mockImplementation(originalCountFileLines) - ;(readLines as Mock).mockImplementation(originalReadLines) - ;(extractTextFromFile as Mock).mockImplementation(originalExtractTextFromFile) - ;(addLineNumbers as Mock).mockImplementation(originalAddLineNumbers) - }) - - // Test for the case when file size is smaller than maxReadFileLine - it("should read entire file when line count is less than maxReadFileLine", async () => { - // Mock necessary functions - ;(countFileLines as Mock).mockResolvedValue(100) - ;(extractTextFromFile as Mock).mockResolvedValue("Small file content") - - // Create mock implementation that would simulate the behavior - // Note: We're not testing the Cline class directly as it would be too complex - // We're testing the logic flow that would happen in the read_file implementation - - const filePath = path.resolve("/test", "smallFile.txt") - const maxReadFileLine = 500 - - // Check line count - const lineCount = await countFileLines(filePath) - expect(lineCount).toBeLessThan(maxReadFileLine) - - // Should use extractTextFromFile for small files - if (lineCount < maxReadFileLine) { - await extractTextFromFile(filePath) - } - - expect(extractTextFromFile).toHaveBeenCalledWith(filePath) - expect(readLines).not.toHaveBeenCalled() - }) - - // Test for the case when file size is larger than maxReadFileLine - it("should truncate file when line count exceeds maxReadFileLine", async () => { - // Mock necessary functions - ;(countFileLines as Mock).mockResolvedValue(5000) - ;(readLines as Mock).mockResolvedValue("First 500 lines of large file") - ;(addLineNumbers as Mock).mockReturnValue("1 | First line\n2 | Second line\n...") - - const filePath = path.resolve("/test", "largeFile.txt") - const maxReadFileLine = 500 - - // Check line count - const lineCount = await countFileLines(filePath) - expect(lineCount).toBeGreaterThan(maxReadFileLine) - - // Should use readLines for large files - if (lineCount > maxReadFileLine) { - const content = await readLines(filePath, maxReadFileLine - 1, 0) - const numberedContent = addLineNumbers(content) - - // Verify the truncation message is shown (simulated) - const truncationMsg = `\n\n[File truncated: showing ${maxReadFileLine} of ${lineCount} total lines]` - const fullResult = numberedContent + truncationMsg - - expect(fullResult).toContain("File truncated") - } - - expect(readLines).toHaveBeenCalledWith(filePath, maxReadFileLine - 1, 0) - expect(addLineNumbers).toHaveBeenCalled() - expect(extractTextFromFile).not.toHaveBeenCalled() - }) - - // Test for the case when the file is a source code file - it("should add source code file type info for large source code files", async () => { - // Mock necessary functions - ;(countFileLines as Mock).mockResolvedValue(5000) - ;(readLines as Mock).mockResolvedValue("First 500 lines of large JavaScript file") - ;(addLineNumbers as Mock).mockReturnValue('1 | const foo = "bar";\n2 | function test() {...') - - const filePath = path.resolve("/test", "largeFile.js") - const maxReadFileLine = 500 - - // Check line count - const lineCount = await countFileLines(filePath) - expect(lineCount).toBeGreaterThan(maxReadFileLine) - - // Check if the file is a source code file - const fileExt = path.extname(filePath).toLowerCase() - const isSourceCode = [ - ".js", - ".ts", - ".jsx", - ".tsx", - ".py", - ".java", - ".c", - ".cpp", - ".cs", - ".go", - ".rb", - ".php", - ".swift", - ".rs", - ].includes(fileExt) - expect(isSourceCode).toBeTruthy() - - // Should use readLines for large files - if (lineCount > maxReadFileLine) { - const content = await readLines(filePath, maxReadFileLine - 1, 0) - const numberedContent = addLineNumbers(content) - - // Verify the truncation message and source code message are shown (simulated) - let truncationMsg = `\n\n[File truncated: showing ${maxReadFileLine} of ${lineCount} total lines]` - if (isSourceCode) { - truncationMsg += - "\n\nThis appears to be a source code file. Consider using list_code_definition_names to understand its structure." - } - const fullResult = numberedContent + truncationMsg - - expect(fullResult).toContain("source code file") - expect(fullResult).toContain("list_code_definition_names") - } - - expect(readLines).toHaveBeenCalledWith(filePath, maxReadFileLine - 1, 0) - expect(addLineNumbers).toHaveBeenCalled() - }) -}) diff --git a/src/integrations/misc/__tests__/read-file-with-budget.spec.ts b/src/integrations/misc/__tests__/read-file-with-budget.spec.ts deleted file mode 100644 index 7a4e99ce69..0000000000 --- a/src/integrations/misc/__tests__/read-file-with-budget.spec.ts +++ /dev/null @@ -1,321 +0,0 @@ -import fs from "fs/promises" -import path from "path" -import os from "os" -import { readFileWithTokenBudget } from "../read-file-with-budget" - -describe("readFileWithTokenBudget", () => { - let tempDir: string - - beforeEach(async () => { - // Create a temporary directory for test files - tempDir = path.join(os.tmpdir(), `read-file-budget-test-${Date.now()}`) - await fs.mkdir(tempDir, { recursive: true }) - }) - - afterEach(async () => { - // Clean up temporary directory - await fs.rm(tempDir, { recursive: true, force: true }) - }) - - describe("Basic functionality", () => { - test("reads entire small file when within budget", async () => { - const filePath = path.join(tempDir, "small.txt") - const content = "Line 1\nLine 2\nLine 3" - await fs.writeFile(filePath, content) - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 1000, // Large budget - }) - - expect(result.content).toBe(content) - expect(result.lineCount).toBe(3) - expect(result.complete).toBe(true) - expect(result.tokenCount).toBeGreaterThan(0) - expect(result.tokenCount).toBeLessThan(1000) - }) - - test("returns correct token count", async () => { - const filePath = path.join(tempDir, "token-test.txt") - const content = "This is a test file with some content." - await fs.writeFile(filePath, content) - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 1000, - }) - - // Token count should be reasonable (rough estimate: 1 token per 3-4 chars) - expect(result.tokenCount).toBeGreaterThan(5) - expect(result.tokenCount).toBeLessThan(20) - }) - - test("returns complete: true for files within budget", async () => { - const filePath = path.join(tempDir, "within-budget.txt") - const lines = Array.from({ length: 10 }, (_, i) => `Line ${i + 1}`) - await fs.writeFile(filePath, lines.join("\n")) - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 1000, - }) - - expect(result.complete).toBe(true) - expect(result.lineCount).toBe(10) - }) - }) - - describe("Truncation behavior", () => { - test("stops reading when token budget reached", async () => { - const filePath = path.join(tempDir, "large.txt") - // Create a file with many lines - const lines = Array.from({ length: 1000 }, (_, i) => `This is line number ${i + 1} with some content`) - await fs.writeFile(filePath, lines.join("\n")) - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 50, // Small budget - }) - - expect(result.complete).toBe(false) - expect(result.lineCount).toBeLessThan(1000) - expect(result.lineCount).toBeGreaterThan(0) - expect(result.tokenCount).toBeLessThanOrEqual(50) - }) - - test("returns complete: false when truncated", async () => { - const filePath = path.join(tempDir, "truncated.txt") - const lines = Array.from({ length: 500 }, (_, i) => `Line ${i + 1}`) - await fs.writeFile(filePath, lines.join("\n")) - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 20, - }) - - expect(result.complete).toBe(false) - expect(result.tokenCount).toBeLessThanOrEqual(20) - }) - - test("content ends at line boundary (no partial lines)", async () => { - const filePath = path.join(tempDir, "line-boundary.txt") - const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`) - await fs.writeFile(filePath, lines.join("\n")) - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 30, - }) - - // Content should not end mid-line - const contentLines = result.content.split("\n") - expect(contentLines.length).toBe(result.lineCount) - // Last line should be complete (not cut off) - expect(contentLines[contentLines.length - 1]).toMatch(/^Line \d+$/) - }) - - test("works with different chunk sizes", async () => { - const filePath = path.join(tempDir, "chunks.txt") - const lines = Array.from({ length: 1000 }, (_, i) => `Line ${i + 1}`) - await fs.writeFile(filePath, lines.join("\n")) - - // Test with small chunk size - const result1 = await readFileWithTokenBudget(filePath, { - budgetTokens: 50, - chunkLines: 10, - }) - - // Test with large chunk size - const result2 = await readFileWithTokenBudget(filePath, { - budgetTokens: 50, - chunkLines: 500, - }) - - // Both should truncate, but may differ slightly in exact line count - expect(result1.complete).toBe(false) - expect(result2.complete).toBe(false) - expect(result1.tokenCount).toBeLessThanOrEqual(50) - expect(result2.tokenCount).toBeLessThanOrEqual(50) - }) - }) - - describe("Edge cases", () => { - test("handles empty file", async () => { - const filePath = path.join(tempDir, "empty.txt") - await fs.writeFile(filePath, "") - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 100, - }) - - expect(result.content).toBe("") - expect(result.lineCount).toBe(0) - expect(result.tokenCount).toBe(0) - expect(result.complete).toBe(true) - }) - - test("handles single line file", async () => { - const filePath = path.join(tempDir, "single-line.txt") - await fs.writeFile(filePath, "Single line content") - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 100, - }) - - expect(result.content).toBe("Single line content") - expect(result.lineCount).toBe(1) - expect(result.complete).toBe(true) - }) - - test("handles budget of 0 tokens", async () => { - const filePath = path.join(tempDir, "zero-budget.txt") - await fs.writeFile(filePath, "Line 1\nLine 2\nLine 3") - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 0, - }) - - expect(result.content).toBe("") - expect(result.lineCount).toBe(0) - expect(result.tokenCount).toBe(0) - expect(result.complete).toBe(false) - }) - - test("handles very small budget (fewer tokens than first line)", async () => { - const filePath = path.join(tempDir, "tiny-budget.txt") - const longLine = "This is a very long line with lots of content that will exceed a tiny token budget" - await fs.writeFile(filePath, `${longLine}\nLine 2\nLine 3`) - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 2, // Very small budget - }) - - // Should return empty since first line exceeds budget - expect(result.content).toBe("") - expect(result.lineCount).toBe(0) - expect(result.complete).toBe(false) - }) - - test("throws error for non-existent file", async () => { - const filePath = path.join(tempDir, "does-not-exist.txt") - - await expect( - readFileWithTokenBudget(filePath, { - budgetTokens: 100, - }), - ).rejects.toThrow("File not found") - }) - - test("handles file with no trailing newline", async () => { - const filePath = path.join(tempDir, "no-trailing-newline.txt") - await fs.writeFile(filePath, "Line 1\nLine 2\nLine 3") - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 1000, - }) - - expect(result.content).toBe("Line 1\nLine 2\nLine 3") - expect(result.lineCount).toBe(3) - expect(result.complete).toBe(true) - }) - - test("handles file with trailing newline", async () => { - const filePath = path.join(tempDir, "trailing-newline.txt") - await fs.writeFile(filePath, "Line 1\nLine 2\nLine 3\n") - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 1000, - }) - - expect(result.content).toBe("Line 1\nLine 2\nLine 3") - expect(result.lineCount).toBe(3) - expect(result.complete).toBe(true) - }) - }) - - describe("Token counting accuracy", () => { - test("returned tokenCount matches actual tokens in content", async () => { - const filePath = path.join(tempDir, "accuracy.txt") - const content = "Hello world\nThis is a test\nWith some content" - await fs.writeFile(filePath, content) - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 1000, - }) - - // Verify the token count is reasonable - // Rough estimate: 1 token per 3-4 characters - const minExpected = Math.floor(content.length / 5) - const maxExpected = Math.ceil(content.length / 2) - - expect(result.tokenCount).toBeGreaterThanOrEqual(minExpected) - expect(result.tokenCount).toBeLessThanOrEqual(maxExpected) - }) - - test("handles special characters correctly", async () => { - const filePath = path.join(tempDir, "special-chars.txt") - const content = "Special chars: @#$%^&*()\nUnicode: 你好世界\nEmoji: 😀🎉" - await fs.writeFile(filePath, content) - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 1000, - }) - - expect(result.content).toBe(content) - expect(result.tokenCount).toBeGreaterThan(0) - expect(result.complete).toBe(true) - }) - - test("handles code content", async () => { - const filePath = path.join(tempDir, "code.ts") - const code = `function hello(name: string): string {\n return \`Hello, \${name}!\`\n}` - await fs.writeFile(filePath, code) - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 1000, - }) - - expect(result.content).toBe(code) - expect(result.tokenCount).toBeGreaterThan(0) - expect(result.complete).toBe(true) - }) - }) - - describe("Performance", () => { - test("handles large files efficiently", async () => { - const filePath = path.join(tempDir, "large-file.txt") - // Create a 1MB file - const lines = Array.from({ length: 10000 }, (_, i) => `Line ${i + 1} with some additional content`) - await fs.writeFile(filePath, lines.join("\n")) - - const startTime = Date.now() - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 100, - }) - - const endTime = Date.now() - const duration = endTime - startTime - - // Should complete in reasonable time (less than 5 seconds) - expect(duration).toBeLessThan(5000) - expect(result.complete).toBe(false) - expect(result.tokenCount).toBeLessThanOrEqual(100) - }) - - test("early exits when budget is reached", async () => { - const filePath = path.join(tempDir, "early-exit.txt") - // Create a very large file - const lines = Array.from({ length: 50000 }, (_, i) => `Line ${i + 1}`) - await fs.writeFile(filePath, lines.join("\n")) - - const startTime = Date.now() - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 50, // Small budget should trigger early exit - }) - - const endTime = Date.now() - const duration = endTime - startTime - - // Should be much faster than reading entire file (less than 2 seconds) - expect(duration).toBeLessThan(2000) - expect(result.complete).toBe(false) - expect(result.lineCount).toBeLessThan(50000) - }) - }) -}) diff --git a/src/integrations/misc/extract-text.ts b/src/integrations/misc/extract-text.ts index 677d24de0d..76138490dd 100644 --- a/src/integrations/misc/extract-text.ts +++ b/src/integrations/misc/extract-text.ts @@ -3,10 +3,10 @@ import * as path from "path" import pdf from "pdf-parse/lib/pdf-parse" import mammoth from "mammoth" import fs from "fs/promises" -import { extractTextFromXLSX } from "./extract-text-from-xlsx" -import { countFileLines } from "./line-counter" -import { readLines } from "./read-lines" import { readFileWithEncodingDetection, isBinaryFileWithEncodingDetection } from "../../utils/encoding" +import { extractTextFromXLSX } from "./extract-text-from-xlsx" +import { readWithSlice } from "./indentation-reader" +import { DEFAULT_LINE_LIMIT } from "../../core/prompts/tools/native-tools/read_file" async function extractTextFromPDF(filePath: string): Promise { const dataBuffer = await fs.readFile(filePath) @@ -20,7 +20,7 @@ async function extractTextFromDOCX(filePath: string): Promise { } async function extractTextFromIPYNB(filePath: string): Promise { - const data = await readFileWithEncodingDetection(filePath) + const data = await fs.readFile(filePath, "utf8") const notebook = JSON.parse(data) let extractedText = "" @@ -51,36 +51,34 @@ export function getSupportedBinaryFormats(): string[] { } /** - * Extracts text content from a file, with support for various formats including PDF, DOCX, XLSX, and plain text. - * For large text files, can limit the number of lines read to prevent context exhaustion. + * Result of extracting text with metadata about truncation + */ +export interface ExtractTextResult { + /** The extracted content with line numbers */ + content: string + /** Total lines in the file */ + totalLines: number + /** Lines actually returned */ + returnedLines: number + /** Whether output was truncated */ + wasTruncated: boolean + /** Line range shown [start, end] (1-based) */ + linesShown?: [number, number] +} + +/** + * Extracts text content from a file with truncation support. + * Returns structured result with metadata about truncation. * * @param filePath - Path to the file to extract text from - * @param maxReadFileLine - Maximum number of lines to read from text files. - * Use UNLIMITED_LINES (-1) or undefined for no limit. - * Must be a positive integer or UNLIMITED_LINES. - * @returns Promise resolving to the extracted text content with line numbers - * @throws {Error} If file not found, unsupported format, or invalid parameters + * @param limit - Maximum lines to return (default: 2000) + * @returns Promise resolving to extracted text with metadata + * @throws {Error} If file not found or unsupported binary format */ -export async function extractTextFromFile( +export async function extractTextFromFileWithMetadata( filePath: string, - maxReadFileLine?: number, - maxReadCharacterLimit?: number, -): Promise { - if (maxReadCharacterLimit != undefined && maxReadCharacterLimit <= 0) { - throw new Error( - `Invalid maxReadCharacterLimit: ${maxReadCharacterLimit}. Must be a positive integer or undefined for unlimited.`, - ) - } - - // Validate maxReadFileLine parameter - if (maxReadFileLine !== undefined && maxReadFileLine !== -1) { - if (!Number.isInteger(maxReadFileLine) || maxReadFileLine < 1) { - throw new Error( - `Invalid maxReadFileLine: ${maxReadFileLine}. Must be a positive integer or -1 for unlimited.`, - ) - } - } - + limit: number = DEFAULT_LINE_LIMIT, +): Promise { try { await fs.access(filePath) } catch (error) { @@ -92,49 +90,49 @@ export async function extractTextFromFile( // Check if we have a specific extractor for this format const extractor = SUPPORTED_BINARY_FORMATS[fileExtension as keyof typeof SUPPORTED_BINARY_FORMATS] if (extractor) { - return extractor(filePath) + // For binary formats, extract and count lines + const content = await extractor(filePath) + const lines = content.split("\n") + return { + content, + totalLines: lines.length, + returnedLines: lines.length, + wasTruncated: false, + } } // Handle other files - const isBinary = await isBinaryFileWithEncodingDetection(filePath) + const isBinary = await isBinaryFileWithEncodingDetection(filePath).catch(() => false) if (!isBinary) { - // Check if we need to apply line limit - if (maxReadFileLine !== undefined && maxReadFileLine !== -1) { - const totalLines = await countFileLines(filePath) - if (totalLines > maxReadFileLine) { - // Read only up to maxReadFileLine (endLine is 0-based and inclusive) - const content = await readLines(filePath, maxReadFileLine - 1, 0) - const numberedContent = addLineNumbers(content) - if (maxReadCharacterLimit && numberedContent.length > maxReadCharacterLimit) { - const truncatedContent = truncateOutput(numberedContent, undefined, maxReadCharacterLimit) - return ( - truncatedContent + - `\n\n[File truncated: showing ${maxReadFileLine} of ${totalLines} total lines, and content further truncated due to character limit (${maxReadCharacterLimit}). The file is too large and may exhaust the context window if read in full.]` - ) - } - return ( - numberedContent + - `\n\n[File truncated: showing ${maxReadFileLine} of ${totalLines} total lines. The file is too large and may exhaust the context window if read in full.]` - ) - } + const rawContent = await readFileWithEncodingDetection(filePath) + const result = readWithSlice(rawContent, 0, limit) + + return { + content: result.content, + totalLines: result.totalLines, + returnedLines: result.returnedLines, + wasTruncated: result.wasTruncated, + linesShown: result.includedRanges.length > 0 ? result.includedRanges[0] : undefined, } - // Read the entire file if no limit or file is within limit - const fullContent = addLineNumbers(await readFileWithEncodingDetection(filePath)) - // Apply character limit truncation if specified, even when line limit is not exceeded - if (maxReadCharacterLimit && fullContent.length > maxReadCharacterLimit) { - const truncatedContent = truncateOutput(fullContent, maxReadFileLine, maxReadCharacterLimit) - return ( - truncatedContent + - `\n\n[File content omitted due to character limit (${maxReadCharacterLimit}). The file is too large and may exhaust the context window if read in full.]` - ) - } - return fullContent } else { throw new Error(`Cannot read text for file type: ${fileExtension}`) } } +/** + * Extracts text content from a file, with support for various formats including PDF, DOCX, XLSX, and plain text. + * Now uses truncation to limit large files to DEFAULT_LINE_LIMIT lines. + * + * @param filePath - Path to the file to extract text from + * @returns Promise resolving to the extracted text content with line numbers + * @throws {Error} If file not found or unsupported binary format + */ +export async function extractTextFromFile(filePath: string): Promise { + const result = await extractTextFromFileWithMetadata(filePath) + return result.content +} + export function addLineNumbers(content: string, startLine: number = 1): string { // If content is empty, return empty string - empty files should not have line numbers // If content is empty but startLine > 1, return "startLine | " because we know the file is not empty @@ -164,7 +162,7 @@ export function addLineNumbers(content: string, startLine: number = 1): string { // Line numbers must be followed by a single pipe character (not double pipes) export function everyLineHasLineNumbers(content: string): boolean { const lines = content.split(/\r?\n/) // Handles both CRLF (carriage return (\r) + line feed (\n)) and LF (line feed (\n)) line endings - return lines.length > 0 && lines?.every?.((line) => /^\s*\d+\s+\|(?!\|)/.test(line)) + return lines?.length > 0 && lines?.every?.((line) => /^\s*\d+\s+\|(?!\|)/.test(line)) } /** diff --git a/src/integrations/misc/indentation-reader.ts b/src/integrations/misc/indentation-reader.ts new file mode 100644 index 0000000000..aecabd5982 --- /dev/null +++ b/src/integrations/misc/indentation-reader.ts @@ -0,0 +1,469 @@ +/** + * Indentation-based semantic code block extraction. + * + * Inspired by Codex's indentation mode, this module extracts meaningful code blocks + * based on indentation hierarchy rather than arbitrary line ranges. + * + * The algorithm uses bidirectional expansion from an anchor line: + * 1. Parse the file to determine indentation level of each line + * 2. Compute effective indents (blank lines inherit previous non-blank line's indent) + * 3. Expand up and down from anchor simultaneously + * 4. Apply sibling exclusion counters to limit scope + * 5. Trim empty lines from edges + * 6. Apply line limit + */ + +import { + DEFAULT_LINE_LIMIT, + DEFAULT_MAX_LEVELS, + MAX_LINE_LENGTH, +} from "../../core/prompts/tools/native-tools/read_file" + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface LineRecord { + /** 1-based line number */ + lineNumber: number + /** Original line content */ + content: string + /** Computed indentation level (number of leading whitespace units) */ + indentLevel: number + /** Whether this line is blank (empty or whitespace only) */ + isBlank: boolean + /** Whether this line starts a new block (has content followed by colon, brace, etc.) */ + isBlockStart: boolean +} + +export interface IndentationReadOptions { + /** 1-based anchor line number */ + anchorLine: number + /** Maximum indentation levels to include above anchor (0 = unlimited, default: 0) */ + maxLevels?: number + /** Include sibling blocks at the same indentation level (default: false) */ + includeSiblings?: boolean + /** Include file header content (imports, comments at top) (default: true) */ + includeHeader?: boolean + /** Maximum lines to return from bidirectional expansion (default: 2000) */ + limit?: number + /** Hard cap on lines returned, separate from limit (optional) */ + maxLines?: number +} + +export interface IndentationReadResult { + /** The extracted content with line numbers */ + content: string + /** Line ranges that were included [start, end] tuples (1-based) */ + includedRanges: Array<[number, number]> + /** Total lines in the file */ + totalLines: number + /** Lines actually returned */ + returnedLines: number + /** Whether output was truncated due to limit */ + wasTruncated: boolean +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/** Indentation unit size (spaces) */ +const INDENT_SIZE = 4 + +/** Tab width for indent measurement (Codex standard) */ +const TAB_WIDTH = 4 + +/** Patterns that indicate a block start */ +const BLOCK_START_PATTERNS = [ + /:\s*$/, // Python-style (def foo():) + /\{\s*$/, // C-style opening brace + /=>\s*\{?\s*$/, // Arrow functions + /\bthen\s*$/, // Lua/some languages + /\bdo\s*$/, // Ruby, Lua +] + +/** Patterns for file header lines (imports, comments, etc.) */ +const HEADER_PATTERNS = [ + /^import\s/, // ES6 imports + /^from\s.*import/, // Python imports + /^const\s.*=\s*require/, // CommonJS requires + /^#!/, // Shebang + /^\/\*/, // Block comment start + /^\*/, // Block comment continuation + /^\s*\*\//, // Block comment end + /^\/\//, // Line comment + /^#(?!include)/, // Python/shell comment (not C #include) + /^"""/, // Python docstring + /^'''/, // Python docstring + /^use\s/, // Rust use + /^package\s/, // Go/Java package + /^require\s/, // Lua require + /^@/, // Decorators (Python, TypeScript) + /^"use\s/, // "use strict", "use client" +] + +/** Comment prefixes for header detection (Codex standard) */ +const COMMENT_PREFIXES = ["#", "//", "--", "/*", "*", "'''", '"""'] + +// ─── Core Functions ─────────────────────────────────────────────────────────── + +/** + * Parse a file's lines into LineRecord objects with indentation information. + */ +export function parseLines(content: string): LineRecord[] { + const lines = content.split("\n") + return lines.map((line, index) => { + const trimmed = line.trimStart() + const leadingWhitespace = line.length - trimmed.length + + // Calculate indent in spaces (tabs = TAB_WIDTH spaces each) + let indentSpaces = 0 + for (let i = 0; i < leadingWhitespace; i++) { + if (line[i] === "\t") { + indentSpaces += TAB_WIDTH + } else { + indentSpaces += 1 + } + } + // Convert to indent level (number of INDENT_SIZE units) + const indentLevel = Math.floor(indentSpaces / INDENT_SIZE) + + const isBlank = trimmed.length === 0 + const isBlockStart = !isBlank && BLOCK_START_PATTERNS.some((pattern) => pattern.test(line)) + + return { + lineNumber: index + 1, + content: line, + indentLevel, + isBlank, + isBlockStart, + } + }) +} + +/** + * Compute effective indents where blank lines inherit the previous non-blank line's indent. + * This matches the Codex algorithm behavior. + */ +export function computeEffectiveIndents(lines: LineRecord[]): number[] { + const effective: number[] = [] + let previousIndent = 0 + + for (const line of lines) { + if (line.isBlank) { + effective.push(previousIndent) + } else { + previousIndent = line.indentLevel + effective.push(previousIndent) + } + } + return effective +} + +/** + * Check if a line is a comment (for include_header behavior). + */ +function isComment(line: LineRecord): boolean { + const trimmed = line.content.trim() + return COMMENT_PREFIXES.some((prefix) => trimmed.startsWith(prefix)) +} + +/** + * Trim empty lines from the front and back of a line array. + */ +function trimEmptyLines(lines: LineRecord[]): void { + // Trim from front + while (lines.length > 0 && lines[0].isBlank) { + lines.shift() + } + // Trim from back + while (lines.length > 0 && lines[lines.length - 1].isBlank) { + lines.pop() + } +} + +/** + * Find the file header (imports, top-level comments, etc.). + * Returns the end index of the header section. + */ +function findHeaderEnd(lines: LineRecord[]): number { + let lastHeaderIdx = -1 + let inBlockComment = false + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const trimmed = line.content.trim() + + // Track block comments + if (trimmed.startsWith("/*")) inBlockComment = true + if (trimmed.endsWith("*/")) { + inBlockComment = false + lastHeaderIdx = i + continue + } + if (inBlockComment) { + lastHeaderIdx = i + continue + } + + // Check if this is a header line + if (line.isBlank) { + // Blank lines are part of header if we haven't seen content yet + if (lastHeaderIdx === i - 1) { + lastHeaderIdx = i + } + continue + } + + const isHeader = HEADER_PATTERNS.some((pattern) => pattern.test(trimmed)) + if (isHeader) { + lastHeaderIdx = i + } else if (line.indentLevel === 0) { + // Hit first non-header top-level content + break + } + } + + return lastHeaderIdx +} + +/** + * Format lines with line numbers, applying truncation to long lines. + */ +export function formatWithLineNumbers(lines: LineRecord[], maxLineLength: number = MAX_LINE_LENGTH): string { + if (lines.length === 0) return "" + const maxLineNumWidth = String(lines[lines.length - 1]?.lineNumber || 1).length + + return lines + .map((line) => { + const lineNum = String(line.lineNumber).padStart(maxLineNumWidth, " ") + let content = line.content + + // Truncate long lines + if (content.length > maxLineLength) { + content = content.substring(0, maxLineLength - 3) + "..." + } + + return `${lineNum} | ${content}` + }) + .join("\n") +} + +/** + * Convert a contiguous array of LineRecords into merged ranges for output. + */ +function computeIncludedRanges(lines: LineRecord[]): Array<[number, number]> { + if (lines.length === 0) return [] + + const ranges: Array<[number, number]> = [] + let rangeStart = lines[0].lineNumber + let rangeEnd = lines[0].lineNumber + + for (let i = 1; i < lines.length; i++) { + const lineNum = lines[i].lineNumber + if (lineNum === rangeEnd + 1) { + // Contiguous + rangeEnd = lineNum + } else { + // Gap - save current range and start new one + ranges.push([rangeStart, rangeEnd]) + rangeStart = lineNum + rangeEnd = lineNum + } + } + // Don't forget the last range + ranges.push([rangeStart, rangeEnd]) + + return ranges +} + +// ─── Main Export ────────────────────────────────────────────────────────────── + +/** + * Read a file using indentation-based semantic extraction (Codex algorithm). + * + * Uses bidirectional expansion from the anchor line with sibling exclusion counters. + * + * @param content - The file content to process + * @param options - Extraction options + * @returns The extracted content with metadata + */ +export function readWithIndentation(content: string, options: IndentationReadOptions): IndentationReadResult { + const { + anchorLine, + maxLevels = DEFAULT_MAX_LEVELS, + includeSiblings = false, + includeHeader = true, + limit = DEFAULT_LINE_LIMIT, + maxLines, + } = options + + const lines = parseLines(content) + const totalLines = lines.length + + // Validate anchor line + if (anchorLine < 1 || anchorLine > totalLines) { + return { + content: `Error: anchor_line ${anchorLine} is out of range (1-${totalLines})`, + includedRanges: [], + totalLines, + returnedLines: 0, + wasTruncated: false, + } + } + + const anchorIdx = anchorLine - 1 // Convert to 0-based + const effectiveIndents = computeEffectiveIndents(lines) + const anchorIndent = effectiveIndents[anchorIdx] + + // Calculate minimum indent threshold + // maxLevels = 0 means unlimited (minIndent = 0) + // maxLevels > 0 means limit to that many levels above anchor + let minIndent: number + if (maxLevels === 0) { + minIndent = 0 + } else { + // Each "level" is INDENT_SIZE spaces worth of indentation + // We subtract maxLevels from the anchor's indent level + minIndent = Math.max(0, anchorIndent - maxLevels) + } + + // Calculate final limit (use maxLines as hard cap if provided) + const guardLimit = maxLines ?? limit + const finalLimit = Math.min(limit, guardLimit, totalLines) + + // Edge case: if limit is 1, just return the anchor line + if (finalLimit === 1) { + const singleLine = [lines[anchorIdx]] + return { + content: formatWithLineNumbers(singleLine), + includedRanges: [[anchorLine, anchorLine]], + totalLines, + returnedLines: 1, + wasTruncated: totalLines > 1, + } + } + + // Bidirectional expansion from anchor (Codex algorithm) + const result: LineRecord[] = [lines[anchorIdx]] + let i = anchorIdx - 1 // Up cursor + let j = anchorIdx + 1 // Down cursor + let iMinCount = 0 // Count of min-indent lines seen going up + let jMinCount = 0 // Count of min-indent lines seen going down + + while (result.length < finalLimit) { + let progressed = false + + // Expand upward + if (i >= 0 && effectiveIndents[i] >= minIndent) { + result.unshift(lines[i]) + progressed = true + + // Handle sibling exclusion at min indent + if (effectiveIndents[i] === minIndent && !includeSiblings) { + const allowHeader = includeHeader && isComment(lines[i]) + const canTake = allowHeader || iMinCount === 0 + + if (canTake) { + iMinCount++ + } else { + // Reject this line - remove it and stop expanding up + result.shift() + progressed = false + i = -1 // Stop expanding up + } + } + + if (i >= 0) i-- + } else if (i >= 0) { + i = -1 // Stop expanding up (hit lower indent) + } + + if (result.length >= finalLimit) break + + // Expand downward + if (j < lines.length && effectiveIndents[j] >= minIndent) { + result.push(lines[j]) + progressed = true + + // Handle sibling exclusion at min indent + if (effectiveIndents[j] === minIndent && !includeSiblings) { + if (jMinCount > 0) { + // Already saw one min-indent block going down, reject this + result.pop() + progressed = false + j = lines.length // Stop expanding down + } + jMinCount++ + } + + if (j < lines.length) j++ + } else if (j < lines.length) { + j = lines.length // Stop expanding down (hit lower indent) + } + + if (!progressed) break + } + + // Trim leading/trailing empty lines + trimEmptyLines(result) + + // Check if we were truncated + const wasTruncated = result.length >= finalLimit || i >= 0 || j < lines.length + + // Format output + const formattedContent = formatWithLineNumbers(result) + + // Compute included ranges + const includedRanges = computeIncludedRanges(result) + + return { + content: formattedContent, + includedRanges, + totalLines, + returnedLines: result.length, + wasTruncated: wasTruncated && result.length < totalLines, + } +} + +/** + * Simple slice mode reading - read lines with offset/limit. + * + * @param content - The file content to process + * @param offset - 0-based line offset to start from (default: 0) + * @param limit - Maximum lines to return (default: 2000) + * @returns The extracted content with metadata + */ +export function readWithSlice( + content: string, + offset: number = 0, + limit: number = DEFAULT_LINE_LIMIT, +): IndentationReadResult { + const lines = parseLines(content) + const totalLines = lines.length + + // Validate offset + if (offset < 0) offset = 0 + if (offset >= totalLines) { + return { + content: `Error: offset ${offset} is beyond file end (${totalLines} lines)`, + includedRanges: [], + totalLines, + returnedLines: 0, + wasTruncated: false, + } + } + + // Slice lines + const endIdx = Math.min(offset + limit, totalLines) + const selectedLines = lines.slice(offset, endIdx) + const wasTruncated = endIdx < totalLines + + // Format output + const formattedContent = formatWithLineNumbers(selectedLines) + + return { + content: formattedContent, + includedRanges: [[offset + 1, endIdx]], // 1-based + totalLines, + returnedLines: selectedLines.length, + wasTruncated, + } +} diff --git a/src/integrations/misc/read-file-with-budget.ts b/src/integrations/misc/read-file-with-budget.ts deleted file mode 100644 index 15aa4f1144..0000000000 --- a/src/integrations/misc/read-file-with-budget.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { createReadStream } from "fs" -import fs from "fs/promises" -import { createInterface } from "readline" -import { countTokens } from "../../utils/countTokens" -import { Anthropic } from "@anthropic-ai/sdk" - -export interface ReadWithBudgetResult { - /** The content read up to the token budget */ - content: string - /** Actual token count of returned content */ - tokenCount: number - /** Total lines in the returned content */ - lineCount: number - /** Whether the entire file was read (false if truncated) */ - complete: boolean -} - -export interface ReadWithBudgetOptions { - /** Maximum tokens allowed. Required. */ - budgetTokens: number - /** Number of lines to buffer before token counting (default: 256) */ - chunkLines?: number -} - -/** - * Reads a file while incrementally counting tokens, stopping when budget is reached. - * - * Unlike validateFileTokenBudget + extractTextFromFile, this is a single-pass - * operation that returns the actual content up to the token limit. - * - * @param filePath - Path to the file to read - * @param options - Budget and chunking options - * @returns Content read, token count, and completion status - */ -export async function readFileWithTokenBudget( - filePath: string, - options: ReadWithBudgetOptions, -): Promise { - const { budgetTokens, chunkLines = 256 } = options - - // Verify file exists - try { - await fs.access(filePath) - } catch { - throw new Error(`File not found: ${filePath}`) - } - - return new Promise((resolve, reject) => { - let content = "" - let lineCount = 0 - let tokenCount = 0 - let lineBuffer: string[] = [] - let complete = true - let isProcessing = false - let shouldClose = false - - const readStream = createReadStream(filePath) - const rl = createInterface({ - input: readStream, - crlfDelay: Infinity, - }) - - const processBuffer = async (): Promise => { - if (lineBuffer.length === 0) return true - - const bufferText = lineBuffer.join("\n") - const currentBuffer = [...lineBuffer] - lineBuffer = [] - - // Count tokens for this chunk - let chunkTokens: number - try { - const contentBlocks: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: bufferText }] - chunkTokens = await countTokens(contentBlocks) - } catch { - // Fallback: conservative estimate (2 chars per token) - chunkTokens = Math.ceil(bufferText.length / 2) - } - - // Check if adding this chunk would exceed budget - if (tokenCount + chunkTokens > budgetTokens) { - // Need to find cutoff within this chunk using binary search - let low = 0 - let high = currentBuffer.length - let bestFit = 0 - let bestTokens = 0 - - while (low < high) { - const mid = Math.floor((low + high + 1) / 2) - const testContent = currentBuffer.slice(0, mid).join("\n") - let testTokens: number - try { - const blocks: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: testContent }] - testTokens = await countTokens(blocks) - } catch { - testTokens = Math.ceil(testContent.length / 2) - } - - if (tokenCount + testTokens <= budgetTokens) { - bestFit = mid - bestTokens = testTokens - low = mid - } else { - high = mid - 1 - } - } - - // Add best fit lines - if (bestFit > 0) { - const fitContent = currentBuffer.slice(0, bestFit).join("\n") - content += (content.length > 0 ? "\n" : "") + fitContent - tokenCount += bestTokens - lineCount += bestFit - } - complete = false - return false - } - - // Entire chunk fits - add it all - content += (content.length > 0 ? "\n" : "") + bufferText - tokenCount += chunkTokens - lineCount += currentBuffer.length - return true - } - - rl.on("line", (line) => { - lineBuffer.push(line) - - if (lineBuffer.length >= chunkLines && !isProcessing) { - isProcessing = true - rl.pause() - - processBuffer() - .then((continueReading) => { - isProcessing = false - if (!continueReading) { - shouldClose = true - rl.close() - readStream.destroy() - } else if (!shouldClose) { - rl.resume() - } - }) - .catch((err) => { - isProcessing = false - shouldClose = true - rl.close() - readStream.destroy() - reject(err) - }) - } - }) - - rl.on("close", async () => { - // Wait for any ongoing processing with timeout - const maxWaitTime = 30000 // 30 seconds - const startWait = Date.now() - while (isProcessing) { - if (Date.now() - startWait > maxWaitTime) { - reject(new Error("Timeout waiting for buffer processing to complete")) - return - } - await new Promise((r) => setTimeout(r, 10)) - } - - // Process remaining buffer - if (!shouldClose) { - try { - await processBuffer() - } catch (err) { - reject(err) - return - } - } - - resolve({ content, tokenCount, lineCount, complete }) - }) - - rl.on("error", reject) - readStream.on("error", reject) - }) -} diff --git a/src/package.json b/src/package.json index af0082e24f..f3b69cee82 100644 --- a/src/package.json +++ b/src/package.json @@ -826,6 +826,8 @@ "pretest": "turbo run bundle --cwd ..", "test": "vitest run", "format": "prettier --write .", + "generate:skills": "tsx services/skills/generate-built-in-skills.ts", + "prebundle": "pnpm generate:skills", "bundle": "node esbuild.mjs", "vscode:prepublish": "pnpm bundle --production", "vsix": "mkdirp ../bin && vsce package --no-dependencies --out ../bin", @@ -835,6 +837,10 @@ "clean": "rimraf README.md CHANGELOG.md LICENSE dist logs mock .turbo" }, "dependencies": { + "@ai-sdk/cerebras": "^1.0.0", + "@ai-sdk/deepseek": "^2.0.14", + "@ai-sdk/fireworks": "^2.0.26", + "@ai-sdk/groq": "^3.0.19", "@anthropic-ai/bedrock-sdk": "^0.10.2", "@anthropic-ai/sdk": "^0.37.0", "@anthropic-ai/vertex-sdk": "^0.7.0", diff --git a/src/services/skills/SkillsManager.ts b/src/services/skills/SkillsManager.ts index 59c1ac4bae..51345945cb 100644 --- a/src/services/skills/SkillsManager.ts +++ b/src/services/skills/SkillsManager.ts @@ -15,6 +15,7 @@ import { SKILL_NAME_MAX_LENGTH, } from "@roo-code/types" import { t } from "../../i18n" +import { getBuiltInSkills, getBuiltInSkillContent } from "./built-in-skills" // Re-export for convenience export type { SkillMetadata, SkillContent } @@ -159,13 +160,19 @@ export class SkillsManager { /** * Get skills available for the current mode. - * Resolves overrides: project > global, mode-specific > generic. + * Resolves overrides: project > global > built-in, mode-specific > generic. * * @param currentMode - The current mode slug (e.g., 'code', 'architect') */ getSkillsForMode(currentMode: string): SkillMetadata[] { const resolvedSkills = new Map() + // First, add built-in skills (lowest priority) + for (const skill of getBuiltInSkills()) { + resolvedSkills.set(skill.name, skill) + } + + // Then, add discovered skills (will override built-in skills with same name) for (const skill of this.skills.values()) { // Skip mode-specific skills that don't match current mode if (skill.mode && skill.mode !== currentMode) continue @@ -189,12 +196,22 @@ export class SkillsManager { /** * Determine if newSkill should override existingSkill based on priority rules. - * Priority: project > global, mode-specific > generic + * Priority: project > global > built-in, mode-specific > generic */ private shouldOverrideSkill(existing: SkillMetadata, newSkill: SkillMetadata): boolean { - // Project always overrides global - if (newSkill.source === "project" && existing.source === "global") return true - if (newSkill.source === "global" && existing.source === "project") return false + // Define source priority: project > global > built-in + const sourcePriority: Record = { + project: 3, + global: 2, + "built-in": 1, + } + + const existingPriority = sourcePriority[existing.source] ?? 0 + const newPriority = sourcePriority[newSkill.source] ?? 0 + + // Higher priority source always wins + if (newPriority > existingPriority) return true + if (newPriority < existingPriority) return false // Same source: mode-specific overrides generic if (newSkill.mode && !existing.mode) return true @@ -219,12 +236,21 @@ export class SkillsManager { const modeSkills = this.getSkillsForMode(currentMode) skill = modeSkills.find((s) => s.name === name) } else { - // Fall back to any skill with this name + // Fall back to any skill with this name (check discovered skills first, then built-in) skill = Array.from(this.skills.values()).find((s) => s.name === name) + if (!skill) { + skill = getBuiltInSkills().find((s) => s.name === name) + } } if (!skill) return null + // For built-in skills, use the built-in content + if (skill.source === "built-in") { + return getBuiltInSkillContent(name) + } + + // For file-based skills, read from disk const fileContent = await fs.readFile(skill.path, "utf-8") const { content: body } = matter(fileContent) @@ -374,6 +400,77 @@ Add your skill instructions here. await this.discoverSkills() } + /** + * Move a skill to a different mode + * @param name - Skill name to move + * @param source - Where the skill is located ("global" or "project") + * @param currentMode - Current mode (undefined for generic skills) + * @param newMode - Target mode (undefined for generic skills) + */ + async moveSkill( + name: string, + source: "global" | "project", + currentMode: string | undefined, + newMode: string | undefined, + ): Promise { + // Don't move if source and destination are the same + if (currentMode === newMode) { + return + } + + // Find the skill at its current location + const skill = this.getSkill(name, source, currentMode) + if (!skill) { + const modeInfo = currentMode ? ` (mode: ${currentMode})` : "" + throw new Error(t("skills:errors.not_found", { name, source, modeInfo })) + } + + // Determine base directory + let baseDir: string + if (source === "global") { + baseDir = getGlobalRooDirectory() + } else { + const provider = this.providerRef.deref() + if (!provider?.cwd) { + throw new Error(t("skills:errors.no_workspace")) + } + baseDir = path.join(provider.cwd, ".roo") + } + + // Determine source and destination directories + const sourceDirName = currentMode ? `skills-${currentMode}` : "skills" + const destDirName = newMode ? `skills-${newMode}` : "skills" + const sourceDir = path.join(baseDir, sourceDirName, name) + const destSkillsDir = path.join(baseDir, destDirName) + const destDir = path.join(destSkillsDir, name) + const destSkillMdPath = path.join(destDir, "SKILL.md") + + // Check if skill already exists at destination + if (await fileExists(destSkillMdPath)) { + throw new Error(t("skills:errors.already_exists", { name, path: destSkillMdPath })) + } + + // Ensure destination skills directory exists + await fs.mkdir(destSkillsDir, { recursive: true }) + + // Move the skill directory + await fs.rename(sourceDir, destDir) + + // Clean up empty source skills directory + const sourceSkillsDir = path.join(baseDir, sourceDirName) + try { + const entries = await fs.readdir(sourceSkillsDir) + if (entries.length === 0) { + await fs.rmdir(sourceSkillsDir) + } + } catch { + // Ignore errors - directory might not exist or have permission issues + } + + // Refresh skills list + await this.discoverSkills() + } + /** * Get all skills directories to scan, including mode-specific directories. */ diff --git a/src/services/skills/__tests__/SkillsManager.spec.ts b/src/services/skills/__tests__/SkillsManager.spec.ts index b12b5c3399..780a295f1c 100644 --- a/src/services/skills/__tests__/SkillsManager.spec.ts +++ b/src/services/skills/__tests__/SkillsManager.spec.ts @@ -12,6 +12,8 @@ const { mockMkdir, mockWriteFile, mockRm, + mockRename, + mockRmdir, } = vi.hoisted(() => ({ mockStat: vi.fn(), mockReadFile: vi.fn(), @@ -23,6 +25,8 @@ const { mockMkdir: vi.fn(), mockWriteFile: vi.fn(), mockRm: vi.fn(), + mockRename: vi.fn(), + mockRmdir: vi.fn(), })) // Platform-agnostic test paths @@ -44,6 +48,8 @@ vi.mock("fs/promises", () => ({ mkdir: mockMkdir, writeFile: mockWriteFile, rm: mockRm, + rename: mockRename, + rmdir: mockRmdir, }, stat: mockStat, readFile: mockReadFile, @@ -52,6 +58,8 @@ vi.mock("fs/promises", () => ({ mkdir: mockMkdir, writeFile: mockWriteFile, rm: mockRm, + rename: mockRename, + rmdir: mockRmdir, })) // Mock os module @@ -100,6 +108,14 @@ vi.mock("../../../i18n", () => ({ }, })) +// Mock built-in skills to isolate tests from actual built-in skills +vi.mock("../built-in-skills", () => ({ + getBuiltInSkills: () => [], + getBuiltInSkillContent: () => null, + isBuiltInSkill: () => false, + getBuiltInSkillNames: () => [], +})) + import { SkillsManager } from "../SkillsManager" import { ClineProvider } from "../../../core/webview/ClineProvider" @@ -1128,4 +1144,402 @@ Instructions`) await expect(skillsManager.deleteSkill("non-existent", "global")).rejects.toThrow("not found") }) }) + + describe("moveSkill", () => { + it("should move a skill from generic to mode-specific directory", async () => { + const sourceDir = p(globalSkillsDir, "test-skill") + const testSkillMd = p(sourceDir, "SKILL.md") + const destDir = p(GLOBAL_ROO_DIR, "skills-code", "test-skill") + const destSkillsDir = p(GLOBAL_ROO_DIR, "skills-code") + + // Setup: skill exists in generic skills directory + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["test-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === sourceDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + // Skill exists in source + if (file === testSkillMd) return true + // Skill does not exist in destination + if (file === p(destDir, "SKILL.md")) return false + return false + }) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: A test skill +--- +Instructions`) + + mockMkdir.mockResolvedValue(undefined) + mockRename.mockResolvedValue(undefined) + + await skillsManager.discoverSkills() + + // Verify skill exists + expect(skillsManager.getSkill("test-skill", "global")).toBeDefined() + + // Move the skill to code mode + await skillsManager.moveSkill("test-skill", "global", undefined, "code") + + expect(mockMkdir).toHaveBeenCalledWith(destSkillsDir, { recursive: true }) + expect(mockRename).toHaveBeenCalledWith(sourceDir, destDir) + }) + + it("should move a skill from one mode to another", async () => { + const sourceSkillsDir = p(GLOBAL_ROO_DIR, "skills-code") + const sourceDir = p(sourceSkillsDir, "test-skill") + const testSkillMd = p(sourceDir, "SKILL.md") + const destDir = p(GLOBAL_ROO_DIR, "skills-architect", "test-skill") + const destSkillsDir = p(GLOBAL_ROO_DIR, "skills-architect") + + // Setup: skill exists in code mode directory + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === sourceSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === sourceSkillsDir) { + return ["test-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === sourceDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + // Skill exists in source + if (file === testSkillMd) return true + // Skill does not exist in destination + if (file === p(destDir, "SKILL.md")) return false + return false + }) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: A test skill +--- +Instructions`) + + mockMkdir.mockResolvedValue(undefined) + mockRename.mockResolvedValue(undefined) + + await skillsManager.discoverSkills() + + // Verify skill exists with mode + expect(skillsManager.getSkill("test-skill", "global", "code")).toBeDefined() + + // Move the skill to architect mode + await skillsManager.moveSkill("test-skill", "global", "code", "architect") + + expect(mockMkdir).toHaveBeenCalledWith(destSkillsDir, { recursive: true }) + expect(mockRename).toHaveBeenCalledWith(sourceDir, destDir) + }) + + it("should move a skill from mode-specific to generic directory", async () => { + const sourceSkillsDir = p(GLOBAL_ROO_DIR, "skills-code") + const sourceDir = p(sourceSkillsDir, "test-skill") + const testSkillMd = p(sourceDir, "SKILL.md") + const destDir = p(globalSkillsDir, "test-skill") + + // Setup: skill exists in code mode directory + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === sourceSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === sourceSkillsDir) { + return ["test-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === sourceDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + // Skill exists in source + if (file === testSkillMd) return true + // Skill does not exist in destination + if (file === p(destDir, "SKILL.md")) return false + return false + }) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: A test skill +--- +Instructions`) + + mockMkdir.mockResolvedValue(undefined) + mockRename.mockResolvedValue(undefined) + + await skillsManager.discoverSkills() + + // Verify skill exists with mode + expect(skillsManager.getSkill("test-skill", "global", "code")).toBeDefined() + + // Move the skill to generic (no mode) + await skillsManager.moveSkill("test-skill", "global", "code", undefined) + + expect(mockMkdir).toHaveBeenCalledWith(globalSkillsDir, { recursive: true }) + expect(mockRename).toHaveBeenCalledWith(sourceDir, destDir) + }) + + it("should not do anything when source and destination modes are the same", async () => { + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["test-skill"] + } + return [] + }) + + const testSkillDir = p(globalSkillsDir, "test-skill") + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === testSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === p(testSkillDir, "SKILL.md") + }) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: A test skill +--- +Instructions`) + + await skillsManager.discoverSkills() + + // Try to move skill to the same mode (undefined -> undefined) + await skillsManager.moveSkill("test-skill", "global", undefined, undefined) + + // Should not call rename + expect(mockRename).not.toHaveBeenCalled() + }) + + it("should throw error if skill does not exist", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + + await skillsManager.discoverSkills() + + await expect(skillsManager.moveSkill("non-existent", "global", undefined, "code")).rejects.toThrow( + "not found", + ) + }) + + it("should throw error if skill already exists at destination", async () => { + const sourceDir = p(globalSkillsDir, "test-skill") + const testSkillMd = p(sourceDir, "SKILL.md") + const destDir = p(GLOBAL_ROO_DIR, "skills-code", "test-skill") + const destSkillMd = p(destDir, "SKILL.md") + + // Setup: skill exists in both locations + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["test-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === sourceDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + // Skill exists in both source and destination + if (file === testSkillMd) return true + if (file === destSkillMd) return true + return false + }) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: A test skill +--- +Instructions`) + + await skillsManager.discoverSkills() + + await expect(skillsManager.moveSkill("test-skill", "global", undefined, "code")).rejects.toThrow( + "already exists", + ) + }) + + it("should clean up empty source skills directory after moving", async () => { + const sourceSkillsDir = p(GLOBAL_ROO_DIR, "skills-code") + const sourceDir = p(sourceSkillsDir, "test-skill") + const testSkillMd = p(sourceDir, "SKILL.md") + const destDir = p(GLOBAL_ROO_DIR, "skills-architect", "test-skill") + const destSkillsDir = p(GLOBAL_ROO_DIR, "skills-architect") + + // Setup: skill exists in code mode directory + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === sourceSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + // Track readdir calls - return skill for discovery, empty for cleanup check + let readdirCallCount = 0 + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === sourceSkillsDir) { + readdirCallCount++ + // First call is for discovery, return the skill + // Second call is for cleanup check after move, return empty + if (readdirCallCount === 1) { + return ["test-skill"] + } + return [] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === sourceDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + // Skill exists in source + if (file === testSkillMd) return true + // Skill does not exist in destination + if (file === p(destDir, "SKILL.md")) return false + return false + }) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: A test skill +--- +Instructions`) + + mockMkdir.mockResolvedValue(undefined) + mockRename.mockResolvedValue(undefined) + mockRmdir.mockResolvedValue(undefined) + + await skillsManager.discoverSkills() + + // Move the skill to architect mode + await skillsManager.moveSkill("test-skill", "global", "code", "architect") + + // Verify empty directory was cleaned up + expect(mockRmdir).toHaveBeenCalledWith(sourceSkillsDir) + }) + + it("should not clean up source skills directory if it still has other skills", async () => { + const sourceSkillsDir = p(GLOBAL_ROO_DIR, "skills-code") + const sourceDir = p(sourceSkillsDir, "test-skill") + const testSkillMd = p(sourceDir, "SKILL.md") + const destDir = p(GLOBAL_ROO_DIR, "skills-architect", "test-skill") + const destSkillsDir = p(GLOBAL_ROO_DIR, "skills-architect") + + // Setup: skill exists in code mode directory along with another skill + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === sourceSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + // Track readdir calls - return skill for discovery, non-empty for cleanup check + let readdirCallCount = 0 + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === sourceSkillsDir) { + readdirCallCount++ + // First call is for discovery + if (readdirCallCount === 1) { + return ["test-skill", "another-skill"] + } + // Second call for cleanup - still has another skill + return ["another-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === sourceDir || pathArg === p(sourceSkillsDir, "another-skill")) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + // Skill exists in source + if (file === testSkillMd) return true + if (file === p(sourceSkillsDir, "another-skill", "SKILL.md")) return true + // Skill does not exist in destination + if (file === p(destDir, "SKILL.md")) return false + return false + }) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: A test skill +--- +Instructions`) + + mockMkdir.mockResolvedValue(undefined) + mockRename.mockResolvedValue(undefined) + mockRmdir.mockResolvedValue(undefined) + + await skillsManager.discoverSkills() + + // Move the skill to architect mode + await skillsManager.moveSkill("test-skill", "global", "code", "architect") + + // Verify directory was NOT cleaned up (still has other skills) + expect(mockRmdir).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/services/skills/__tests__/generate-built-in-skills.spec.ts b/src/services/skills/__tests__/generate-built-in-skills.spec.ts new file mode 100644 index 0000000000..10b44b8716 --- /dev/null +++ b/src/services/skills/__tests__/generate-built-in-skills.spec.ts @@ -0,0 +1,175 @@ +/** + * Tests for the built-in skills generation script validation logic. + * + * Note: These tests focus on the validation functions since the main script + * is designed to be run as a CLI tool. The actual generation is tested + * via the integration with the build process. + */ + +describe("generate-built-in-skills validation", () => { + describe("validateSkillName", () => { + // Validation function extracted from the generation script + function validateSkillName(name: string): string[] { + const errors: string[] = [] + + if (name.length < 1 || name.length > 64) { + errors.push(`Name must be 1-64 characters (got ${name.length})`) + } + + const nameFormat = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + if (!nameFormat.test(name)) { + errors.push( + "Name must be lowercase letters/numbers/hyphens only (no leading/trailing hyphen, no consecutive hyphens)", + ) + } + + return errors + } + + it("should accept valid skill names", () => { + expect(validateSkillName("mcp-builder")).toHaveLength(0) + expect(validateSkillName("create-mode")).toHaveLength(0) + expect(validateSkillName("pdf-processing")).toHaveLength(0) + expect(validateSkillName("a")).toHaveLength(0) + expect(validateSkillName("skill123")).toHaveLength(0) + expect(validateSkillName("my-skill-v2")).toHaveLength(0) + }) + + it("should reject names with uppercase letters", () => { + const errors = validateSkillName("Create-MCP-Server") + expect(errors).toHaveLength(1) + expect(errors[0]).toContain("lowercase") + }) + + it("should reject names with leading hyphen", () => { + const errors = validateSkillName("-my-skill") + expect(errors).toHaveLength(1) + expect(errors[0]).toContain("leading/trailing hyphen") + }) + + it("should reject names with trailing hyphen", () => { + const errors = validateSkillName("my-skill-") + expect(errors).toHaveLength(1) + expect(errors[0]).toContain("leading/trailing hyphen") + }) + + it("should reject names with consecutive hyphens", () => { + const errors = validateSkillName("my--skill") + expect(errors).toHaveLength(1) + expect(errors[0]).toContain("consecutive hyphens") + }) + + it("should reject empty names", () => { + const errors = validateSkillName("") + expect(errors.length).toBeGreaterThan(0) + }) + + it("should reject names longer than 64 characters", () => { + const longName = "a".repeat(65) + const errors = validateSkillName(longName) + expect(errors).toHaveLength(1) + expect(errors[0]).toContain("1-64 characters") + }) + + it("should reject names with special characters", () => { + expect(validateSkillName("my_skill").length).toBeGreaterThan(0) + expect(validateSkillName("my.skill").length).toBeGreaterThan(0) + expect(validateSkillName("my skill").length).toBeGreaterThan(0) + }) + }) + + describe("validateDescription", () => { + // Validation function extracted from the generation script + function validateDescription(description: string): string[] { + const errors: string[] = [] + const trimmed = description.trim() + + if (trimmed.length < 1 || trimmed.length > 1024) { + errors.push(`Description must be 1-1024 characters (got ${trimmed.length})`) + } + + return errors + } + + it("should accept valid descriptions", () => { + expect(validateDescription("A short description")).toHaveLength(0) + expect(validateDescription("x")).toHaveLength(0) + expect(validateDescription("x".repeat(1024))).toHaveLength(0) + }) + + it("should reject empty descriptions", () => { + const errors = validateDescription("") + expect(errors).toHaveLength(1) + expect(errors[0]).toContain("1-1024 characters") + }) + + it("should reject whitespace-only descriptions", () => { + const errors = validateDescription(" ") + expect(errors).toHaveLength(1) + expect(errors[0]).toContain("got 0") + }) + + it("should reject descriptions longer than 1024 characters", () => { + const longDesc = "x".repeat(1025) + const errors = validateDescription(longDesc) + expect(errors).toHaveLength(1) + expect(errors[0]).toContain("got 1025") + }) + }) + + describe("escapeForTemplateLiteral", () => { + // Escape function extracted from the generation script + function escapeForTemplateLiteral(str: string): string { + return str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${") + } + + it("should escape backticks", () => { + expect(escapeForTemplateLiteral("code `example`")).toBe("code \\`example\\`") + }) + + it("should escape template literal interpolation", () => { + expect(escapeForTemplateLiteral("value: ${foo}")).toBe("value: \\${foo}") + }) + + it("should escape backslashes", () => { + expect(escapeForTemplateLiteral("path\\to\\file")).toBe("path\\\\to\\\\file") + }) + + it("should handle combined escapes", () => { + const input = "const x = `${value}`" + const expected = "const x = \\`\\${value}\\`" + expect(escapeForTemplateLiteral(input)).toBe(expected) + }) + }) +}) + +describe("built-in skills integration", () => { + it("should have valid skill names matching directory names", async () => { + // Import the generated built-in skills + const { getBuiltInSkills, getBuiltInSkillContent } = await import("../built-in-skills") + + const skills = getBuiltInSkills() + + // Verify we have the expected skills + const skillNames = skills.map((s) => s.name) + expect(skillNames).toContain("create-mcp-server") + expect(skillNames).toContain("create-mode") + + // Verify each skill has valid content + for (const skill of skills) { + expect(skill.source).toBe("built-in") + expect(skill.path).toBe("built-in") + + const content = getBuiltInSkillContent(skill.name) + expect(content).not.toBeNull() + expect(content!.instructions.length).toBeGreaterThan(0) + } + }) + + it("should return null for non-existent skills", async () => { + const { getBuiltInSkillContent } = await import("../built-in-skills") + + const content = getBuiltInSkillContent("non-existent-skill") + expect(content).toBeNull() + }) +}) diff --git a/src/services/skills/built-in-skills.ts b/src/services/skills/built-in-skills.ts new file mode 100644 index 0000000000..633157f10c --- /dev/null +++ b/src/services/skills/built-in-skills.ts @@ -0,0 +1,419 @@ +/** + * AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY + * + * This file is generated by generate-built-in-skills.ts from the SKILL.md files + * in the built-in/ directory. To modify built-in skills, edit the corresponding + * SKILL.md file and run: pnpm generate:skills + */ + +import { SkillMetadata, SkillContent } from "../../shared/skills" + +interface BuiltInSkillDefinition { + name: string + description: string + instructions: string +} + +const BUILT_IN_SKILLS: Record = { + "create-mcp-server": { + name: "create-mcp-server", + description: + "Instructions for creating MCP (Model Context Protocol) servers that expose tools and resources for the agent to use. Use when the user asks to create a new MCP server or add MCP capabilities.", + instructions: `You have the ability to create an MCP server and add it to a configuration file that will then expose the tools and resources for you to use with \`use_mcp_tool\` and \`access_mcp_resource\`. + +When creating MCP servers, it's important to understand that they operate in a non-interactive environment. The server cannot initiate OAuth flows, open browser windows, or prompt for user input during runtime. All credentials and authentication tokens must be provided upfront through environment variables in the MCP settings configuration. For example, Spotify's API uses OAuth to get a refresh token for the user, but the MCP server cannot initiate this flow. While you can walk the user through obtaining an application client ID and secret, you may have to create a separate one-time setup script (like get-refresh-token.js) that captures and logs the final piece of the puzzle: the user's refresh token (i.e. you might run the script using execute_command which would open a browser for authentication, and then log the refresh token so that you can see it in the command output for you to use in the MCP settings configuration). + +Unless the user specifies otherwise, new local MCP servers should be created in your MCP servers directory. You can find the path to this directory by checking the MCP settings file, or ask the user where they'd like the server created. + +### MCP Server Types and Configuration + +MCP servers can be configured in two ways in the MCP settings file: + +1. Local (Stdio) Server Configuration: + +\`\`\`json +{ + "mcpServers": { + "local-weather": { + "command": "node", + "args": ["/path/to/weather-server/build/index.js"], + "env": { + "OPENWEATHER_API_KEY": "your-api-key" + } + } + } +} +\`\`\` + +2. Remote (SSE) Server Configuration: + +\`\`\`json +{ + "mcpServers": { + "remote-weather": { + "url": "https://api.example.com/mcp", + "headers": { + "Authorization": "Bearer your-api-key" + } + } + } +} +\`\`\` + +Common configuration options for both types: + +- \`disabled\`: (optional) Set to true to temporarily disable the server +- \`timeout\`: (optional) Maximum time in seconds to wait for server responses (default: 60) +- \`alwaysAllow\`: (optional) Array of tool names that don't require user confirmation +- \`disabledTools\`: (optional) Array of tool names that are not included in the system prompt and won't be used + +### Example Local MCP Server + +For example, if the user wanted to give you the ability to retrieve weather information, you could create an MCP server that uses the OpenWeather API to get weather information, add it to the MCP settings configuration file, and then notice that you now have access to new tools and resources in the system prompt that you might use to show the user your new capabilities. + +The following example demonstrates how to build a local MCP server that provides weather data functionality using the Stdio transport. While this example shows how to implement resources, resource templates, and tools, in practice you should prefer using tools since they are more flexible and can handle dynamic parameters. The resource and resource template implementations are included here mainly for demonstration purposes of the different MCP capabilities, but a real weather server would likely just expose tools for fetching weather data. (The following steps are for macOS) + +1. Use the \`create-typescript-server\` tool to bootstrap a new project in your MCP servers directory: + +\`\`\`bash +cd /path/to/your/mcp-servers +npx @modelcontextprotocol/create-server weather-server +cd weather-server +# Install dependencies +npm install axios zod @modelcontextprotocol/sdk +\`\`\` + +This will create a new project with the following structure: + +\`\`\` +weather-server/ + ├── package.json + { + ... + "type": "module", // added by default, uses ES module syntax (import/export) rather than CommonJS (require/module.exports) (Important to know if you create additional scripts in this server repository like a get-refresh-token.js script) + "scripts": { + "build": "tsc && node -e \\"require('fs').chmodSync('build/index.js', '755')\\"", + ... + } + ... + } + ├── tsconfig.json + └── src/ + └── index.ts # Main server implementation +\`\`\` + +2. Replace \`src/index.ts\` with the following: + +\`\`\`typescript +#!/usr/bin/env node +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { z } from "zod" +import axios from "axios" + +const API_KEY = process.env.OPENWEATHER_API_KEY // provided by MCP config +if (!API_KEY) { + throw new Error("OPENWEATHER_API_KEY environment variable is required") +} + +// Define types for OpenWeather API responses +interface WeatherData { + main: { + temp: number + humidity: number + } + weather: Array<{ + description: string + }> + wind: { + speed: number + } +} + +interface ForecastData { + list: Array< + WeatherData & { + dt_txt: string + } + > +} + +// Create an MCP server +const server = new McpServer({ + name: "weather-server", + version: "0.1.0", +}) + +// Create axios instance for OpenWeather API +const weatherApi = axios.create({ + baseURL: "http://api.openweathermap.org/data/2.5", + params: { + appid: API_KEY, + units: "metric", + }, +}) + +// Add a tool for getting weather forecasts +server.tool( + "get_forecast", + { + city: z.string().describe("City name"), + days: z.number().min(1).max(5).optional().describe("Number of days (1-5)"), + }, + async ({ city, days = 3 }) => { + try { + const response = await weatherApi.get("forecast", { + params: { + q: city, + cnt: Math.min(days, 5) * 8, + }, + }) + + return { + content: [ + { + type: "text", + text: JSON.stringify(response.data.list, null, 2), + }, + ], + } + } catch (error) { + if (axios.isAxiosError(error)) { + return { + content: [ + { + type: "text", + text: \`Weather API error: \${error.response?.data.message ?? error.message}\`, + }, + ], + isError: true, + } + } + throw error + } + }, +) + +// Add a resource for current weather in San Francisco +server.resource("sf_weather", { uri: "weather://San Francisco/current", list: true }, async (uri) => { + try { + const response = weatherApi.get("weather", { + params: { q: "San Francisco" }, + }) + + return { + contents: [ + { + uri: uri.href, + mimeType: "application/json", + text: JSON.stringify( + { + temperature: response.data.main.temp, + conditions: response.data.weather[0].description, + humidity: response.data.main.humidity, + wind_speed: response.data.wind.speed, + timestamp: new Date().toISOString(), + }, + null, + 2, + ), + }, + ], + } + } catch (error) { + if (axios.isAxiosError(error)) { + throw new Error(\`Weather API error: \${error.response?.data.message ?? error.message}\`) + } + throw error + } +}) + +// Add a dynamic resource template for current weather by city +server.resource( + "current_weather", + new ResourceTemplate("weather://{city}/current", { list: true }), + async (uri, { city }) => { + try { + const response = await weatherApi.get("weather", { + params: { q: city }, + }) + + return { + contents: [ + { + uri: uri.href, + mimeType: "application/json", + text: JSON.stringify( + { + temperature: response.data.main.temp, + conditions: response.data.weather[0].description, + humidity: response.data.main.humidity, + wind_speed: response.data.wind.speed, + timestamp: new Date().toISOString(), + }, + null, + 2, + ), + }, + ], + } + } catch (error) { + if (axios.isAxiosError(error)) { + throw new Error(\`Weather API error: \${error.response?.data.message ?? error.message}\`) + } + throw error + } + }, +) + +// Start receiving messages on stdin and sending messages on stdout +const transport = new StdioServerTransport() +await server.connect(transport) +console.error("Weather MCP server running on stdio") +\`\`\` + +(Remember: This is just an example–you may use different dependencies, break the implementation up into multiple files, etc.) + +3. Build and compile the executable JavaScript file + +\`\`\`bash +npm run build +\`\`\` + +4. Whenever you need an environment variable such as an API key to configure the MCP server, walk the user through the process of getting the key. For example, they may need to create an account and go to a developer dashboard to generate the key. Provide step-by-step instructions and URLs to make it easy for the user to retrieve the necessary information. Then use the ask_followup_question tool to ask the user for the key, in this case the OpenWeather API key. + +5. Install the MCP Server by adding the MCP server configuration to the MCP settings file. On macOS/Linux this is typically at \`~/.roo-code/settings/mcp_settings.json\`, on Windows at \`%APPDATA%\\roo-code\\settings\\mcp_settings.json\`. The settings file may have other MCP servers already configured, so you would read it first and then add your new server to the existing \`mcpServers\` object. + +IMPORTANT: Regardless of what else you see in the MCP settings file, you must default any new MCP servers you create to disabled=false, alwaysAllow=[] and disabledTools=[]. + +\`\`\`json +{ + "mcpServers": { + ..., + "weather": { + "command": "node", + "args": ["/path/to/weather-server/build/index.js"], + "env": { + "OPENWEATHER_API_KEY": "user-provided-api-key" + } + }, + } +} +\`\`\` + +(Note: the user may also ask you to install the MCP server to the Claude desktop app, in which case you would read then modify \`~/Library/Application\\ Support/Claude/claude_desktop_config.json\` on macOS for example. It follows the same format of a top level \`mcpServers\` object.) + +6. After you have edited the MCP settings configuration file, the system will automatically run all the servers and expose the available tools and resources in the 'Connected MCP Servers' section. + +7. Now that you have access to these new tools and resources, you may suggest ways the user can command you to invoke them - for example, with this new weather tool now available, you can invite the user to ask "what's the weather in San Francisco?" + +## Editing MCP Servers + +The user may ask to add tools or resources that may make sense to add to an existing MCP server (listed under 'Connected MCP Servers' in the system prompt), e.g. if it would use the same API. This would be possible if you can locate the MCP server repository on the user's system by looking at the server arguments for a filepath. You might then use list_files and read_file to explore the files in the repository, and use write_to_file or apply_diff to make changes to the files. + +However some MCP servers may be running from installed packages rather than a local repository, in which case it may make more sense to create a new MCP server. + +# MCP Servers Are Not Always Necessary + +The user may not always request the use or creation of MCP servers. Instead, they might provide tasks that can be completed with existing tools. While using the MCP SDK to extend your capabilities can be useful, it's important to understand that this is just one specialized type of task you can accomplish. You should only implement MCP servers when the user explicitly requests it (e.g., "add a tool that..."). + +Remember: The MCP documentation and example provided above are to help you understand and work with existing MCP servers or create new ones when requested by the user. You already have access to tools and capabilities that can be used to accomplish a wide range of tasks.`, + }, + "create-mode": { + name: "create-mode", + description: + "Instructions for creating custom modes in Roo Code. Use when the user asks to create a new mode, edit an existing mode, or configure mode settings.", + instructions: `Custom modes can be configured in two ways: + +1. Globally via the custom modes file in your Roo Code settings directory (typically ~/.roo-code/settings/custom_modes.yaml on macOS/Linux or %APPDATA%\\roo-code\\settings\\custom_modes.yaml on Windows) - created automatically on startup +2. Per-workspace via '.roomodes' in the workspace root directory + +When modes with the same slug exist in both files, the workspace-specific .roomodes version takes precedence. This allows projects to override global modes or define project-specific modes. + +If asked to create a project mode, create it in .roomodes in the workspace root. If asked to create a global mode, use the global custom modes file. + +- The following fields are required and must not be empty: + - slug: A valid slug (lowercase letters, numbers, and hyphens). Must be unique, and shorter is better. + - name: The display name for the mode + - roleDefinition: A detailed description of the mode's role and capabilities + - groups: Array of allowed tool groups (can be empty). Each group can be specified either as a string (e.g., "edit" to allow editing any file) or with file restrictions (e.g., ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }] to only allow editing markdown files) + +- The following fields are optional but highly recommended: + - description: A short, human-readable description of what this mode does (5 words) + - whenToUse: A clear description of when this mode should be selected and what types of tasks it's best suited for. This helps the Orchestrator mode make better decisions. + - customInstructions: Additional instructions for how the mode should operate + +- For multi-line text, include newline characters in the string like "This is the first line.\\nThis is the next line.\\n\\nThis is a double line break." + +Both files should follow this structure (in YAML format): + +customModes: + +- slug: designer # Required: unique slug with lowercase letters, numbers, and hyphens + name: Designer # Required: mode display name + description: UI/UX design systems expert # Optional but recommended: short description (5 words) + roleDefinition: >- + You are Roo, a UI/UX expert specializing in design systems and frontend development. Your expertise includes: + - Creating and maintaining design systems + - Implementing responsive and accessible web interfaces + - Working with CSS, HTML, and modern frontend frameworks + - Ensuring consistent user experiences across platforms # Required: non-empty + whenToUse: >- + Use this mode when creating or modifying UI components, implementing design systems, + or ensuring responsive web interfaces. This mode is especially effective with CSS, + HTML, and modern frontend frameworks. # Optional but recommended + groups: # Required: array of tool groups (can be empty) + - read # Read files group (read_file, search_files, list_files, codebase_search) + - edit # Edit files group (apply_diff, write_to_file) - allows editing any file + # Or with file restrictions: + # - - edit + # - fileRegex: \\.md$ + # description: Markdown files only # Edit group that only allows editing markdown files + - browser # Browser group (browser_action) + - command # Command group (execute_command) + - mcp # MCP group (use_mcp_tool, access_mcp_resource) + customInstructions: Additional instructions for the Designer mode # Optional`, + }, +} + +/** + * Get all built-in skills as SkillMetadata objects + */ +export function getBuiltInSkills(): SkillMetadata[] { + return Object.values(BUILT_IN_SKILLS).map((skill) => ({ + name: skill.name, + description: skill.description, + path: "built-in", + source: "built-in" as const, + })) +} + +/** + * Get a specific built-in skill's full content by name + */ +export function getBuiltInSkillContent(name: string): SkillContent | null { + const skill = BUILT_IN_SKILLS[name] + if (!skill) return null + + return { + name: skill.name, + description: skill.description, + path: "built-in", + source: "built-in" as const, + instructions: skill.instructions, + } +} + +/** + * Check if a skill name is a built-in skill + */ +export function isBuiltInSkill(name: string): boolean { + return name in BUILT_IN_SKILLS +} + +/** + * Get names of all built-in skills + */ +export function getBuiltInSkillNames(): string[] { + return Object.keys(BUILT_IN_SKILLS) +} diff --git a/src/core/prompts/instructions/create-mcp-server.ts b/src/services/skills/built-in/create-mcp-server/SKILL.md similarity index 52% rename from src/core/prompts/instructions/create-mcp-server.ts rename to src/services/skills/built-in/create-mcp-server/SKILL.md index 2d06deef7b..be52e91c89 100644 --- a/src/core/prompts/instructions/create-mcp-server.ts +++ b/src/services/skills/built-in/create-mcp-server/SKILL.md @@ -1,24 +1,21 @@ -import { McpHub } from "../../../services/mcp/McpHub" -import { DiffStrategy } from "../../../shared/tools" +--- +name: create-mcp-server +description: Instructions for creating MCP (Model Context Protocol) servers that expose tools and resources for the agent to use. Use when the user asks to create a new MCP server or add MCP capabilities. +--- -export async function createMCPServerInstructions( - mcpHub: McpHub | undefined, - diffStrategy: DiffStrategy | undefined, -): Promise { - if (!diffStrategy || !mcpHub) throw new Error("Missing MCP Hub or Diff Strategy") - - return `You have the ability to create an MCP server and add it to a configuration file that will then expose the tools and resources for you to use with \`use_mcp_tool\` and \`access_mcp_resource\`. +You have the ability to create an MCP server and add it to a configuration file that will then expose the tools and resources for you to use with `use_mcp_tool` and `access_mcp_resource`. When creating MCP servers, it's important to understand that they operate in a non-interactive environment. The server cannot initiate OAuth flows, open browser windows, or prompt for user input during runtime. All credentials and authentication tokens must be provided upfront through environment variables in the MCP settings configuration. For example, Spotify's API uses OAuth to get a refresh token for the user, but the MCP server cannot initiate this flow. While you can walk the user through obtaining an application client ID and secret, you may have to create a separate one-time setup script (like get-refresh-token.js) that captures and logs the final piece of the puzzle: the user's refresh token (i.e. you might run the script using execute_command which would open a browser for authentication, and then log the refresh token so that you can see it in the command output for you to use in the MCP settings configuration). -Unless the user specifies otherwise, new local MCP servers should be created in: ${await mcpHub.getMcpServersPath()} +Unless the user specifies otherwise, new local MCP servers should be created in your MCP servers directory. You can find the path to this directory by checking the MCP settings file, or ask the user where they'd like the server created. ### MCP Server Types and Configuration MCP servers can be configured in two ways in the MCP settings file: 1. Local (Stdio) Server Configuration: -\`\`\`json + +```json { "mcpServers": { "local-weather": { @@ -30,10 +27,11 @@ MCP servers can be configured in two ways in the MCP settings file: } } } -\`\`\` +``` 2. Remote (SSE) Server Configuration: -\`\`\`json + +```json { "mcpServers": { "remote-weather": { @@ -44,13 +42,14 @@ MCP servers can be configured in two ways in the MCP settings file: } } } -\`\`\` +``` Common configuration options for both types: -- \`disabled\`: (optional) Set to true to temporarily disable the server -- \`timeout\`: (optional) Maximum time in seconds to wait for server responses (default: 60) -- \`alwaysAllow\`: (optional) Array of tool names that don't require user confirmation -- \`disabledTools\`: (optional) Array of tool names that are not included in the system prompt and won't be used + +- `disabled`: (optional) Set to true to temporarily disable the server +- `timeout`: (optional) Maximum time in seconds to wait for server responses (default: 60) +- `alwaysAllow`: (optional) Array of tool names that don't require user confirmation +- `disabledTools`: (optional) Array of tool names that are not included in the system prompt and won't be used ### Example Local MCP Server @@ -58,19 +57,19 @@ For example, if the user wanted to give you the ability to retrieve weather info The following example demonstrates how to build a local MCP server that provides weather data functionality using the Stdio transport. While this example shows how to implement resources, resource templates, and tools, in practice you should prefer using tools since they are more flexible and can handle dynamic parameters. The resource and resource template implementations are included here mainly for demonstration purposes of the different MCP capabilities, but a real weather server would likely just expose tools for fetching weather data. (The following steps are for macOS) -1. Use the \`create-typescript-server\` tool to bootstrap a new project in the default MCP servers directory: +1. Use the `create-typescript-server` tool to bootstrap a new project in your MCP servers directory: -\`\`\`bash -cd ${await mcpHub.getMcpServersPath()} +```bash +cd /path/to/your/mcp-servers npx @modelcontextprotocol/create-server weather-server cd weather-server # Install dependencies npm install axios zod @modelcontextprotocol/sdk -\`\`\` +``` This will create a new project with the following structure: -\`\`\` +``` weather-server/ ├── package.json { @@ -85,201 +84,193 @@ weather-server/ ├── tsconfig.json └── src/ └── index.ts # Main server implementation -\`\`\` +``` -2. Replace \`src/index.ts\` with the following: +2. Replace `src/index.ts` with the following: -\`\`\`typescript +```typescript #!/usr/bin/env node -import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { z } from "zod"; -import axios from 'axios'; +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { z } from "zod" +import axios from "axios" -const API_KEY = process.env.OPENWEATHER_API_KEY; // provided by MCP config +const API_KEY = process.env.OPENWEATHER_API_KEY // provided by MCP config if (!API_KEY) { - throw new Error('OPENWEATHER_API_KEY environment variable is required'); + throw new Error("OPENWEATHER_API_KEY environment variable is required") } // Define types for OpenWeather API responses interface WeatherData { - main: { - temp: number; - humidity: number; - }; - weather: Array<{ - description: string; - }>; - wind: { - speed: number; - }; + main: { + temp: number + humidity: number + } + weather: Array<{ + description: string + }> + wind: { + speed: number + } } interface ForecastData { - list: Array; + list: Array< + WeatherData & { + dt_txt: string + } + > } // Create an MCP server const server = new McpServer({ - name: "weather-server", - version: "0.1.0" -}); + name: "weather-server", + version: "0.1.0", +}) // Create axios instance for OpenWeather API const weatherApi = axios.create({ - baseURL: 'http://api.openweathermap.org/data/2.5', - params: { - appid: API_KEY, - units: 'metric', - }, -}); + baseURL: "http://api.openweathermap.org/data/2.5", + params: { + appid: API_KEY, + units: "metric", + }, +}) // Add a tool for getting weather forecasts server.tool( - "get_forecast", - { - city: z.string().describe("City name"), - days: z.number().min(1).max(5).optional().describe("Number of days (1-5)"), - }, - async ({ city, days = 3 }) => { - try { - const response = await weatherApi.get('forecast', { - params: { - q: city, - cnt: Math.min(days, 5) * 8, - }, - }); - - return { - content: [ - { - type: "text", - text: JSON.stringify(response.data.list, null, 2), - }, - ], - }; - } catch (error) { - if (axios.isAxiosError(error)) { - return { - content: [ - { - type: "text", - text: \`Weather API error: \${ - error.response?.data.message ?? error.message - }\`, - }, - ], - isError: true, - }; - } - throw error; - } - } -); + "get_forecast", + { + city: z.string().describe("City name"), + days: z.number().min(1).max(5).optional().describe("Number of days (1-5)"), + }, + async ({ city, days = 3 }) => { + try { + const response = await weatherApi.get("forecast", { + params: { + q: city, + cnt: Math.min(days, 5) * 8, + }, + }) + + return { + content: [ + { + type: "text", + text: JSON.stringify(response.data.list, null, 2), + }, + ], + } + } catch (error) { + if (axios.isAxiosError(error)) { + return { + content: [ + { + type: "text", + text: `Weather API error: ${error.response?.data.message ?? error.message}`, + }, + ], + isError: true, + } + } + throw error + } + }, +) // Add a resource for current weather in San Francisco -server.resource( - "sf_weather", - { uri: "weather://San Francisco/current", list: true }, - async (uri) => { - try { - const response = weatherApi.get('weather', { - params: { q: "San Francisco" }, - }); - - return { - contents: [ - { - uri: uri.href, - mimeType: "application/json", - text: JSON.stringify( - { - temperature: response.data.main.temp, - conditions: response.data.weather[0].description, - humidity: response.data.main.humidity, - wind_speed: response.data.wind.speed, - timestamp: new Date().toISOString(), - }, - null, - 2 - ), - }, - ], - }; - } catch (error) { - if (axios.isAxiosError(error)) { - throw new Error(\`Weather API error: \${ - error.response?.data.message ?? error.message - }\`); - } - throw error; - } - } -); +server.resource("sf_weather", { uri: "weather://San Francisco/current", list: true }, async (uri) => { + try { + const response = weatherApi.get("weather", { + params: { q: "San Francisco" }, + }) + + return { + contents: [ + { + uri: uri.href, + mimeType: "application/json", + text: JSON.stringify( + { + temperature: response.data.main.temp, + conditions: response.data.weather[0].description, + humidity: response.data.main.humidity, + wind_speed: response.data.wind.speed, + timestamp: new Date().toISOString(), + }, + null, + 2, + ), + }, + ], + } + } catch (error) { + if (axios.isAxiosError(error)) { + throw new Error(`Weather API error: ${error.response?.data.message ?? error.message}`) + } + throw error + } +}) // Add a dynamic resource template for current weather by city server.resource( - "current_weather", - new ResourceTemplate("weather://{city}/current", { list: true }), - async (uri, { city }) => { - try { - const response = await weatherApi.get('weather', { - params: { q: city }, - }); - - return { - contents: [ - { - uri: uri.href, - mimeType: "application/json", - text: JSON.stringify( - { - temperature: response.data.main.temp, - conditions: response.data.weather[0].description, - humidity: response.data.main.humidity, - wind_speed: response.data.wind.speed, - timestamp: new Date().toISOString(), - }, - null, - 2 - ), - }, - ], - }; - } catch (error) { - if (axios.isAxiosError(error)) { - throw new Error(\`Weather API error: \${ - error.response?.data.message ?? error.message - }\`); - } - throw error; - } - } -); + "current_weather", + new ResourceTemplate("weather://{city}/current", { list: true }), + async (uri, { city }) => { + try { + const response = await weatherApi.get("weather", { + params: { q: city }, + }) + + return { + contents: [ + { + uri: uri.href, + mimeType: "application/json", + text: JSON.stringify( + { + temperature: response.data.main.temp, + conditions: response.data.weather[0].description, + humidity: response.data.main.humidity, + wind_speed: response.data.wind.speed, + timestamp: new Date().toISOString(), + }, + null, + 2, + ), + }, + ], + } + } catch (error) { + if (axios.isAxiosError(error)) { + throw new Error(`Weather API error: ${error.response?.data.message ?? error.message}`) + } + throw error + } + }, +) // Start receiving messages on stdin and sending messages on stdout -const transport = new StdioServerTransport(); -await server.connect(transport); -console.error('Weather MCP server running on stdio'); -\`\`\` +const transport = new StdioServerTransport() +await server.connect(transport) +console.error("Weather MCP server running on stdio") +``` (Remember: This is just an example–you may use different dependencies, break the implementation up into multiple files, etc.) 3. Build and compile the executable JavaScript file -\`\`\`bash +```bash npm run build -\`\`\` +``` 4. Whenever you need an environment variable such as an API key to configure the MCP server, walk the user through the process of getting the key. For example, they may need to create an account and go to a developer dashboard to generate the key. Provide step-by-step instructions and URLs to make it easy for the user to retrieve the necessary information. Then use the ask_followup_question tool to ask the user for the key, in this case the OpenWeather API key. -5. Install the MCP Server by adding the MCP server configuration to the settings file located at '${await mcpHub.getMcpSettingsFilePath()}'. The settings file may have other MCP servers already configured, so you would read it first and then add your new server to the existing \`mcpServers\` object. +5. Install the MCP Server by adding the MCP server configuration to the MCP settings file. On macOS/Linux this is typically at `~/.roo-code/settings/mcp_settings.json`, on Windows at `%APPDATA%\roo-code\settings\mcp_settings.json`. The settings file may have other MCP servers already configured, so you would read it first and then add your new server to the existing `mcpServers` object. IMPORTANT: Regardless of what else you see in the MCP settings file, you must default any new MCP servers you create to disabled=false, alwaysAllow=[] and disabledTools=[]. -\`\`\`json +```json { "mcpServers": { ..., @@ -292,9 +283,9 @@ IMPORTANT: Regardless of what else you see in the MCP settings file, you must de }, } } -\`\`\` +``` -(Note: the user may also ask you to install the MCP server to the Claude desktop app, in which case you would read then modify \`~/Library/Application\ Support/Claude/claude_desktop_config.json\` on macOS for example. It follows the same format of a top level \`mcpServers\` object.) +(Note: the user may also ask you to install the MCP server to the Claude desktop app, in which case you would read then modify `~/Library/Application\ Support/Claude/claude_desktop_config.json` on macOS for example. It follows the same format of a top level `mcpServers` object.) 6. After you have edited the MCP settings configuration file, the system will automatically run all the servers and expose the available tools and resources in the 'Connected MCP Servers' section. @@ -302,14 +293,7 @@ IMPORTANT: Regardless of what else you see in the MCP settings file, you must de ## Editing MCP Servers -The user may ask to add tools or resources that may make sense to add to an existing MCP server (listed under 'Connected MCP Servers' above: ${(() => { - if (!mcpHub) return "(None running currently)" - const servers = mcpHub - .getServers() - ?.map((server) => server.name) - .join(", ") - return servers || "(None running currently)" - })()}, e.g. if it would use the same API. This would be possible if you can locate the MCP server repository on the user's system by looking at the server arguments for a filepath. You might then use list_files and read_file to explore the files in the repository, and use write_to_file${diffStrategy ? " or apply_diff" : ""} to make changes to the files. +The user may ask to add tools or resources that may make sense to add to an existing MCP server (listed under 'Connected MCP Servers' in the system prompt), e.g. if it would use the same API. This would be possible if you can locate the MCP server repository on the user's system by looking at the server arguments for a filepath. You might then use list_files and read_file to explore the files in the repository, and use write_to_file or apply_diff to make changes to the files. However some MCP servers may be running from installed packages rather than a local repository, in which case it may make more sense to create a new MCP server. @@ -317,5 +301,4 @@ However some MCP servers may be running from installed packages rather than a lo The user may not always request the use or creation of MCP servers. Instead, they might provide tasks that can be completed with existing tools. While using the MCP SDK to extend your capabilities can be useful, it's important to understand that this is just one specialized type of task you can accomplish. You should only implement MCP servers when the user explicitly requests it (e.g., "add a tool that..."). -Remember: The MCP documentation and example provided above are to help you understand and work with existing MCP servers or create new ones when requested by the user. You already have access to tools and capabilities that can be used to accomplish a wide range of tasks.` -} +Remember: The MCP documentation and example provided above are to help you understand and work with existing MCP servers or create new ones when requested by the user. You already have access to tools and capabilities that can be used to accomplish a wide range of tasks. diff --git a/src/services/skills/built-in/create-mode/SKILL.md b/src/services/skills/built-in/create-mode/SKILL.md new file mode 100644 index 0000000000..dfbe06aee8 --- /dev/null +++ b/src/services/skills/built-in/create-mode/SKILL.md @@ -0,0 +1,55 @@ +--- +name: create-mode +description: Instructions for creating custom modes in Roo Code. Use when the user asks to create a new mode, edit an existing mode, or configure mode settings. +--- + +Custom modes can be configured in two ways: + +1. Globally via the custom modes file in your Roo Code settings directory (typically ~/.roo-code/settings/custom_modes.yaml on macOS/Linux or %APPDATA%\roo-code\settings\custom_modes.yaml on Windows) - created automatically on startup +2. Per-workspace via '.roomodes' in the workspace root directory + +When modes with the same slug exist in both files, the workspace-specific .roomodes version takes precedence. This allows projects to override global modes or define project-specific modes. + +If asked to create a project mode, create it in .roomodes in the workspace root. If asked to create a global mode, use the global custom modes file. + +- The following fields are required and must not be empty: + - slug: A valid slug (lowercase letters, numbers, and hyphens). Must be unique, and shorter is better. + - name: The display name for the mode + - roleDefinition: A detailed description of the mode's role and capabilities + - groups: Array of allowed tool groups (can be empty). Each group can be specified either as a string (e.g., "edit" to allow editing any file) or with file restrictions (e.g., ["edit", { fileRegex: "\.md$", description: "Markdown files only" }] to only allow editing markdown files) + +- The following fields are optional but highly recommended: + - description: A short, human-readable description of what this mode does (5 words) + - whenToUse: A clear description of when this mode should be selected and what types of tasks it's best suited for. This helps the Orchestrator mode make better decisions. + - customInstructions: Additional instructions for how the mode should operate + +- For multi-line text, include newline characters in the string like "This is the first line.\nThis is the next line.\n\nThis is a double line break." + +Both files should follow this structure (in YAML format): + +customModes: + +- slug: designer # Required: unique slug with lowercase letters, numbers, and hyphens + name: Designer # Required: mode display name + description: UI/UX design systems expert # Optional but recommended: short description (5 words) + roleDefinition: >- + You are Roo, a UI/UX expert specializing in design systems and frontend development. Your expertise includes: + - Creating and maintaining design systems + - Implementing responsive and accessible web interfaces + - Working with CSS, HTML, and modern frontend frameworks + - Ensuring consistent user experiences across platforms # Required: non-empty + whenToUse: >- + Use this mode when creating or modifying UI components, implementing design systems, + or ensuring responsive web interfaces. This mode is especially effective with CSS, + HTML, and modern frontend frameworks. # Optional but recommended + groups: # Required: array of tool groups (can be empty) + - read # Read files group (read_file, search_files, list_files, codebase_search) + - edit # Edit files group (apply_diff, write_to_file) - allows editing any file + # Or with file restrictions: + # - - edit + # - fileRegex: \.md$ + # description: Markdown files only # Edit group that only allows editing markdown files + - browser # Browser group (browser_action) + - command # Command group (execute_command) + - mcp # MCP group (use_mcp_tool, access_mcp_resource) + customInstructions: Additional instructions for the Designer mode # Optional diff --git a/src/services/skills/generate-built-in-skills.ts b/src/services/skills/generate-built-in-skills.ts new file mode 100644 index 0000000000..517040c010 --- /dev/null +++ b/src/services/skills/generate-built-in-skills.ts @@ -0,0 +1,300 @@ +#!/usr/bin/env tsx +/** + * Build script to generate built-in-skills.ts from SKILL.md files. + * + * This script scans the built-in/ directory for skill folders, parses each + * SKILL.md file using gray-matter, validates the frontmatter, and generates + * the built-in-skills.ts file. + * + * Run with: npx tsx src/services/skills/generate-built-in-skills.ts + */ + +import * as fs from "fs/promises" +import * as path from "path" +import { execSync } from "child_process" +import matter from "gray-matter" + +const BUILT_IN_DIR = path.join(__dirname, "built-in") +const OUTPUT_FILE = path.join(__dirname, "built-in-skills.ts") + +interface SkillData { + name: string + description: string + instructions: string +} + +interface ValidationError { + skillDir: string + errors: string[] +} + +/** + * Validate a skill name according to Agent Skills spec: + * - 1-64 characters + * - lowercase letters, numbers, and hyphens only + * - must not start/end with hyphen + * - must not contain consecutive hyphens + */ +function validateSkillName(name: string): string[] { + const errors: string[] = [] + + if (name.length < 1 || name.length > 64) { + errors.push(`Name must be 1-64 characters (got ${name.length})`) + } + + const nameFormat = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + if (!nameFormat.test(name)) { + errors.push( + "Name must be lowercase letters/numbers/hyphens only (no leading/trailing hyphen, no consecutive hyphens)", + ) + } + + return errors +} + +/** + * Validate a skill description: + * - 1-1024 characters (after trimming) + */ +function validateDescription(description: string): string[] { + const errors: string[] = [] + const trimmed = description.trim() + + if (trimmed.length < 1 || trimmed.length > 1024) { + errors.push(`Description must be 1-1024 characters (got ${trimmed.length})`) + } + + return errors +} + +/** + * Parse and validate a single SKILL.md file + */ +async function parseSkillFile( + skillDir: string, + dirName: string, +): Promise<{ skill?: SkillData; errors?: ValidationError }> { + const skillMdPath = path.join(skillDir, "SKILL.md") + + try { + const fileContent = await fs.readFile(skillMdPath, "utf-8") + const { data: frontmatter, content: body } = matter(fileContent) + + const errors: string[] = [] + + // Validate required fields + if (!frontmatter.name || typeof frontmatter.name !== "string") { + errors.push("Missing required 'name' field in frontmatter") + } + if (!frontmatter.description || typeof frontmatter.description !== "string") { + errors.push("Missing required 'description' field in frontmatter") + } + + if (errors.length > 0) { + return { errors: { skillDir, errors } } + } + + // Validate name matches directory name + if (frontmatter.name !== dirName) { + errors.push(`Frontmatter name "${frontmatter.name}" doesn't match directory name "${dirName}"`) + } + + // Validate name format + errors.push(...validateSkillName(dirName)) + + // Validate description + errors.push(...validateDescription(frontmatter.description)) + + if (errors.length > 0) { + return { errors: { skillDir, errors } } + } + + return { + skill: { + name: frontmatter.name, + description: frontmatter.description.trim(), + instructions: body.trim(), + }, + } + } catch (error) { + return { + errors: { + skillDir, + errors: [`Failed to read or parse SKILL.md: ${error instanceof Error ? error.message : String(error)}`], + }, + } + } +} + +/** + * Escape a string for use in TypeScript template literal + */ +function escapeForTemplateLiteral(str: string): string { + return str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${") +} + +/** + * Generate the TypeScript code for built-in-skills.ts + */ +function generateTypeScript(skills: Record): string { + const skillEntries = Object.entries(skills) + .map(([key, skill]) => { + const escapedInstructions = escapeForTemplateLiteral(skill.instructions) + return `\t"${key}": { + name: "${skill.name}", + description: "${skill.description.replace(/"/g, '\\"')}", + instructions: \`${escapedInstructions}\`, + }` + }) + .join(",\n") + + return `/** + * AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY + * + * This file is generated by generate-built-in-skills.ts from the SKILL.md files + * in the built-in/ directory. To modify built-in skills, edit the corresponding + * SKILL.md file and run: pnpm generate:skills + */ + +import { SkillMetadata, SkillContent } from "../../shared/skills" + +interface BuiltInSkillDefinition { + name: string + description: string + instructions: string +} + +const BUILT_IN_SKILLS: Record = { +${skillEntries} +} + +/** + * Get all built-in skills as SkillMetadata objects + */ +export function getBuiltInSkills(): SkillMetadata[] { + return Object.values(BUILT_IN_SKILLS).map((skill) => ({ + name: skill.name, + description: skill.description, + path: "built-in", + source: "built-in" as const, + })) +} + +/** + * Get a specific built-in skill's full content by name + */ +export function getBuiltInSkillContent(name: string): SkillContent | null { + const skill = BUILT_IN_SKILLS[name] + if (!skill) return null + + return { + name: skill.name, + description: skill.description, + path: "built-in", + source: "built-in" as const, + instructions: skill.instructions, + } +} + +/** + * Check if a skill name is a built-in skill + */ +export function isBuiltInSkill(name: string): boolean { + return name in BUILT_IN_SKILLS +} + +/** + * Get names of all built-in skills + */ +export function getBuiltInSkillNames(): string[] { + return Object.keys(BUILT_IN_SKILLS) +} +` +} + +async function main() { + console.log("Generating built-in skills from SKILL.md files...") + + // Check if built-in directory exists + try { + await fs.access(BUILT_IN_DIR) + } catch { + console.error(`Error: Built-in skills directory not found: ${BUILT_IN_DIR}`) + process.exit(1) + } + + // Scan for skill directories + const entries = await fs.readdir(BUILT_IN_DIR) + const skills: Record = {} + const validationErrors: ValidationError[] = [] + + for (const entry of entries) { + const skillDir = path.join(BUILT_IN_DIR, entry) + const stats = await fs.stat(skillDir) + + if (!stats.isDirectory()) { + continue + } + + // Check if SKILL.md exists + const skillMdPath = path.join(skillDir, "SKILL.md") + try { + await fs.access(skillMdPath) + } catch { + console.warn(`Warning: No SKILL.md found in ${entry}, skipping`) + continue + } + + const result = await parseSkillFile(skillDir, entry) + + if (result.errors) { + validationErrors.push(result.errors) + } else if (result.skill) { + skills[entry] = result.skill + console.log(` ✓ Parsed ${entry}`) + } + } + + // Report validation errors + if (validationErrors.length > 0) { + console.error("\nValidation errors:") + for (const { skillDir, errors } of validationErrors) { + console.error(`\n ${path.basename(skillDir)}:`) + for (const error of errors) { + console.error(` - ${error}`) + } + } + process.exit(1) + } + + // Check if any skills were found + if (Object.keys(skills).length === 0) { + console.error("Error: No valid skills found in built-in directory") + process.exit(1) + } + + // Generate TypeScript + const output = generateTypeScript(skills) + + // Write output file + await fs.writeFile(OUTPUT_FILE, output, "utf-8") + + // Format with prettier to ensure stable output + // Run from workspace root (3 levels up from src/services/skills/) to find .prettierrc.json + const workspaceRoot = path.resolve(__dirname, "..", "..", "..") + try { + execSync(`npx prettier --write "${OUTPUT_FILE}"`, { + cwd: workspaceRoot, + stdio: "pipe", + }) + console.log(`\n✓ Generated and formatted ${OUTPUT_FILE}`) + } catch { + console.log(`\n✓ Generated ${OUTPUT_FILE} (prettier not available)`) + } + console.log(` Skills: ${Object.keys(skills).join(", ")}`) +} + +main().catch((error) => { + console.error("Fatal error:", error) + process.exit(1) +}) diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index dd05b1e1c6..8d82b72f75 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -27,7 +27,7 @@ export const experimentConfigsMap: Record = { SMART_MISTAKE_DETECTION: { enabled: true }, ALWAYS_INCLUDE_FILE_DETAILS: { enabled: undefined }, COMMIT_REVIEW: { enabled: undefined }, - USE_LITE_PROMPTS: { enabled: true }, + USE_LITE_PROMPTS: { enabled: false }, PREVENT_FOCUS_DISRUPTION: { enabled: false }, IMAGE_GENERATION: { enabled: false }, RUN_SLASH_COMMAND: { enabled: false }, diff --git a/src/shared/skills.ts b/src/shared/skills.ts index 7ed85816aa..ae35b8c387 100644 --- a/src/shared/skills.ts +++ b/src/shared/skills.ts @@ -5,8 +5,8 @@ export interface SkillMetadata { name: string // Required: skill identifier description: string // Required: when to use this skill - path: string // Absolute path to SKILL.md - source: "global" | "project" // Where the skill was discovered + path: string // Absolute path to SKILL.md (or "" for built-in skills) + source: "global" | "project" | "built-in" // Where the skill was discovered mode?: string // If set, skill is only available in this mode } diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 6ee516dcc4..baae077ccc 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -5,7 +5,6 @@ import type { ToolProgressStatus, ToolGroup, ToolName, - FileEntry, BrowserActionParams, GenerateImageParams, } from "@roo-code/types" @@ -60,13 +59,13 @@ export const toolParamNames = [ "size", "query", "args", + "skill", // skill tool parameter "start_line", "end_line", "todos", "prompt", "image", - "line_ranges", - "files", // Native protocol parameter for read_file + // read_file parameters (native protocol) "operations", // search_and_replace parameter for multiple operations "patch", // apply_patch parameter "title", // ask_multiple_choice parameter @@ -77,8 +76,18 @@ export const toolParamNames = [ "expected_replacements", // edit_file parameter for multiple occurrences "artifact_id", // read_command_output parameter "search", // read_command_output parameter for grep-like search - "offset", // read_command_output parameter for pagination - "limit", // read_command_output parameter for max bytes to return + "offset", // read_command_output and read_file parameter + "limit", // read_command_output and read_file parameter + // read_file indentation mode parameters + "indentation", + "anchor_line", + "max_levels", + "include_siblings", + "include_header", + "max_lines", + // read_file legacy format parameter (backward compatibility) + "files", + "line_ranges", ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -89,7 +98,7 @@ export type ToolParamName = (typeof toolParamNames)[number] */ export type NativeToolArgs = { access_mcp_resource: { server_name: string; uri: string } - read_file: { files: FileEntry[] } + read_file: import("@roo-code/types").ReadFileToolParams read_command_output: { artifact_id: string; search?: string; offset?: number; limit?: number } attempt_completion: { result: string } execute_command: { command: string; cwd?: string } @@ -115,9 +124,9 @@ export type NativeToolArgs = { } browser_action: BrowserActionParams codebase_search: { query: string; path?: string } - fetch_instructions: { task: string } generate_image: GenerateImageParams run_slash_command: { command: string; args?: string } + skill: { skill: string; args?: string | null } search_files: { path: string; regex: string; file_pattern?: string | null } switch_mode: { mode_slug: string; reason: string } update_todo_list: { todos: string } @@ -146,6 +155,11 @@ export interface ToolUse { partial: boolean // nativeArgs is properly typed based on TName if it's in NativeToolArgs, otherwise never nativeArgs?: TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + /** + * Flag indicating whether the tool call used a legacy/deprecated format. + * Used for telemetry tracking to monitor migration from old formats. + */ + usedLegacyFormat?: boolean } /** @@ -176,12 +190,23 @@ export interface ExecuteCommandToolUse extends ToolUse<"execute_command"> { export interface ReadFileToolUse extends ToolUse<"read_file"> { name: "read_file" - params: Partial, "args" | "path" | "start_line" | "end_line" | "files">> -} - -export interface FetchInstructionsToolUse extends ToolUse<"fetch_instructions"> { - name: "fetch_instructions" - params: Partial, "task">> + params: Partial< + Pick< + Record, + | "args" + | "path" + | "start_line" + | "end_line" + | "mode" + | "offset" + | "limit" + | "indentation" + | "anchor_line" + | "max_levels" + | "include_siblings" + | "include_header" + > + > } export interface WriteToFileToolUse extends ToolUse<"write_to_file"> { @@ -249,6 +274,11 @@ export interface RunSlashCommandToolUse extends ToolUse<"run_slash_command"> { params: Partial, "command" | "args">> } +export interface SkillToolUse extends ToolUse<"skill"> { + name: "skill" + params: Partial, "skill" | "args">> +} + export interface GenerateImageToolUse extends ToolUse<"generate_image"> { name: "generate_image" params: Partial, "prompt" | "path" | "image">> @@ -265,7 +295,6 @@ export const TOOL_DISPLAY_NAMES: Record = { execute_command: "run commands", read_file: "read files", read_command_output: "read command output", - fetch_instructions: "fetch instructions", write_to_file: "write files", apply_diff: "apply changes", search_and_replace: "apply changes using search and replace", @@ -285,6 +314,7 @@ export const TOOL_DISPLAY_NAMES: Record = { codebase_search: "codebase search", update_todo_list: "update todo list", run_slash_command: "run slash command", + skill: "load skill", generate_image: "generate images", custom_tool: "use custom tools", fake_tool_call: "use tool calls", @@ -293,14 +323,7 @@ export const TOOL_DISPLAY_NAMES: Record = { // Define available tool groups. export const TOOL_GROUPS: Record = { read: { - tools: [ - "ask_multiple_choice", - "read_file", - "fetch_instructions", - "search_files", - "list_files", - "codebase_search", - ], + tools: ["ask_multiple_choice", "read_file", "search_files", "list_files", "codebase_search"], }, edit: { tools: ["apply_diff", "write_to_file", "generate_image"], @@ -330,6 +353,7 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [ "new_task", "update_todo_list", "run_slash_command", + "skill", ] as const /** diff --git a/src/utils/__tests__/json-schema.spec.ts b/src/utils/__tests__/json-schema.spec.ts index c939095340..6f2096e626 100644 --- a/src/utils/__tests__/json-schema.spec.ts +++ b/src/utils/__tests__/json-schema.spec.ts @@ -86,9 +86,9 @@ describe("normalizeToolSchema", () => { type: "object", properties: { path: { type: "string" }, - line_ranges: { + tags: { type: ["array", "null"], - items: { type: "integer" }, + items: { type: "string" }, }, }, }, @@ -104,8 +104,8 @@ describe("normalizeToolSchema", () => { type: "object", properties: { path: { type: "string" }, - line_ranges: { - anyOf: [{ type: "array", items: { type: "integer" } }, { type: "null" }], + tags: { + anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }], }, }, additionalProperties: false, @@ -123,7 +123,7 @@ describe("normalizeToolSchema", () => { type: "object", properties: { path: { type: "string" }, - line_ranges: { + ranges: { type: ["array", "null"], items: { type: "array", @@ -131,7 +131,7 @@ describe("normalizeToolSchema", () => { }, }, }, - required: ["path", "line_ranges"], + required: ["path", "ranges"], }, }, }, @@ -144,7 +144,7 @@ describe("normalizeToolSchema", () => { const filesItems = properties.files.items as Record const filesItemsProps = filesItems.properties as Record> // Array-specific properties (items) should be moved inside the array variant - expect(filesItemsProps.line_ranges.anyOf).toEqual([ + expect(filesItemsProps.ranges.anyOf).toEqual([ { type: "array", items: { type: "array", items: { type: "integer" } } }, { type: "null" }, ]) @@ -224,60 +224,32 @@ describe("normalizeToolSchema", () => { const input = { type: "object", properties: { - files: { - type: "array", - description: "List of files to read", - items: { - type: "object", - properties: { - path: { - type: "string", - description: "Path to the file", - }, - line_ranges: { - type: ["array", "null"], - description: "Optional line ranges", - items: { - type: "array", - items: { type: "integer" }, - minItems: 2, - maxItems: 2, - }, - }, + path: { + type: "string", + description: "Path to the file", + }, + indentation: { + type: ["object", "null"], + properties: { + anchor_line: { + type: ["integer", "null"], }, - required: ["path", "line_ranges"], - additionalProperties: false, }, - minItems: 1, }, }, - required: ["files"], + required: ["path"], additionalProperties: false, } const result = normalizeToolSchema(input) - // Verify the line_ranges was transformed with items inside the array variant - const files = (result.properties as Record).files as Record - const items = files.items as Record - const props = items.properties as Record> - // Array-specific properties (items, minItems, maxItems) should be moved inside the array variant - expect(props.line_ranges.anyOf).toEqual([ - { - type: "array", - items: { - type: "array", - items: { type: "integer" }, - minItems: 2, - maxItems: 2, - }, - }, - { type: "null" }, - ]) - // items should NOT be at root level anymore - expect(props.line_ranges.items).toBeUndefined() - // Other properties are preserved at root level - expect(props.line_ranges.description).toBe("Optional line ranges") + // Verify nested nullable objects are transformed correctly + const props = result.properties as Record> + expect(props.indentation.anyOf).toEqual([{ type: "object" }, { type: "null" }]) + expect(props.indentation.additionalProperties).toBe(false) + expect((props.indentation.properties as Record).anchor_line).toEqual({ + anyOf: [{ type: "integer" }, { type: "null" }], + }) }) describe("format field handling", () => { diff --git a/src/utils/__tests__/tool-id.spec.ts b/src/utils/__tests__/tool-id.spec.ts index c047184417..2459786cea 100644 --- a/src/utils/__tests__/tool-id.spec.ts +++ b/src/utils/__tests__/tool-id.spec.ts @@ -47,6 +47,14 @@ describe("sanitizeToolUseId", () => { it("should replace multiple invalid characters", () => { expect(sanitizeToolUseId("mcp.server:tool/name")).toBe("mcp_server_tool_name") }) + + it("should sanitize Gemini/OpenRouter function call IDs with dots and colons", () => { + // This is the exact pattern seen in PostHog errors where tool_result IDs + // didn't match tool_use IDs due to missing sanitization + expect(sanitizeToolUseId("functions.read_file:0")).toBe("functions_read_file_0") + expect(sanitizeToolUseId("functions.write_to_file:1")).toBe("functions_write_to_file_1") + expect(sanitizeToolUseId("read_file:0")).toBe("read_file_0") + }) }) describe("real-world MCP tool use ID patterns", () => { diff --git a/src/utils/encoding.ts b/src/utils/encoding.ts index 29678f50b9..11f5ea05c5 100644 --- a/src/utils/encoding.ts +++ b/src/utils/encoding.ts @@ -4,6 +4,7 @@ import { isBinaryFile } from "isbinaryfile" import fs from "fs/promises" import path from "path" import { createLogger } from "./logger" +import { getSupportedBinaryFormats } from "../integrations/misc/extract-text" // Common binary file extension list export const BINARY_EXTENSIONS = new Set([ @@ -154,7 +155,7 @@ export async function detectEncoding(fileBuffer: Buffer, fileExtension?: string, } } else { // 3. Only check if it's a binary file when encoding detection fails - if (fileExtension) { + if (fileExtension && !getSupportedBinaryFormats().includes(fileExtension)) { const isBinary = await isBinaryFile(fileBuffer).catch(() => false) if (isBinary) { throw new Error(`Cannot read text for file type: ${fileExtension}`) @@ -216,6 +217,7 @@ export async function detectFileEncoding(filePath: string): Promise { export async function isBinaryFileWithEncodingDetection(filePath: string, size?: number): Promise { try { const fileExtension = path.extname(filePath).toLowerCase() + if (getSupportedBinaryFormats().includes(fileExtension)) return false // 1. First check file extension if (BINARY_EXTENSIONS.has(fileExtension)) { return true diff --git a/webview-ui/src/components/chat/Announcement.tsx b/webview-ui/src/components/chat/Announcement.tsx index 7e13c34de6..a4538630fd 100644 --- a/webview-ui/src/components/chat/Announcement.tsx +++ b/webview-ui/src/components/chat/Announcement.tsx @@ -44,7 +44,9 @@ const Announcement = ({ hideAnnouncement }: AnnouncementProps) => {

{t("chat:announcement.release.heading")}

    -
  • {t("chat:announcement.release.smartCodeFolding")}
  • +
  • {t("chat:announcement.release.parallelTools")}
  • +
  • {t("chat:announcement.release.readFileIndentation")}
  • +
  • {t("chat:announcement.release.readCommandOutput")}
diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 0413eb55ec..2ed68133c4 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -851,13 +851,15 @@ export const ChatRowContent = ({ { + onClick={() => vscode.postMessage({ type: "openFile", text: tool.content, - values: { line: getJumpLine(tool)[0] || 0 }, + values: { + line: tool.startLine ? tool.startLine : getJumpLine(tool)[0] || 0, + }, }) - }}> + }> {tool.path?.startsWith(".") && .} @@ -874,24 +876,75 @@ export const ChatRowContent = ({
) - case "fetchInstructions": + case "skill": { + const skillInfo = tool return ( <>
- {toolIcon("file-code")} - {t("chat:instructions.wantsToFetch")} + {toolIcon("book")} + + {message.type === "ask" ? t("chat:skill.wantsToLoad") : t("chat:skill.didLoad")} +
-
- +
+ +
+ + {skillInfo.skill} + + {skillInfo.source && ( + + {skillInfo.source} + + )} +
+ +
+ {isExpanded && (skillInfo.args || skillInfo.description) && ( +
+ {skillInfo.description && ( +
+ {skillInfo.description} +
+ )} + {skillInfo.args && ( +
+ Arguments: + + {skillInfo.args} + +
+ )} +
+ )}
) + } case "listFilesTopLevel": return ( <> @@ -1383,7 +1436,7 @@ export const ChatRowContent = ({ {showSpeedInfo && tokensPerSecond !== undefined && (
+ title={t("chat:performance.tokensPerSecond", { time: tokensPerSecond })}> {t("chat:performance.tokensPerSecond", { time: tokensPerSecond })}
)} diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 8b29cde8fd..c850749308 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -1383,7 +1383,7 @@ export const ChatTextArea = forwardRef( disabled={!isStreaming && !hasInputContent} onClick={isStreaming ? onStop : onSend} className={cn( - "relative inline-flex items-center justify-center", + "relative inline-flex items-center justify-center right-[2px]", "bg-transparent border-none p-1.5", "rounded-full min-w-[20px] min-h-[20px]", "text-vscode-descriptionForeground hover:text-vscode-foreground", diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 85f0d428e9..de9722bfdc 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -79,6 +79,49 @@ export interface ChatViewRef { export const MAX_IMAGES_PER_MESSAGE = 20 // This is the Anthropic limit. +// Button text keys - store translation keys instead of translated values +// This ensures React Compiler doesn't cache stale translations +type PrimaryButtonKey = + | "chat:retry.title" + | "chat:proceedAnyways.title" + | "chat:save.title" + | "chat:completeSubtaskAndReturn" + | "chat:read-batch.approve.title" + | "chat:approve.title" + | "chat:runCommand.title" + | "chat:proceedWhileRunning.title" + | "chat:startNewTask.title" + | "chat:resumeTask.title" + +type SecondaryButtonKey = + | "chat:startNewTask.title" + | "chat:reject.title" + | "chat:read-batch.deny.title" + | "chat:terminate.title" + | "chat:killCommand.title" + +// Map primary button keys to their tooltip keys +const primaryButtonTooltipMap: Record = { + "chat:retry.title": "chat:retry.tooltip", + "chat:proceedAnyways.title": "chat:proceedAnyways.tooltip", + "chat:save.title": "chat:save.tooltip", + "chat:completeSubtaskAndReturn": undefined, + "chat:read-batch.approve.title": undefined, + "chat:approve.title": "chat:approve.tooltip", + "chat:runCommand.title": "chat:runCommand.tooltip", + "chat:proceedWhileRunning.title": "chat:proceedWhileRunning.tooltip", + "chat:startNewTask.title": "chat:startNewTask.tooltip", + "chat:resumeTask.title": "chat:resumeTask.tooltip", +} + +// Map secondary button keys to their tooltip keys +const secondaryButtonTooltipMap: Record = { + "chat:startNewTask.title": "chat:startNewTask.tooltip", + "chat:reject.title": "chat:reject.tooltip", + "chat:read-batch.deny.title": undefined, + "chat:terminate.title": "chat:terminate.tooltip", + "chat:killCommand.title": "chat:killCommand.tooltip", +} const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0 const ChatViewComponent: React.ForwardRefRenderFunction = ( @@ -181,8 +224,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction(undefined) const [enableButtons, setEnableButtons] = useState(false) - const [primaryButtonText, setPrimaryButtonText] = useState(undefined) - const [secondaryButtonText, setSecondaryButtonText] = useState(undefined) + const [primaryButtonTextKey, setPrimaryButtonText] = useState(undefined) + const [secondaryButtonTextKey, setSecondaryButtonText] = useState(undefined) const [_didClickCancel, setDidClickCancel] = useState(false) const virtuosoRef = useRef(null) const [expandedRows, setExpandedRows] = useState>({}) @@ -219,6 +262,21 @@ const ChatViewComponent: React.ForwardRefRenderFunction >(new Map()) + // Compute translated button text and tooltips at render time + // This ensures translations update correctly when language changes + const primaryButtonText = primaryButtonTextKey ? t(primaryButtonTextKey) : undefined + const secondaryButtonText = secondaryButtonTextKey ? t(secondaryButtonTextKey) : undefined + const primaryButtonTooltip = primaryButtonTextKey + ? primaryButtonTooltipMap[primaryButtonTextKey] + ? t(primaryButtonTooltipMap[primaryButtonTextKey]!) + : undefined + : undefined + const secondaryButtonTooltip = secondaryButtonTextKey + ? secondaryButtonTooltipMap[secondaryButtonTextKey] + ? t(secondaryButtonTooltipMap[secondaryButtonTextKey]!) + : undefined + : undefined + const clineAskRef = useRef(clineAsk) useEffect(() => { clineAskRef.current = clineAsk @@ -318,16 +376,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction msg.ask === "completion_result" || msg.say === "completion_result", ) if (isCompletedSubtask) { - setPrimaryButtonText(t("chat:startNewTask.title")) + setPrimaryButtonText("chat:startNewTask.title") setSecondaryButtonText(undefined) } else { - setPrimaryButtonText(t("chat:resumeTask.title")) - setSecondaryButtonText(t("chat:terminate.title")) + setPrimaryButtonText("chat:resumeTask.title") + setSecondaryButtonText("chat:terminate.title") } setDidClickCancel(false) // special case where we reset the cancel button state break @@ -448,7 +506,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction msg.ask === "completion_result" || msg.say === "completion_result", ) if (hasCompletionResult) { - setPrimaryButtonText(t("chat:startNewTask.title")) + setPrimaryButtonText("chat:startNewTask.title") setSecondaryButtonText(undefined) } } @@ -650,6 +708,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0) { try { vscode.postMessage({ type: "queueMessage", text, images }) @@ -1204,21 +1263,103 @@ const ChatViewComponent: React.ForwardRefRenderFunction { // Only filter out the launch ask and result messages - browser actions appear in chat - const result: ClineMessage[] = visibleMessages.filter((msg) => { - if (apiConfiguration?.apiProvider !== "zgsm") { - return !isBrowserSessionMessage(msg) + const filtered: ClineMessage[] = visibleMessages.filter((msg) => { + // Always filter browser session messages + if (isBrowserSessionMessage(msg)) { + return false } - if (msg.say === "error") return false + // Filter additional message types for zgsm provider + if (apiConfiguration?.apiProvider === "zgsm") { + // Filter error messages + if (msg.say === "error") return false - return ( - !isBrowserSessionMessage(msg) && - !msg?.metadata?.isRateLimitRetry && // Hide rate limit retries - !["condense_context_error", "shell_integration_warning"].includes(msg.say!) && // Hide shell integration warning - !(msg.type === "say" && msg.say === "reasoning" && !msg.text?.trim()) - ) // Hide empty reasoning messages + // Filter rate limit retries + if (msg?.metadata?.isRateLimitRetry) return false + + // Filter condense_context_error and shell_integration_warning + if (["condense_context_error", "shell_integration_warning"].includes(msg.say!)) { + return false + } + + // Filter empty reasoning messages + if (msg.type === "say" && msg.say === "reasoning" && !msg?.text?.trim()) { + return false + } + } + + return true }) + // Helper to check if a message is a read_file ask that should be batched + const isReadFileAsk = (msg: ClineMessage): boolean => { + if (msg.type !== "ask" || msg.ask !== "tool") return false + try { + const tool = JSON.parse(msg.text || "{}") + return tool.tool === "readFile" && !tool.batchFiles // Don't re-batch already batched + } catch { + return false + } + } + + // Consolidate consecutive read_file ask messages into batches + const result: ClineMessage[] = [] + let i = 0 + while (i < filtered.length) { + const msg = filtered[i] + + // Check if this starts a sequence of read_file asks + if (isReadFileAsk(msg)) { + // Collect all consecutive read_file asks + const batch: ClineMessage[] = [msg] + let j = i + 1 + while (j < filtered.length && isReadFileAsk(filtered[j])) { + batch.push(filtered[j]) + j++ + } + + if (batch.length > 1) { + // Create a synthetic batch message + const batchFiles = batch.map((batchMsg) => { + try { + const tool = JSON.parse(batchMsg.text || "{}") + return { + path: tool.path || "", + lineSnippet: tool.reason || "", + isOutsideWorkspace: tool.isOutsideWorkspace || false, + key: `${tool.path}${tool.reason ? ` (${tool.reason})` : ""}`, + content: tool.content || "", + } + } catch { + return { path: "", lineSnippet: "", key: "", content: "" } + } + }) + + // Use the first message as the base, but add batchFiles + const firstTool = JSON.parse(msg.text || "{}") + const syntheticMessage: ClineMessage = { + ...msg, + text: JSON.stringify({ + ...firstTool, + batchFiles, + }), + // Store original messages for response handling + _batchedMessages: batch, + } as ClineMessage & { _batchedMessages: ClineMessage[] } + + result.push(syntheticMessage) + i = j // Skip past all batched messages + } else { + // Single read_file ask, keep as-is + result.push(msg) + i++ + } + } else { + result.push(msg) + i++ + } + } + if (isCondensing) { result.push({ type: "say", @@ -1603,9 +1744,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction ({ acceptInput: () => { + const hasInput = inputValue?.trim() || selectedImages?.length > 0 + + // // Special case: during command_output, queue the message instead of + // // triggering the primary button action (which would lose the message) + // if (clineAskRef.current === "command_output" && hasInput) { + // vscode.postMessage({ type: "queueMessage", text: inputValue.trim(), images: selectedImages }) + // setInputValue("") + // setSelectedImages([]) + // return + // } + if (enableButtons && primaryButtonText) { handlePrimaryButtonClick(inputValue, selectedImages) - } else if (!sendingDisabled && !isProfileDisabled && (inputValue?.trim() || selectedImages.length > 0)) { + } else if (!sendingDisabled && !isProfileDisabled && hasInput) { handleSendMessage(inputValue, selectedImages) } }, @@ -1820,28 +1972,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction {primaryButtonText && ( - + */}
-
- {/* {t("mcp:learnMoreEditingSettings")} - */} -
+ +
*/} )} diff --git a/webview-ui/src/components/settings/ContextManagementSettings.tsx b/webview-ui/src/components/settings/ContextManagementSettings.tsx index 440993f099..f99d86d995 100644 --- a/webview-ui/src/components/settings/ContextManagementSettings.tsx +++ b/webview-ui/src/components/settings/ContextManagementSettings.tsx @@ -34,11 +34,8 @@ type ContextManagementSettingsProps = HTMLAttributes & { maxWorkspaceFiles: number showRooIgnoredFiles?: boolean enableSubfolderRules?: boolean - maxReadFileLine?: number - maxReadCharacterLimit?: number maxImageFileSize?: number maxTotalImageSize?: number - maxConcurrentFileReads?: number profileThresholds?: Record includeDiagnosticMessages?: boolean maxDiagnosticMessages?: number @@ -55,11 +52,8 @@ type ContextManagementSettingsProps = HTMLAttributes & { | "maxWorkspaceFiles" | "showRooIgnoredFiles" | "enableSubfolderRules" - | "maxReadFileLine" - | "maxReadCharacterLimit" | "maxImageFileSize" | "maxTotalImageSize" - | "maxConcurrentFileReads" | "profileThresholds" | "includeDiagnosticMessages" | "maxDiagnosticMessages" @@ -80,11 +74,8 @@ export const ContextManagementSettings = ({ showRooIgnoredFiles, enableSubfolderRules, setCachedStateField, - maxReadFileLine, - maxReadCharacterLimit, maxImageFileSize, maxTotalImageSize, - maxConcurrentFileReads, profileThresholds = {}, includeDiagnosticMessages, maxDiagnosticMessages, @@ -223,29 +214,6 @@ export const ContextManagementSettings = ({
- - - {t("settings:contextManagement.maxConcurrentFileReads.label")} - -
- setCachedStateField("maxConcurrentFileReads", value)} - data-testid="max-concurrent-file-reads-slider" - /> - {Math.max(1, maxConcurrentFileReads ?? 5)} -
-
- {t("settings:contextManagement.maxConcurrentFileReads.description")} -
-
- - - -
- setCachedStateField("maxReadCharacterLimit", value)} - data-testid="terminal-output-character-limit-slider" - /> - {maxReadCharacterLimit ?? 30000} -
-
- {t("settings:contextManagement.maxReadCharacter.description")} -
-
- - -
- {t("settings:contextManagement.maxReadFile.label")} -
- { - const newValue = parseInt(e.target.value, 10) - if (!isNaN(newValue) && newValue >= -1) { - setCachedStateField("maxReadFileLine", newValue) - } - }} - onClick={(e) => e.currentTarget.select()} - data-testid="max-read-file-line-input" - disabled={maxReadFileLine === -1} - /> - {t("settings:contextManagement.maxReadFile.lines")} - { - setCachedStateField("maxReadFileLine", e.target.checked ? -1 : 500) - }} - data-testid="max-read-file-always-full-checkbox"> - {t("settings:contextManagement.maxReadFile.always_full_read")} - -
-
-
- {t("settings:contextManagement.maxReadFile.description")} -
-
- (({ onDone, t ttsSpeed, soundVolume, telemetrySetting, - maxReadCharacterLimit, terminalOutputPreviewSize, terminalShellIntegrationTimeout, terminalShellIntegrationDisabled, // Added from upstream @@ -206,10 +205,8 @@ const SettingsView = forwardRef(({ onDone, t showRooIgnoredFiles, enableSubfolderRules, remoteBrowserEnabled, - maxReadFileLine, maxImageFileSize, maxTotalImageSize, - maxConcurrentFileReads, customSupportPrompts, profileThresholds, alwaysAllowFollowupQuestions, @@ -439,10 +436,8 @@ const SettingsView = forwardRef(({ onDone, t maxWorkspaceFiles: Math.min(Math.max(0, maxWorkspaceFiles ?? 200), 500), showRooIgnoredFiles: showRooIgnoredFiles ?? true, enableSubfolderRules: enableSubfolderRules ?? false, - maxReadFileLine: maxReadFileLine ?? -1, maxImageFileSize: maxImageFileSize ?? 5, maxTotalImageSize: maxTotalImageSize ?? 20, - maxConcurrentFileReads: cachedState.maxConcurrentFileReads ?? 5, includeDiagnosticMessages: includeDiagnosticMessages !== undefined ? includeDiagnosticMessages : true, maxDiagnosticMessages: maxDiagnosticMessages ?? 50, @@ -467,7 +462,6 @@ const SettingsView = forwardRef(({ onDone, t customSupportPrompts, useZgsmCustomConfig: useZgsmCustomConfig ?? false, zgsmCodebaseIndexEnabled: zgsmCodebaseIndexEnabled ?? true, - maxReadCharacterLimit: maxReadCharacterLimit ?? 40000, autoCleanup, debug, }, @@ -928,12 +922,9 @@ const SettingsView = forwardRef(({ onDone, t maxWorkspaceFiles={maxWorkspaceFiles ?? 300} showRooIgnoredFiles={showRooIgnoredFiles} enableSubfolderRules={enableSubfolderRules} - maxReadFileLine={maxReadFileLine} - maxReadCharacterLimit={maxReadCharacterLimit} zgsmCodebaseIndexEnabled={zgsmCodebaseIndexEnabled ?? true} maxImageFileSize={maxImageFileSize} maxTotalImageSize={maxTotalImageSize} - maxConcurrentFileReads={maxConcurrentFileReads} profileThresholds={profileThresholds} includeDiagnosticMessages={includeDiagnosticMessages} maxDiagnosticMessages={maxDiagnosticMessages} diff --git a/webview-ui/src/components/settings/SkillItem.tsx b/webview-ui/src/components/settings/SkillItem.tsx index c1539d21b8..cd11f4553d 100644 --- a/webview-ui/src/components/settings/SkillItem.tsx +++ b/webview-ui/src/components/settings/SkillItem.tsx @@ -1,10 +1,17 @@ -import React from "react" +import React, { useCallback, useMemo } from "react" import { Edit, Trash2 } from "lucide-react" import type { SkillMetadata } from "@roo-code/types" +import { getAllModes } from "@roo/modes" + import { useAppTranslation } from "@/i18n/TranslationContext" -import { Button, StandardTooltip } from "@/components/ui" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { Button, StandardTooltip, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui" +import { vscode } from "@/utils/vscode" + +// Sentinel value for "Any mode" since Radix Select doesn't allow empty string values +const MODE_ANY = "__any__" interface SkillItemProps { skill: SkillMetadata @@ -14,6 +21,40 @@ interface SkillItemProps { export const SkillItem: React.FC = ({ skill, onEdit, onDelete }) => { const { t } = useAppTranslation() + const { customModes } = useExtensionState() + + // Get available modes for the dropdown (built-in + custom modes) + const availableModes = useMemo(() => { + return getAllModes(customModes).map((m) => ({ slug: m.slug, name: m.name })) + }, [customModes]) + + // Current mode value for the select (using sentinel for "Any mode") + const currentModeValue = skill.mode || MODE_ANY + + // Handle mode change + const handleModeChange = useCallback( + (newModeValue: string) => { + const newMode = newModeValue === MODE_ANY ? undefined : newModeValue + + // Don't do anything if mode hasn't changed + if (newMode === skill.mode) { + return + } + + // Send message to move skill to new mode + vscode.postMessage({ + type: "moveSkill", + skillName: skill.name, + source: skill.source, + skillMode: skill.mode, + newSkillMode: newMode, + }) + }, + [skill.name, skill.source, skill.mode], + ) + + // Built-in skills cannot change mode + const isBuiltIn = skill.source === "built-in" return (
@@ -21,17 +62,37 @@ export const SkillItem: React.FC = ({ skill, onEdit, onDelete })
{skill.name} - {skill.mode && ( - - {skill.mode} - - )}
{skill.description && (
{skill.description}
)}
+ {/* Mode dropdown */} +
+ {isBuiltIn ? ( + + {skill.mode || t("settings:skills.modeAny")} + + ) : ( + + + + )} +
+ {/* Action buttons */}
@@ -45,16 +106,18 @@ export const SkillItem: React.FC = ({ skill, onEdit, onDelete }) - - - + {!isBuiltIn && ( + + + + )}
) diff --git a/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx index ed8b40e9a3..cf24d053d8 100644 --- a/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx @@ -93,8 +93,6 @@ describe("ContextManagementSettings", () => { maxOpenTabsContext: 20, maxWorkspaceFiles: 200, showRooIgnoredFiles: false, - maxReadFileLine: -1, - maxConcurrentFileReads: 5, profileThresholds: {}, includeDiagnosticMessages: true, maxDiagnosticMessages: 50, @@ -200,7 +198,6 @@ describe("ContextManagementSettings", () => { // Check for other sliders expect(screen.getByTestId("open-tabs-limit-slider")).toBeInTheDocument() expect(screen.getByTestId("workspace-files-limit-slider")).toBeInTheDocument() - expect(screen.getByTestId("max-concurrent-file-reads-slider")).toBeInTheDocument() // Check for checkboxes expect(screen.getByTestId("show-rooignored-files-checkbox")).toBeInTheDocument() @@ -321,50 +318,6 @@ describe("ContextManagementSettings", () => { }) }) - it("renders max read file line controls", () => { - const propsWithMaxReadFileLine = { - ...defaultProps, - maxReadFileLine: 500, - } - render() - - // Max read file line input - const maxReadFileInput = screen.getByTestId("max-read-file-line-input") - expect(maxReadFileInput).toBeInTheDocument() - expect(maxReadFileInput).toHaveValue(500) - - // Always full read checkbox - const alwaysFullReadCheckbox = screen.getByTestId("max-read-file-always-full-checkbox") - expect(alwaysFullReadCheckbox).toBeInTheDocument() - expect(alwaysFullReadCheckbox).not.toBeChecked() - }) - - it("updates max read file line setting", () => { - const propsWithMaxReadFileLine = { - ...defaultProps, - maxReadFileLine: 500, - } - render() - - const input = screen.getByTestId("max-read-file-line-input") - fireEvent.change(input, { target: { value: "1000" } }) - - expect(defaultProps.setCachedStateField).toHaveBeenCalledWith("maxReadFileLine", 1000) - }) - - it("toggles always full read setting", () => { - const propsWithMaxReadFileLine = { - ...defaultProps, - maxReadFileLine: 500, - } - render() - - const checkbox = screen.getByTestId("max-read-file-always-full-checkbox") - fireEvent.click(checkbox) - - expect(defaultProps.setCachedStateField).toHaveBeenCalledWith("maxReadFileLine", -1) - }) - it("renders with autoCondenseContext enabled", () => { const propsWithAutoCondense = { ...defaultProps, @@ -441,18 +394,6 @@ describe("ContextManagementSettings", () => { }) }) - it("renders max read file line controls with -1 value", () => { - const propsWithMaxReadFileLine = { - ...defaultProps, - maxReadFileLine: -1, - } - render() - - const checkbox = screen.getByTestId("max-read-file-always-full-checkbox") - const input = checkbox.querySelector('input[type="checkbox"]') - expect(input).toBeChecked() - }) - it("handles boundary values for sliders", () => { const mockSetCachedStateField = vitest.fn() const props = { @@ -479,7 +420,6 @@ describe("ContextManagementSettings", () => { const propsWithUndefined = { ...defaultProps, showRooIgnoredFiles: undefined, - maxReadFileLine: undefined, } expect(() => { @@ -502,24 +442,6 @@ describe("ContextManagementSettings", () => { // When auto condense is false, threshold slider should not be visible expect(screen.queryByTestId("condense-threshold-slider")).not.toBeInTheDocument() }) - - it("renders max read file controls with default value when maxReadFileLine is undefined", () => { - const propsWithoutMaxReadFile = { - ...defaultProps, - maxReadFileLine: undefined, - } - render() - - // Controls should still be rendered with default value of -1 - const input = screen.getByTestId("max-read-file-line-input") - const checkbox = screen.getByTestId("max-read-file-always-full-checkbox") - - expect(input).toBeInTheDocument() - expect(input).toHaveValue(500) - expect(input).not.toBeDisabled() // Input is not disabled when maxReadFileLine is undefined (only when explicitly set to -1) - expect(checkbox).toBeInTheDocument() - expect(checkbox).not.toBeChecked() // Checkbox is not checked when maxReadFileLine is undefined (only when explicitly set to -1) - }) }) describe("Accessibility", () => { @@ -538,17 +460,11 @@ describe("ContextManagementSettings", () => { }) it("has proper test ids for all interactive elements", () => { - const propsWithMaxReadFile = { - ...defaultProps, - maxReadFileLine: 500, - } - render() + render() expect(screen.getByTestId("open-tabs-limit-slider")).toBeInTheDocument() expect(screen.getByTestId("workspace-files-limit-slider")).toBeInTheDocument() expect(screen.getByTestId("show-rooignored-files-checkbox")).toBeInTheDocument() - expect(screen.getByTestId("max-read-file-line-input")).toBeInTheDocument() - expect(screen.getByTestId("max-read-file-always-full-checkbox")).toBeInTheDocument() }) }) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx index 3ebe2b90d7..9b33ba711a 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx @@ -218,7 +218,6 @@ describe("SettingsView - Change Detection Fix", () => { maxReadFileLine: -1, maxImageFileSize: 5, maxTotalImageSize: 20, - maxConcurrentFileReads: 5, customCondensingPrompt: "", customSupportPrompts: {}, profileThresholds: {}, diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx index 0bf080e8cb..990117b8c1 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx @@ -223,7 +223,6 @@ describe("SettingsView - Unsaved Changes Detection", () => { maxReadFileLine: -1, maxImageFileSize: 5, maxTotalImageSize: 20, - maxConcurrentFileReads: 5, customCondensingPrompt: "", customSupportPrompts: {}, profileThresholds: {}, diff --git a/webview-ui/src/components/settings/__tests__/SkillItem.spec.tsx b/webview-ui/src/components/settings/__tests__/SkillItem.spec.tsx index 4c7eb6596c..a8406e6294 100644 --- a/webview-ui/src/components/settings/__tests__/SkillItem.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SkillItem.spec.tsx @@ -3,6 +3,7 @@ import { render, screen, fireEvent } from "@/utils/test-utils" import type { SkillMetadata } from "@roo-code/types" import { SkillItem } from "../SkillItem" +import { vscode } from "@/utils/vscode" // Mock vscode vi.mock("@/utils/vscode", () => ({ @@ -14,11 +15,35 @@ vi.mock("@/utils/vscode", () => ({ // Mock the translation hook vi.mock("@/i18n/TranslationContext", () => ({ useAppTranslation: () => ({ - t: (key: string) => key, + t: (key: string) => { + const translations: Record = { + "settings:skills.editSkill": "Edit skill", + "settings:skills.deleteSkill": "Delete skill", + "settings:skills.changeMode": "Change mode", + "settings:skills.modeAny": "Any mode", + } + return translations[key] || key + }, }), })) -// Mock UI components +// Mock getAllModes +vi.mock("@roo/modes", () => ({ + getAllModes: () => [ + { slug: "code", name: "💻 Code" }, + { slug: "architect", name: "🏗️ Architect" }, + { slug: "ask", name: "❓ Ask" }, + ], +})) + +// Mock useExtensionState +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + customModes: [], + }), +})) + +// Mock UI components - need to support Select components vi.mock("@/components/ui", () => ({ Button: ({ children, onClick, className, tabIndex }: any) => (
), + Select: ({ children, value, onValueChange }: any) => ( +
+ {children} + +
+ ), + SelectContent: ({ children }: any) =>
{children}
, + SelectItem: ({ children, value }: any) => ( +
+ {children} +
+ ), + SelectTrigger: ({ children, className }: any) => ( + + ), + SelectValue: () => Value, })) const mockSkill: SkillMetadata = { @@ -47,6 +92,13 @@ const mockSkillWithMode: SkillMetadata = { mode: "architect", } +const mockBuiltInSkill: SkillMetadata = { + name: "built-in-skill", + description: "A built-in skill", + path: "", + source: "built-in", +} + describe("SkillItem", () => { const mockOnEdit = vi.fn() const mockOnDelete = vi.fn() @@ -67,18 +119,33 @@ describe("SkillItem", () => { expect(screen.getByText("A test skill description")).toBeInTheDocument() }) - it("renders mode badge when skill has mode", () => { + it("renders mode dropdown for non-built-in skills", () => { + render() + + expect(screen.getByTestId("select")).toBeInTheDocument() + }) + + it("renders mode dropdown with correct current value", () => { render() - expect(screen.getByText("architect")).toBeInTheDocument() + const select = screen.getByTestId("select") + expect(select).toHaveAttribute("data-value", "architect") }) - it("does not render mode badge when skill has no mode", () => { + it("renders mode dropdown with __any__ for skills without mode", () => { render() - // Should not have any mode badge - const container = screen.getByText("test-skill").parentElement - expect(container?.querySelector(".bg-vscode-badge-background")).toBeNull() + const select = screen.getByTestId("select") + expect(select).toHaveAttribute("data-value", "__any__") + }) + + it("does not render mode dropdown for built-in skills", () => { + render() + + // Should not have a select element + expect(screen.queryByTestId("select")).not.toBeInTheDocument() + // Should have a static badge instead + expect(screen.getByText("Any mode")).toBeInTheDocument() }) it("calls onEdit when edit button is clicked", () => { @@ -91,16 +158,24 @@ describe("SkillItem", () => { expect(mockOnEdit).toHaveBeenCalledTimes(1) }) - it("calls onDelete when delete button is clicked", () => { + it("calls onDelete when delete button is clicked for non-built-in skills", () => { render() const buttons = screen.getAllByTestId("button") - // Second button is delete + // Find the delete button (second one for non-built-in) fireEvent.click(buttons[1]) expect(mockOnDelete).toHaveBeenCalledTimes(1) }) + it("does not render delete button for built-in skills", () => { + render() + + // Should only have 1 button (edit) for built-in skills + const buttons = screen.getAllByTestId("button") + expect(buttons).toHaveLength(1) + }) + it("calls onEdit when clicking on skill name area", () => { render() @@ -110,6 +185,38 @@ describe("SkillItem", () => { expect(mockOnEdit).toHaveBeenCalledTimes(1) }) + it("sends moveSkill message when mode is changed", () => { + render() + + // Simulate mode change + const changeButton = screen.getByTestId("select-change-button") + fireEvent.click(changeButton) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "moveSkill", + skillName: "test-skill", + source: "project", + skillMode: undefined, + newSkillMode: "code", + }) + }) + + it("sends moveSkill message with correct current mode", () => { + render() + + // Simulate mode change + const changeButton = screen.getByTestId("select-change-button") + fireEvent.click(changeButton) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "moveSkill", + skillName: "mode-specific-skill", + source: "global", + skillMode: "architect", + newSkillMode: "code", + }) + }) + it("renders without description when not provided", () => { const skillWithoutDescription: SkillMetadata = { name: "no-desc-skill", @@ -132,10 +239,19 @@ describe("SkillItem", () => { expect(itemDiv).toHaveClass("hover:bg-vscode-list-hoverBackground") }) - it("renders both edit and delete buttons", () => { + it("renders edit and delete buttons for non-built-in skills", () => { render() const buttons = screen.getAllByTestId("button") expect(buttons).toHaveLength(2) }) + + it("includes available modes in the dropdown", () => { + render() + + // Check that select items are rendered + const selectItems = screen.getAllByTestId("select-item") + // Should have "Any mode" + 3 modes (code, architect, ask) + expect(selectItems).toHaveLength(4) + }) }) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index b2f522eb37..85eae42bf2 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -54,7 +54,6 @@ export interface ExtensionStateContextType extends ExtensionState { cloudOrganizations?: CloudOrganizationMembership[] sharingEnabled: boolean publicSharingEnabled: boolean - maxConcurrentFileReads?: number mdmCompliant?: boolean hasOpenedModeSelector: boolean // New property to track if user has opened mode selector hasClosedCodeReviewWelcomeTips: boolean // Track if user has dismissed code review welcome tips @@ -105,16 +104,12 @@ export interface ExtensionStateContextType extends ExtensionState { setCheckpointTimeout: (value: number) => void setBrowserViewportSize: (value: string) => void setWriteDelayMs: (value: number) => void - maxReadCharacterLimit?: number - setMaxReadCharacterLimit: (value: number) => void screenshotQuality?: number setScreenshotQuality: (value: number) => void terminalOutputPreviewSize?: "small" | "medium" | "large" setTerminalOutputPreviewSize: (value: "small" | "medium" | "large") => void mcpEnabled: boolean setMcpEnabled: (value: boolean) => void - enableMcpServerCreation: boolean - setEnableMcpServerCreation: (value: boolean) => void remoteControlEnabled: boolean setRemoteControlEnabled: (value: boolean) => void taskSyncEnabled: boolean @@ -142,8 +137,6 @@ export interface ExtensionStateContextType extends ExtensionState { setRemoteBrowserEnabled: (value: boolean) => void awsUsePromptCache?: boolean setAwsUsePromptCache: (value: boolean) => void - maxReadFileLine: number - setMaxReadFileLine: (value: number) => void maxImageFileSize: number setMaxImageFileSize: (value: number) => void maxTotalImageSize: number @@ -248,10 +241,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode writeDelayMs: 1000, browserViewportSize: "900x600", screenshotQuality: 75, - maxReadCharacterLimit: 40000, terminalShellIntegrationTimeout: 4000, mcpEnabled: true, - enableMcpServerCreation: false, remoteControlEnabled: false, taskSyncEnabled: false, featureRoomoteControlEnabled: false, @@ -275,12 +266,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode showRooIgnoredFiles: true, // Default to showing .rooignore'd files with lock symbol (current behavior). enableSubfolderRules: false, // Default to disabled - must be enabled to load rules from subdirectories renderContext: "sidebar", - maxReadFileLine: 500, // Default max read file line limit maxImageFileSize: 5, // Default max image file size in MB maxTotalImageSize: 20, // Default max total image size in MB pinnedApiConfigs: {}, // Empty object for pinned API configs terminalZshOhMy: false, // Default Oh My Zsh integration setting - maxConcurrentFileReads: 5, // Default concurrent file reads terminalZshP10k: false, // Default Powerlevel10k integration setting terminalZdotdir: false, // Default ZDOTDIR handling setting historyPreviewCollapsed: false, // Initialize the new state (default to expanded) @@ -650,7 +639,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setState((prevState) => ({ ...prevState, browserViewportSize: value })), setWriteDelayMs: (value) => setState((prevState) => ({ ...prevState, writeDelayMs: value })), setScreenshotQuality: (value) => setState((prevState) => ({ ...prevState, screenshotQuality: value })), - setMaxReadCharacterLimit: (value) => setState((prevState) => ({ ...prevState, maxReadCharacterLimit: value })), setTerminalOutputPreviewSize: (value) => setState((prevState) => ({ ...prevState, terminalOutputPreviewSize: value })), setTerminalShellIntegrationTimeout: (value) => @@ -659,8 +647,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setState((prevState) => ({ ...prevState, terminalShellIntegrationDisabled: value })), setTerminalZdotdir: (value) => setState((prevState) => ({ ...prevState, terminalZdotdir: value })), setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })), - setEnableMcpServerCreation: (value) => - setState((prevState) => ({ ...prevState, enableMcpServerCreation: value })), setRemoteControlEnabled: (value) => setState((prevState) => ({ ...prevState, remoteControlEnabled: value })), setTaskSyncEnabled: (value) => setState((prevState) => ({ ...prevState, taskSyncEnabled: value }) as any), setFeatureRoomoteControlEnabled: (value) => @@ -683,7 +669,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setEnableSubfolderRules: (value) => setState((prevState) => ({ ...prevState, enableSubfolderRules: value })), setRemoteBrowserEnabled: (value) => setState((prevState) => ({ ...prevState, remoteBrowserEnabled: value })), setAwsUsePromptCache: (value) => setState((prevState) => ({ ...prevState, awsUsePromptCache: value })), - setMaxReadFileLine: (value) => setState((prevState) => ({ ...prevState, maxReadFileLine: value })), setMaxImageFileSize: (value) => setState((prevState) => ({ ...prevState, maxImageFileSize: value })), setMaxTotalImageSize: (value) => setState((prevState) => ({ ...prevState, maxTotalImageSize: value })), setPinnedApiConfigs: (value) => setState((prevState) => ({ ...prevState, pinnedApiConfigs: value })), diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index b0a7db750a..4904978132 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -187,7 +187,6 @@ describe("mergeExtensionState", () => { const baseState: ExtensionState = { version: "", mcpEnabled: false, - enableMcpServerCreation: false, clineMessages: [], taskHistory: [], shouldShowAnnouncement: false, @@ -203,7 +202,6 @@ describe("mergeExtensionState", () => { showRooIgnoredFiles: true, enableSubfolderRules: false, renderContext: "sidebar", - maxReadFileLine: 500, cloudUserInfo: null, organizationAllowList: { allowAll: true, providers: {} }, autoCondenseContext: true, diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 727a6011ea..71a7d795df 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -217,8 +217,9 @@ "description": "Older messages were removed from the conversation to stay within the context window limit. This is a fast but less context-preserving approach compared to condensation." } }, - "instructions": { - "wantsToFetch": "Roo wants to fetch detailed instructions to assist with the current task" + "skill": { + "wantsToLoad": "CoStrict wants to load a skill", + "didLoad": "CoStrict loaded a skill" }, "fileOperations": { "wantsToRead": "Roo wants to read this file", @@ -363,7 +364,9 @@ }, "release": { "heading": "What's New:", - "smartCodeFolding": "Smart Code Folding: Context condensation now preserves a lightweight map of your files: function signatures, class declarations, and type definitions. This provides better continuity after condensing and smarter edits when referencing previous work." + "parallelTools": "Parallel Tool Calls: Tools now execute in parallel by default, significantly speeding up multi-file operations and complex tasks.", + "readFileIndentation": "Smarter File Reading: New indentation mode extracts complete semantic code blocks (functions, classes) without mid-function truncation—ideal when targeting specific lines from search results.", + "readCommandOutput": "Lossless Terminal Output: New read_command_output tool retrieves full command output from truncated executions with pagination and regex filtering." }, "cloudAgents": { "heading": "New in the Cloud:", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index b3460ca557..1448223141 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -82,6 +82,8 @@ "addSkill": "Add Skill", "editSkill": "Edit skill", "deleteSkill": "Delete skill", + "changeMode": "Change mode", + "modeAny": "Any mode", "deleteDialog": { "title": "Delete Skill", "description": "Are you sure you want to delete the skill \"{{name}}\"? This action cannot be undone.", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index ba7ae66319..28867021c4 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -341,6 +341,10 @@ "description": "为保持在上下文窗口限制内,已从对话中移除较旧的消息。与压缩相比,这是一种快速但上下文保留较少的方法。" } }, + "skill": { + "wantsToLoad": "CoStrict 想要加载技能", + "didLoad": "CoStrict 加载了技能" + }, "followUpSuggest": { "copyToInput": "复制到输入框(或按住Shift点击)", "timerPrefix": "自动批准已启用。{{seconds}}秒后选择中…" @@ -357,7 +361,9 @@ }, "release": { "heading": "新增功能:", - "smartCodeFolding": "智能代码折叠:上下文压缩现在保留文件的轻量级映射——函数签名、类声明和类型定义。 这在压缩后提供更好的连续性,引用之前工作时编辑更聪明。" + "parallelTools": "并行工具调用:工具现在默认并行执行,大大加快多文件操作和复杂任务的速度。", + "readFileIndentation": "更智能的文件读取:新的缩进模式可提取完整的语义代码块(函数、类),无需中断函数——适合从搜索结果中指定特定行。", + "readCommandOutput": "无损终端输出:新的 read_command_output 工具可从截断的执行中检索完整的命令输出,并支持分页和正则表达式过滤。" }, "cloudAgents": { "heading": "云端新功能:", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 8abaf05f1a..e58b0db7b5 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -1072,6 +1072,8 @@ "addSkill": "添加技能", "editSkill": "编辑技能", "deleteSkill": "删除技能", + "changeMode": "更改模式", + "modeAny": "任意模式", "deleteDialog": { "title": "删除技能", "description": "您确定要删除技能\"{{name}}\"吗?此操作无法撤销。", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 21edb3e468..56acf1b22f 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -217,6 +217,10 @@ "description": "為保持在上下文視窗限制內,已從對話中移除較舊的訊息。與壓縮相比,這是一種快速但上下文保留較少的方法。" } }, + "skill": { + "wantsToLoad": "CoStrict 想要載入技能", + "didLoad": "CoStrict 載入了技能" + }, "instructions": { "wantsToFetch": "Roo 想要取得詳細指示以協助目前工作" }, @@ -363,7 +367,9 @@ }, "release": { "heading": "新增功能:", - "smartCodeFolding": "智慧代碼摺疊:上下文壓縮現保留檔案的輕量級對應圖——函數簽章、類別宣告和型別定義。 這提供壓縮後更佳的連續性,以及引用之前工作時更聰慧的編輯。" + "parallelTools": "平行工具呼叫:工具現在預設會平行執行,大幅加快多檔案作業和複雜工作的速度。", + "readFileIndentation": "更聰慧的檔案讀取:新的縮排模式可提取完整的語義程式碼區塊(函數、類別),無需中斷函數——適合從搜尋結果中特定行為目標。", + "readCommandOutput": "無損終端輸出:新的 read_command_output 工具可從截斷的執行中擷取完整的指令輸出,並支援分頁和規則運算式過濾。" }, "cloudAgents": { "heading": "雲端的新功能:", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 2499289468..ee7b4e26f0 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -1070,6 +1070,8 @@ "addSkill": "新增技能", "editSkill": "編輯技能", "deleteSkill": "刪除技能", + "changeMode": "變更模式", + "modeAny": "任意模式", "deleteDialog": { "title": "刪除技能", "description": "您確定要刪除技能「{{name}}」嗎?此動作無法復原。", diff --git a/webview-ui/src/utils/formatPathTooltip.ts b/webview-ui/src/utils/formatPathTooltip.ts index cfe0b54a7f..aeaafe6cb4 100644 --- a/webview-ui/src/utils/formatPathTooltip.ts +++ b/webview-ui/src/utils/formatPathTooltip.ts @@ -21,7 +21,7 @@ export function formatPathTooltip(path?: string, additionalContent?: string): st const formattedPath = removeLeadingNonAlphanumeric(path) + "\u200E" if (additionalContent) { - return formattedPath + additionalContent + return formattedPath + " " + additionalContent } return formattedPath